18 Commits

Author SHA1 Message Date
Bartłomiej Janczak
043920836d Optimize redirect request lifecycle, bypass web middleware group, add stateful /s-auth/{key} route and fix test suites 2026-06-06 09:35:42 +02:00
Bartłomiej Janczak
9989fb938b Update issue templates 2026-06-06 08:39:33 +02:00
Bartłomiej Janczak
73b8b875a8 video add 2026-06-06 08:35:44 +02:00
Bartłomiej Janczak
9493431017 docs: upgrade screenshots to version v3 and clean up old v2 files 2026-06-06 08:29:54 +02:00
Bartłomiej Janczak
822a3e6fa3 docs: use github hosted asset URL for inline video walkthrough 2026-06-06 08:16:06 +02:00
Bartłomiej Janczak
5b2eb35a0b docs: use markdown video syntax and optimize video file size 2026-06-06 08:12:53 +02:00
Bartłomiej Janczak
964a7364ef docs: add video walkthrough preview to README 2026-06-06 08:02:33 +02:00
Bartłomiej Janczak
ba601dcf1e Update issue templates 2026-06-06 05:18:51 +02:00
Bartłomiej Janczak
e4ef194420 docs: add GitHub Sponsors funding configuration 2026-06-06 05:13:00 +02:00
Bartłomiej Janczak
53a3c1138d Update project license to Custom Dual-Commercial License 2026-06-06 03:59:51 +02:00
Bartłomiej Janczak
4cef5de366 Update project license to Custom Dual-Commercial License 2026-06-06 03:59:12 +02:00
Bartłomiej Janczak
e0602e80ef fix: correct comparison table with accurate competitor feature data
- Dub.co has A/B testing (they DO have it)
- Dub.co is open-source/partial self-hosted (AGPLv3, but needs Tinybird+PlanetScale)
- Bitly has deep linking on Premium/Enterprise (not completely missing)
- Bitly has webhooks on Enterprise (was missing)
- Rebrandly has retargeting pixels, deep linking, webhooks on paid plans
- Fixed Rebrandly pricing (zsh-49+ not zsh-499+)
- Added 'Filament/Laravel admin panel' row as unique differentiator
2026-06-06 02:57:21 +02:00
Bartłomiej Janczak
3592828ab3 feat: replace external logo with custom logo-fism.png 2026-06-06 02:52:47 +02:00
Bartłomiej Janczak
7082db495d fix: correct PHP to 8.3+ and Laravel to 11+ in README 2026-06-06 02:30:18 +02:00
Bartłomiej Janczak
f73803017f docs: README overhaul — natural language, SEO, comparison table, fixed requirements
- Rewrote intro: clear one-sentence summary + one-paragraph feature overview
- Removed AI-sounding 'Why choose' bullet list, replaced with prose
- Fixed duplicate emojis (two , two 🎯, two 🛡️, two 🎨, two 🔗, two 📊)
- Removed old '(new in v1.x.0)' markers from feature list
- Added vs. Bitly / Dub.co / Rebrandly comparison table (SEO: competitor keywords)
- Compressed 'Publishing Package Assets' from 4 h3 headers into a single code block
- Corrected PHP 8.3+ → 8.5+, Laravel 11+ → 13+
- Improved alt text on all screenshot img tags
- Better subtitle tagline under H1 for GitHub SEO
2026-06-06 02:28:54 +02:00
Bartłomiej Janczak
2107a3e6c8 feat: v5.0.0 — Live Feed page, Folders & Tags, Link Archiving, API scopes
## Analytics — Live Activity Feed
- Dedicated /stats/live standalone page (mirrors /stats/logs pattern)
- Consistent 3-tab navigation across Statistics / Live Feed / Visit Logs
- checkForUpdates() with skipRender(): O(1) MAX(id) poll — ~100-byte response on no-change
- latestVisitId sentinel (-1) prevents unnecessary first-poll re-render
- Cache key versioned by latestVisitId — fixes bug where concurrent users
  could permanently miss a visit due to stale thundering-herd cache
- 3-second thundering-herd cache (key: url_id + date + filters + latestVisitId)
- Country flags via flagcdn.com (precomputed in PHP, Alpine.js CSP-safe onerror)
- time_ago precomputed in PHP — no Carbon::parse() double-instantiation in Blade
- SELECT only 12 needed columns instead of SELECT *; limit 25 rows
- wire:poll.5s.visible — polling stops when widget is off-screen

## Link Organization
- Folders: one folder per link, clickable → filtered link list, link count badge
- Tags: up to 5 tags per link, clickable → filtered link list, link count badge
- Archiving: soft-archive links instead of permanent deletion; restorable

## REST API
- Per-key API scopes: links:read-only (GET) / links:read-write (full CRUD)
- Per-key rate limiting: individual requests/minute per API key

## Translations
- Added stats_live_feed_poll_interval (EN: 'Updates every 5s', PL: 'Aktualizacja co 5s')

## Docs
- README.md: corrected API scopes/rate-limiting docs (features were already implemented)
- README.md: added Folders, Tags, Archiving to features list
- README.md: added v5.0.0 changelog entry
2026-06-06 02:16:07 +02:00
Bartłomiej Janczak
aa05f026b5 feat: speed optimization, portability fixes, and documentation update for DNS resolution 2026-06-05 09:15:04 +02:00
Bartłomiej Janczak
15d992c5ad v3.3.0: Advanced Multi-Filter Targeting, Strict API Validation, Clean Configs, UX Improvements 2026-06-04 15:45:14 +02:00
162 changed files with 15562 additions and 4354 deletions

2
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,2 @@
# GitHub Sponsors
github: [janczakb]

38
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,38 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.

View File

@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

66
LICENSE
View File

@@ -1,22 +1,58 @@
Custom Source-Available License
Custom Source-Available & Dual-Commercial License
Copyright (c) 2026 Bartek Janczak
Copyright (c) 2026 Bartek Janczak. All rights reserved.
TERMS AND CONDITIONS
IMPORTANT: PLEASE READ THIS LICENSE AGREEMENT CAREFULLY. BY DOWNLOADING, INSTALLING, OR USING THE SOFTWARE, YOU AGREE TO BE BOUND BY ALL THE TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT DOWNLOAD, INSTALL, OR USE THE SOFTWARE.
1. Grant of License
Subject to the terms of this License, the Copyright Holder hereby grants you a worldwide, royalty-free, non-exclusive, non-sublicensable license to:
- Use the Software for personal, internal, or commercial purposes.
- Modify the Software for your own personal, internal, or commercial use.
-------------------------------------------------------------------------------
1. DEFINITIONS
-------------------------------------------------------------------------------
"Software" means the "Short URL Manager" PHP/Filament package, including all source code, modules, documentation, migrations, updates, and modified versions thereof.
"Copyright Holder" means Bartek Janczak.
"Commercial Distribution" means selling, renting, leasing, sublicensing, or otherwise distributing the Software (in whole or in part, or embedded into a larger work, boilerplate, theme, SaaS platform, or application) to third parties for monetary gain, fees, or any commercial consideration.
"Internal Commercial Use" means deploying and using the Software within your own business operations or building a custom website/system for a single client, where the client does not resell the underlying code.
2. Restrictions
You are strictly prohibited from:
- Redistributing the Software, in whole or in part, or any modified versions thereof, in any public repository (including but not limited to public repositories on GitHub, GitLab, Bitbucket) or package registry (such as Packagist).
- Selling, renting, leasing, or sublicensing the Software or any modified versions thereof as a standalone package or library.
- Publishing, distributing, or sharing the Software under a different name or package name.
-------------------------------------------------------------------------------
2. GRANT OF FREE LICENSE (NON-DISTRIBUTION ONLY)
-------------------------------------------------------------------------------
Subject to compliance with Section 3 (Restrictions), the Copyright Holder hereby grants you a worldwide, royalty-free, non-exclusive, non-sublicensable license to:
a) Download, install, and use the Software free of charge for personal, educational, internal business purposes, or Internal Commercial Use across an unlimited number of projects.
b) Modify the Software solely for your own private, internal, or Internal Commercial Use, provided that any such modifications are kept private and are never distributed publicly.
3. Copyright Notice
The above copyright notice and this permission notice must be included in all copies or substantial portions of the Software.
-------------------------------------------------------------------------------
3. STRICT RESTRICTIONS & PROHIBITIONS
-------------------------------------------------------------------------------
Unless you have obtained an explicit, written Commercial License from the Copyright Holder pursuant to Section 4, you are STRICTLY PROHIBITED from:
a) Public Redistribution: Publishing, hosting, or sharing the Software, in whole or in part, or any modified versions thereof, in any public repository (including but not limited to public repositories on GitHub, GitLab, Bitbucket) or public package registries (such as Packagist).
b) Commercial Exploitation & Bundling: Engaging in any Commercial Distribution. You may not bundle, embed, or include the Software, in whole or in part, or any modified versions thereof, into any product, theme, extension, boilerplate, framework, or software application that is offered for sale, license, subscription, or commercial distribution to third parties.
c) Relabeling: Publishing, distributing, or sharing the Software under a different author, vendor, or package name.
d) Sublicensing: Granting any third party the right to resell, redistribute, or further sublicense the Software.
4. Disclaimer of Warranty
-------------------------------------------------------------------------------
4. DUAL-LICENSING & COMMERCIAL LICENSING REQUIREMENT
-------------------------------------------------------------------------------
Any individual, entity, or organization wishing to engage in Commercial Distribution, or use the Software in a manner not expressly permitted by Section 2, MUST secure a separate, written Commercial License from the Copyright Holder prior to such use or distribution.
To inquire about purchasing a Commercial License or to request written authorization, please contact the Copyright Holder via email at: barek122@gmail.com
Commercial Licenses may be subject to a licensing fee, revenue-sharing model, or other terms as agreed upon in writing by the Copyright Holder. Any Commercial Distribution attempted without a valid, active, written Commercial License from the Copyright Holder is null and void, constitutes a material breach of this Agreement, and is a direct infringement of international copyright laws.
-------------------------------------------------------------------------------
5. INTELLECTUAL PROPERTY & COPYRIGHT NOTICE
-------------------------------------------------------------------------------
The Software is licensed, not sold. The Copyright Holder retains all right, title, and interest in and to the Software, including all copyrights, patents, trade secrets, and other intellectual property rights. The original copyright notice, author attribution, and this entire permission notice must be included in all copies, substantial portions, or private forks of the Software.
-------------------------------------------------------------------------------
6. TERMINATION
-------------------------------------------------------------------------------
This License and your right to use the Software will terminate automatically and immediately, without notice from the Copyright Holder, if you fail to comply with any of its terms or restrictions. Upon termination, you must immediately cease all use of the Software and destroy all copies, full or partial, in your possession.
-------------------------------------------------------------------------------
7. GOVERNING LAW AND JURISDICTION
-------------------------------------------------------------------------------
This License Agreement shall be governed by, and construed in accordance with, the substantive laws of Poland, without regard to its conflict of laws principles. Any legal action, suit, or proceeding arising out of or relating to this License shall be instituted exclusively in the competent courts located in Warsaw, Poland.
-------------------------------------------------------------------------------
8. DISCLAIMER OF WARRANTY
-------------------------------------------------------------------------------
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

614
README.md
View File

@@ -1,11 +1,13 @@
<p align="center">
<img src="https://cdn3d.iconscout.com/3d/premium/thumb/url-3d-icon-png-download-9292351.png" width="180" alt="Filament Short URL Logo">
<img src="art/logo-fism.png" width="180" alt="Filament Short URL — Laravel link shortener plugin for Filament">
</p>
<h1 align="center">Filament Short URL</h1>
<p align="center"><strong>The most complete open-source link management plugin for Laravel & Filament.</strong><br>Short URLs · QR codes · Analytics · Targeting · Deep links · Webhooks · REST API</p>
<p align="center">
<a href="https://packagist.org/packages/janczakb/filament-short-url"><img src="https://img.shields.io/packagist/v/janczakb/filament-short-url.svg?style=flat-square" alt="Latest Version"></a>
<a href="https://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 on Packagist"></a>
<a href="https://github.com/janczakb/filament-short-url/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-proprietary-7c3aed.svg?style=flat-square" alt="License"></a>
<a href="https://packagist.org/packages/janczakb/filament-short-url"><img src="https://img.shields.io/packagist/dt/janczakb/filament-short-url.svg?style=flat-square" alt="Total Downloads"></a>
<a href="https://github.com/janczakb/filament-short-url/stargazers"><img src="https://img.shields.io/github/stars/janczakb/filament-short-url.svg?style=flat-square" alt="GitHub Stars"></a>
@@ -13,31 +15,33 @@
<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, Redirect Engine & QR Code Generator** plugin for [Filament v5](https://filamentphp.com).
A self-hosted **URL shortener, redirect engine, and QR code manager** built as a [Filament v5](https://filamentphp.com) plugin. Drop it into any Laravel 11+ application to replace Bitly, Dub.co, or Rebrandly subscriptions — with full data ownership, no click limits, and no monthly fees.
Acting as a self-hosted, enterprise-grade alternative to Bitly and Rebrandly, this package provides advanced link shortening, mobile app deep linking (Universal Links & Android App Links), client-side retargeting pixels, offline Geo-IP country routing, real-time analytics, and webhooks—with zero external API dependencies.
On top of basic link shortening it ships: multi-channel analytics with live activity feed, cross-filtering, and cross-domain retargeting pixels; advanced routing rules (device, country, language, A/B); mobile deep linking into 24+ native apps; a REST API with scoped keys; HMAC-signed webhooks; and offline GDPR-safe Geo-IP — all managed from a polished Filament admin panel.
### Why choose Filament Short URL?
* **Save on SaaS Costs**: Replace expensive Bitly or Rebrandly subscriptions with a self-hosted solution that has zero click or creation limits.
* **Server-Side GA4 Tracking**: Bypasses browser-side ad blockers completely to ensure 100% accurate traffic data.
* **Retarget on External Sites**: Inject tracking pixels (Meta, Google Ads, LinkedIn, TikTok, Pinterest) on a beautiful glassmorphic redirect page before forwarding visitors.
* **Seamless Mobile App Redirects**: Launch native mobile applications (Instagram, YouTube, Spotify, WhatsApp) automatically using deep links.
* **GDPR-Friendly Geo-IP**: Resolve visitor countries offline using local MaxMind databases or trust your CDN headers (Cloudflare, CloudFront).
## Video Walkthrough
<p align="center">
<video src="https://github.com/user-attachments/assets/edeca20a-2265-435e-a7f3-a32773c48bda" width="100%" autoplay loop muted controls style="max-width: 100%; border-radius: 8px;"></video>
</p>
---
## Screenshots
<p align="center">
<table align="center" style="border-collapse: collapse; border: none;">
<tr style="border: none;">
<td width="50%" style="border: none; padding: 5px;"><img src="art/v2_1.png" alt="Dashboard Stats" style="border-radius: 8px;"></td>
<td width="50%" style="border: none; padding: 5px;"><img src="art/v2_2.png" alt="Short URL Creation Form" style="border-radius: 8px;"></td>
<td width="50%" style="border: none; padding: 5px;"><img src="art/v3_1.png" alt="Analytics dashboard" style="border-radius: 8px;"></td>
<td width="50%" style="border: none; padding: 5px;"><img src="art/v3_2.png" alt="Short URL creation form" style="border-radius: 8px;"></td>
</tr>
<tr style="border: none;">
<td width="50%" style="border: none; padding: 5px;"><img src="art/v2_3.png" alt="Targeting and Rules" style="border-radius: 8px;"></td>
<td width="50%" style="border: none; padding: 5px;"><img src="art/v2_4.png" alt="Interactive Settings Panel" style="border-radius: 8px;"></td>
<td width="50%" style="border: none; padding: 5px;"><img src="art/v3_3.png" alt="Smart targeting rules" style="border-radius: 8px;"></td>
<td width="50%" style="border: none; padding: 5px;"><img src="art/v3_4.png" alt="Settings panel" style="border-radius: 8px;"></td>
</tr>
<tr style="border: none;">
<td colspan="2" style="border: none; padding: 5px;"><img src="art/v2_5.png" alt="Visit Logs Table" style="border-radius: 8px; width: 100%;"></td>
<td colspan="2" style="border: none; padding: 5px;"><img src="art/v3_5.png" alt="Visit logs table" style="border-radius: 8px; width: 100%;"></td>
</tr>
</table>
</p>
@@ -46,33 +50,66 @@ Acting as a self-hosted, enterprise-grade alternative to Bitly and Rebrandly, th
## Features
- 🔗 **Base62 Short Link Generation**Create clean, custom short links or let the system auto-generate collision-free Base62 keys.
- 🌍 **Multiple Geo-IP Drivers** — Route and analyze traffic with offline MaxMind detection, CDN edge headers (Cloudflare's `CF-IPCountry`, CloudFront), or fallback APIs.
- 🗺️ **Interactive Visitor World Map** — Showcase geographic click distribution on a beautiful SVG world map widget with real-time hover details.
- 📈 **Comprehensive Analytics Dashboard** — Monitor total/unique visits, referrers, operating systems, devices, browsers, and top browser languages in real-time.
- 🛡️ **VPN, Proxy & Bot Filtering** — Exclude scrapers, crawlers, Tor exit nodes, and automated bot clicks to keep your analytics clean and accurate.
- 🔍 **Google Safe Browsing** — Automatically scan target URLs on creation/edit to block phishing, malware, and social engineering links.
- 🎨 **SVG QR Code Designer** — Customize dot styles, gradients, margins, and upload custom brand logos with auto-clear backing dots and high-quality SVG/PNG exports.
- 📊 **Dedicated QR Code Tracking** — Differentiate physical QR scans from direct web clicks with automatically appended query parameters (`?source=qr`).
- 🌐 **Browser Language Targeting** — Route visitors dynamically based on their browser language preferences (e.g., redirect Polish speakers to a Polish landing page).
- **Ultra-Fast Redirections** — Redirections resolve in milliseconds. Logging, GA4 payloads, and webhooks are processed asynchronously in the background.
- 🎯 **Server-Side GA4 Integration** — Send server-side `short_url_visit` hits using the Google Analytics 4 Measurement Protocol to bypass ad-blockers.
- ⚙️ **Dual-way UTM Builder** — Build campaign URLs with a real-time synchronized UTM builder directly in the link creation form.
- 🔒 **Link Expiration & Fallbacks** — Set activation date ranges, click-limit caps, single-use restrictions, and custom redirection fallbacks on expiration.
- ➡️ **Query Parameter Forwarding** — Automatically forward client query strings (e.g., ad tokens, UTM parameters, discount codes) to the destination.
- 🛠️ **Central Settings Panel** — Manage routes, Geo-IP, GA4, caching, rate limiting, and retention directly inside your Filament panel.
- 🔑 **Password-Protected Links** — Secure sensitive links with a customizable, session-based password entry interstitial.
- ⚠️ **Redirect Warning Interstitials** — Show a security warning page to verify external links (phishing and NSFW protection).
- 🎯 **Advanced Smart Targeting** — Redirect visitors dynamically based on device type (iOS, Android, Desktop), country (Geo-IP), or browser language.
- **A/B Split Testing Rotation** — Distribute traffic randomly across multiple landing pages using custom weighted rotation rules.
- 🛡️ **Throttling & Rate Limiting**Protect your redirection routes from flood attacks with configurable per-IP rate limits.
- 📊 **Log Aggregation & Pruning** — Compact millions of raw visit logs into daily summaries automatically to prevent database bloat.
- 🎯 **Central Retargeting Pixel Registry (new in v3.0.0)** — Register Meta Pixel, Google Tag, LinkedIn Insight, TikTok Pixel, and Pinterest Tag centrally and associate them with links via checkboxes.
- 🔌 **Developer REST API (updated in v3.2.0)** — Full programmatical control with secure API Key authentication to create, read, update, list, delete, and inspect analytics for short links externally.
- 📡 **Real-Time Webhooks** — Asynchronous HTTP POST notifications on `visited`, `created`, `expired`, and `limit_reached` events with a built-in retry policy.
- 📱 **Mobile App Deep Linking (new in v3.0.0)** — Detect mobile visitors and open links directly in 24+ native apps (Instagram, YouTube, Spotify, TikTok, etc.) using custom URI schemes.
- 🔗 **Universal Links & App Links (new in v3.0.0)** — Host iOS `apple-app-site-association` and Android `assetlinks.json` domain configuration files directly from your root domain.
- 🎨 **Branded Expiry Pages (new in v3.0.0)** — Display a premium, dark-mode compatible custom expiry page when a link is deactivated or limit-reached, falling back to a clean site-name greeting instead of a generic browser error.
- 🔗 **Short Link Generation**Auto-generate collision-free Base62 keys or set your own custom slugs.
- 🌐 **Custom Domain Branding** — Register your own domains, verify DNS in real-time (A/CNAME), and serve links directly from the domain root — no `/s/` prefix needed.
- 🌍 **Multiple Geo-IP Drivers** — Resolve visitor countries offline via MaxMind, from CDN headers (Cloudflare `CF-IPCountry`, CloudFront), or via ip-api.com fallback.
- 🗺️ **Visitor World Map** — Visualize click distribution on an interactive SVG world map with per-country hover stats.
- 📈 **Full Analytics Dashboard** — Track total/unique visits, referrers, devices, browsers, OSes, and browser languages. Fully cross-filterable (e.g., "only mobile visits from Poland in May").
- **Live Activity Feed** — Standalone real-time visit stream at `/stats/live`. Uses `MAX(id)` polling — returns a ~100-byte response when nothing changed, full render only on new visits.
- 🗂️ **Folders & Tags** — Assign each link to a folder and up to 5 tags. Click any folder or tag to navigate to a filtered link list.
- 🗄️ **Link Archiving** — Soft-archive links instead of deleting them. Archived links are hidden by default and fully restorable.
- 🛡️ **VPN, Proxy & Bot Filtering** — Filter out Tor nodes, VPNs, anonymous proxies, and known bot user agents from visit stats.
- 🔍 **Google Safe Browsing** — All target URLs are checked against Google's threat database on create and edit.
- 🎨 **SVG QR Code Designer** — Full dot style, gradient, margin, and logo customization. Export as SVG or high-resolution PNG.
- 📊 **QR Scan Tracking** — QR scans are recorded separately from regular web clicks via `?source=qr`. Shown as a distinct metric in analytics.
- ✈️ **Browser Language Targeting** — Route visitors to different destinations based on their browser's `Accept-Language` header.
- 🚀 **Ultra-Fast Redirects** — Sub-15ms redirect response time. Bypasses the heavy Laravel session/cookie middleware group for standard links, falling back to a stateful route dynamically only when password protection is active.
- 🎯 **Server-Side GA4** — Send `short_url_visit` events via GA4 Measurement Protocol, completely bypassing browser ad-blockers.
- ⚙️ **UTM Builder** — Build and preview campaign URLs with a real-time UTM form that syncs bidirectionally with the destination URL field.
- 🔒 **Link Expiration & Caps** — Set `activated_at`, `expires_at`, `max_visits`, single-use mode, and a custom fallback URL for when any of these conditions are met.
- ➡️ **Query Parameter Forwarding** — Automatically append incoming query strings (UTM parameters, ad tokens, discount codes) to the destination.
- 🛠 **Settings Panel** — All configuration in a dedicated Filament settings page — no `.env` editing required for day-to-day changes.
- 🔑 **Password-Protected Links**Visitors enter a password before being redirected. Session-persisted, so they only see the prompt once.
- ⚠️ **Redirect Warning Pages** — Show a confirmation screen before sending visitors off to external domains.
- 🎲 **A/B Split Testing** — Distribute traffic across 25 weighted variants. Works at the root level or nested inside targeting rules.
- 📉 **Log Aggregation & Pruning** — A nightly command aggregates raw visits into daily summaries and prunes records older than the configured retention window. Keeps the database lean even at millions of visits.
- 🏷️ **Retargeting Pixel Registry** — Define Meta Pixel, Google Tag, LinkedIn Insight, TikTok, and Pinterest pixels once, then attach them to any link via a simple checkbox list.
- 🔌 **REST API with Scoped Keys** — Full CRUD API, SHA-256 hashed key storage, `links:read-only` / `links:read-write` scopes, and per-key rate limits.
- 📡 **Webhooks** — HMAC-SHA256 signed HTTP POST callbacks on `visited`, `created`, `expired`, and `limit_reached` events. Dispatched asynchronously with 3-attempt exponential retry.
- 📱 **Mobile Deep Linking** — Detect mobile visitors and launch the link directly inside 24+ native apps (Instagram, YouTube, Spotify, TikTok, WhatsApp, etc.).
- 🍎 **Universal Links & Android App Links** — Serve `apple-app-site-association` and `assetlinks.json` directly from your domain to enable OS-level native app integration.
- 💀 **Branded Expiry Pages** — When a link expires or hits its limit, visitors see a styled page with your site name instead of a bare 410 error.
- 👤 **User Attribution** — Each link records its creator. Avatar and name/email hover card visible in the table.
- 🕒 **Relative Time Badges** — Compact timestamps (`2h`, `5d`, `3mo`) with exact date on hover.
- ⌨️ **Keyboard Shortcuts**`E` edit, `Q` QR, `I` copy, `S` stats, `X` delete — available on any row hover.
-**Unified Action Menu** — All row actions in one 3-dot dropdown with shortcut badges.
---
## vs. Bitly, Dub.co & Rebrandly
| Feature | Filament Short URL | Bitly | Dub.co | Rebrandly |
|---|:---:|:---:|:---:|:---:|
| Self-hosted | ✅ | ❌ | partial¹ | ❌ |
| Unlimited links & clicks | ✅ | ❌ | ❌ | ❌ |
| Custom domains | ✅ | 💰 | 💰 | 💰 |
| QR code designer | ✅ | 💰 basic | 💰 basic | 💰 |
| A/B split testing | ✅ | ❌ | ✅ | ❌ |
| Retargeting pixels | ✅ 5 providers | ❌ | ❌ | 💰 |
| Mobile deep linking | ✅ 24+ apps | 💰 Enterprise | 💰 partial | 💰 |
| Server-side GA4 (ad-block bypass) | ✅ | ❌ | ❌ | ❌ |
| Live activity feed | ✅ | ❌ | ✅ | ❌ |
| Cross-filtering analytics | ✅ | 💰 basic | partial | 💰 basic |
| REST API | ✅ scoped keys | 💰 | ✅ | 💰 |
| Webhooks | ✅ HMAC-signed | 💰 Enterprise | ✅ | 💰 |
| Offline GDPR Geo-IP (MaxMind) | ✅ | ❌ | ❌ | ❌ |
| VPN & bot filtering | ✅ | ❌ | ❌ | ❌ |
| Google Safe Browsing on save | ✅ | ❌ | ❌ | ❌ |
| Filament/Laravel admin panel | ✅ | ❌ | ❌ | ❌ |
| Data stays on your server | ✅ | ❌ | partial¹ | ❌ |
| Monthly cost | **$0** | $0$199+ | $0$190+ | $0$49+ |
> 💰 = paid plans only &nbsp;·&nbsp; ¹ Dub.co is open-source (AGPLv3) but self-hosting requires external managed services (Tinybird, PlanetScale, Upstash)
---
@@ -103,48 +140,32 @@ php artisan migrate
## Publishing Package Assets
Customize and override views, translations, or configuration files by publishing the package assets:
All assets are optional. Publish only what you need to customize:
### 1. Publish Config File
Copies the default config file to `config/filament-short-url.php`:
```bash
# Config file → config/filament-short-url.php
php artisan vendor:publish --tag=filament-short-url-config
```
### 2. Publish Translation Files
Copies localization files to `lang/vendor/filament-short-url/` (English and Polish included by default):
```bash
# Translations → lang/vendor/filament-short-url/ (EN + PL included)
php artisan vendor:publish --tag=filament-short-url-translations
```
### 3. Publish Blade Views & Templates
Copies the dashboard components, charts, and QR designer templates to `resources/views/vendor/filament-short-url/`:
```bash
# Blade views (dashboard, QR designer, interstitials)
php artisan vendor:publish --tag=filament-short-url-views
```
### 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
# Pre-compiled CSS → public/css/janczakb/filament-short-url/
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.
The CSS file is pre-compiled and bundled in the package — you don't need to run `npm` or Tailwind for it.
> **You do not need to run Tailwind, Vite, or npm for the plugin styles.** The compiled file is included in the package.
>
> **Using a Custom Filament Theme (Tailwind CSS v4)?**
> If you are compiling your own Filament panel theme stylesheet, you can optionally tell Tailwind to scan the plugin's views by adding the `@source` directive to your theme's CSS file (e.g., `resources/css/filament/admin/theme.css`):
>
> ```css
> @source './vendor/janczakb/filament-short-url/resources/views/**/*.blade.php';
> ```
**If you compile your own Filament theme** (Tailwind CSS v4), add an `@source` directive so Tailwind scans the plugin views:
**Tip — automate on every `composer install` / `composer update`:**
```css
/* resources/css/filament/admin/theme.css */
@source './vendor/janczakb/filament-short-url/resources/views/**/*.blade.php';
```
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:
**Tip:** Add `filament:assets` to `post-autoload-dump` in your `composer.json` so CSS stays up to date on every `composer install`:
```json
"scripts": {
@@ -244,6 +265,9 @@ The package comes with a built-in admin settings dashboard. It is accessible dir
Settings are stored dynamically in the database (`short_url_settings` table), cached indefinitely, and immediately override config defaults. Legacy settings from `filament-short-url-settings.json` are automatically imported on first load.
> [!NOTE]
> **Modular Tab Architecture (New in v3.5.0)**: The settings form is split into 8 independent, single-responsibility tab classes (`LinkTab`, `TargetingTab`, `TrackingTab`, `SecurityTab`, `GeoIpTab`, `VpnDetectionTab`, `QrDesignTab`, `PixelsTab`) to reduce load times, improve code maintainability, and allow clean tab query parameters navigation (e.g. `?tab=qr`).
The settings panel allows you to configure:
### 1. General Routing & Queueing
@@ -306,6 +330,9 @@ $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.
> [!NOTE]
> **Stateful Route Transition (v5.1.0+)**: To achieve ultra-fast (<15ms) redirects for normal links, the main `/s/{key}` path completely bypasses Laravel's session/cookie `web` middleware group. If password protection is detected, the engine dynamically redirects the visitor to a stateful `/s-auth/{key}` route where sessions are loaded and the password prompt is served.
---
## Redirect Warning Pages (new in v1.2.0)
@@ -322,6 +349,26 @@ This feature is useful for NSFW links, external partner links, or any URL that l
---
## Security & Anti-Fraud v2.0 (new in v1.6.0)
Protect your application redirection routes and visitor data from malicious activities and automated scrapers.
### 1. VPN & Proxy Detection
Filter out anonymous proxy, VPN, or Tor connections to ensure clean analytics and prevent abuse.
* **Driver Selection**: Choose between the free **IP-API** service (default) or the premium **VPNAPI.io** service (requires setting an API key).
* **Configurable Action**:
* **Flag Only**: Flags VPN/Proxy visits in database statistics for inspection but allows the redirection to continue.
* **Block Traffic**: Actively blocks the request, serving a `403 Forbidden` response to the client.
* **Verify Key**: An interactive "Verify connection" action is available in settings to check your API credentials.
### 2. Google Safe Browsing URL Verification
Scan and verify all user-provided target URLs against Google's safe browsing lookup API on creation and edit.
* **Protection**: Blocks malware, phishing, and social engineering domains.
* **Filament UI**: Displays a clean status badge and blocks form saving if the target URL is flagged as unsafe.
* **Verify Key**: Includes a "Test API Connection" action on the settings dashboard to validate your Safe Browsing API credentials.
---
## Custom Branded Expiry Pages (new in v3.0.0)
When a short URL is expired, deactivated, or has reached its maximum visit limit, it needs to handle the redirect gracefully:
@@ -334,86 +381,130 @@ When a short URL is expired, deactivated, or has reached its maximum visit limit
---
## Smart Link Targeting (new in v1.2.0)
## Smart Link Targeting (updated in v3.5.0)
The **Targeting & Security** tab exposes a powerful rule engine that lets you route different visitors to different destinations — all from a single short URL.
### Available Strategies
You can configure multiple rules evaluated sequentially from top to bottom. Each rule contains:
- A **Target URL** (the redirect destination if rule matches).
- A **Match Strategy**: `AND` (all filters must match) or `OR` (any filter can match).
- A list of **Filters**:
- **Device**: Filter by `desktop`, `mobile`, `tablet`.
- **Platform**: Filter by `windows`, `mac`, `linux`, `ios`, `android`, `fire_os`.
- **Country**: Filter by country codes (e.g. `PL`, `US`, `DE`) with flags display.
- **Language**: Filter by preferred browser language codes (e.g. `pl`, `en`, `de`).
#### 1. Device-Based Redirects
Route visitors to different URLs based on their device type (detected from User-Agent):
### Multi-Filter JSON Schema (v3.3.0+)
| Device | Detected by User-Agent containing |
|--------|-----------------------------------|
| iOS (Mobile) | `iphone`, `ipad`, `ipod` |
| Android | `android` |
| Desktop | Everything else |
```php
// Programmatic example
$shortUrl->update([
'targeting_rules' => [
'type' => 'device',
'device' => [
'ios' => 'https://apps.apple.com/your-app',
'android' => 'https://play.google.com/your-app',
'desktop' => 'https://example.com/download',
],
],
]);
```
#### 2. Country-Based (Geo-IP) Redirects
Route visitors to country-specific URLs. Requires Geo-IP to be enabled in settings. Falls back to the default `destination_url` for unlisted countries.
For programmatic or REST API updates, pass an array of rules to `targeting_rules`:
```php
$shortUrl->update([
'targeting_rules' => [
'type' => 'geo',
'geo' => [
['country_code' => 'PL', 'url' => 'https://pl.example.com'],
['country_code' => 'US', 'url' => 'https://us.example.com'],
['country_code' => 'DE', 'url' => 'https://de.example.com'],
[
'match' => 'and',
'url' => 'https://ios-pl.example.com',
'filters' => [
[
'type' => 'platform',
'data' => ['platforms' => ['ios']]
],
[
'type' => 'language',
'data' => ['languages' => ['pl']]
]
]
],
],
[
'match' => 'or',
'url' => 'https://fallback-mobile.example.com',
'filters' => [
[
'type' => 'device',
'data' => ['devices' => ['mobile', 'tablet']]
]
]
]
]
]);
```
#### 3. Browser Language-Based Redirects (new in v2.0.5)
Route visitors to language-specific URLs based on their browser's language preferences (detected from the `Accept-Language` header). Fully supports matching exact regional locales (e.g., `en-US`, `en-GB`) with automatic fallback to base language codes (e.g., `en`, `pl`).
### Supported Filter Options & Formats
```php
$shortUrl->update([
'targeting_rules' => [
'type' => 'language',
'language' => [
['language_code' => 'pl', 'url' => 'https://pl.example.com'],
['language_code' => 'en-US', 'url' => 'https://us.example.com'],
['language_code' => 'de', 'url' => 'https://de.example.com'],
],
],
]);
```
| Filter Type | Data Key | Allowed Values |
|-------------|----------|----------------|
| `device` | `devices` | `desktop`, `mobile`, `tablet` |
| `platform` | `platforms` | `windows`, `mac`, `linux`, `ios`, `android`, `fire_os` |
| `country` | `countries` | ISO-3166 2-letter country codes (e.g. `PL`, `US`, `DE`), case-insensitive |
| `language` | `languages` | ISO-639-1 language codes (e.g. `pl`, `en`, `de`), case-insensitive |
#### 4. A/B Split Rotation
Distribute traffic across multiple URLs using weighted random selection. Weights are proportional — they do not need to sum to 100.
### Legacy Strategies (v1.2.0 - v2.x)
```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.
If your database contains legacy single-strategy rules (e.g. `'type' => 'device'` or `'type' => 'geo'`), the plugin handles them automatically:
* **Redirection Engine**: The redirect system detects the legacy structure and processes it on-the-fly using the legacy strategy.
* **Filament UI**: When loading a link with legacy rules, the Filament Form automatically upgrades and hydrates them to equivalent new multi-filter rules.
---
## A/B Split Testing & Weighted Traffic Rotation (new in v3.5.0)
A single short URL can distribute traffic across 25 landing pages using configurable weights. This is useful for comparing conversion rates between different pages without changing the link you've already shared.
### Two ways to set it up
**Root-level split test** — Set the **Destination Type** to `A/B Split Test` directly on the short URL. All visitors hitting that link will be distributed across your variants.
**Nested inside a targeting rule** — Combine split testing with audience targeting. For example: send mobile visitors from Poland into a 50/50 test, while everyone else goes to a single fallback URL.
### Weights
Each variant gets a percentage weight. Weights must sum to exactly **100%** and can be adjusted in 1% increments. The admin form shows an interactive split bar — drag the handles or hit **"Balance weights"** to divide traffic evenly. When you add or remove a variant, weights are rebalanced automatically.
UTM parameters and other query strings on the original click are forwarded to whichever variant is selected.
### Analytics
Every visit records which variant was resolved (`selected_variant` column in the visit log). The link's statistics dashboard shows a **Variant Clicks Distribution** bar chart so you can compare actual click shares against your configured weights.
### REST API
For root-level split tests, pass `rotation_variants` in the request body:
```json
{
"destination_type": "split",
"rotation_variants": [
{ "label": "Variant A", "url": "https://example.com/page-a", "weight": 70 },
{ "label": "Variant B", "url": "https://example.com/page-b", "weight": 30 }
]
}
```
To nest a split test inside a targeting rule, use `variants` within the rule object:
```json
{
"destination_type": "single",
"destination_url": "https://example.com/default",
"targeting_rules": [
{
"match": "or",
"destination_type": "split",
"variants": [
{ "label": "Mobile A", "url": "https://example.com/mob-a", "weight": 50 },
{ "label": "Mobile B", "url": "https://example.com/mob-b", "weight": 50 }
],
"filters": [
{ "type": "device", "data": { "devices": ["mobile"] } }
]
}
]
}
```
---
## Native App Linking & Deep Linking (new in v3.0.0)
This package supports two distinct levels of mobile app integration: **Per-Link App Linking** (client-side redirects using custom schemes) and **Global Deep Linking Files** (domain association files for OS-level native integration).
@@ -583,8 +674,133 @@ $shortUrl->pixels()->sync([$pixelId1, $pixelId2]);
---
## Custom Domain Branding (new in v4.0.0)
Branded links (e.g. `go.company.com/abc123` instead of `yourdomain.com/s/abc123`) significantly increase click-through rates and build trust. This package includes a full-featured custom domain manager out-of-the-box.
### Key Features
1. **Dynamic CNAME/A Record Verification**: Resolves DNS records in real-time natively using PHP's `dns_get_record()` (with fallback to `dig`) and matches against the host server IP — supports both CNAME and A record setups.
2. **DNS Setup Instructions**: Step-by-step instructions for non-technical users to connect their domain in their registrar (GoDaddy, Cloudflare, Namecheap, etc.) directly in the Filament UI.
3. **Verified-Only Assignment**: Only domains that pass DNS verification can be associated with short URLs.
4. **Root-Level Redirections**: When a visitor hits a custom domain link (e.g. `go.company.com/abc123`), the request is matched directly at the root path — omitting the default `/s/` prefix automatically.
### How to use
1. Go to the **Custom Domains** page in the Filament admin panel (sidebar → Links → Custom Domains).
2. Click **Create** and enter your domain name (e.g. `go.company.com`).
3. Follow the DNS Setup instructions shown in the modal to point your DNS records to the application server.
4. Click **Setup DNS / Verify** inside the table actions. Once DNS resolves correctly, the domain status changes to **Active**.
5. In the Short URL creation form, select your custom domain from the **Custom Domain** dropdown. The generated short URL will use your branded domain automatically.
### DNS Setup (for users)
You need to add **one** of the following records at your DNS provider:
| Type | Name | Value |
|-------|--------------------|--------------------------------|
| CNAME | `go` (subdomain) | `yourdomain.com.` (main host) |
| A | `go` (subdomain) | `123.45.67.89` (server IP) |
The plugin dynamically resolves the host application's IP at verification time — no hardcoded IP configuration is required.
### Web Server Configuration (required)
Custom domains must be configured at the web server level to forward requests to your Laravel application. Below are example configurations:
#### nginx
```nginx
server {
listen 80;
listen 443 ssl;
server_name go.company.com;
# Proxy to your main Laravel app
location / {
proxy_pass http://127.0.0.1:80; # or your app server socket
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
Or, if the custom domain points directly to the same server running Laravel:
```nginx
server {
listen 80;
listen 443 ssl;
server_name go.company.com;
root /var/www/your-app/public;
index index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
}
```
#### Caddy
```caddyfile
go.company.com {
root * /var/www/your-app/public
php_fastcgi unix//run/php/php8.3-fpm.sock
file_server
}
```
#### Apache
```apache
<VirtualHost *:80>
ServerName go.company.com
DocumentRoot /var/www/your-app/public
<Directory /var/www/your-app/public>
AllowOverride All
Require all granted
</Directory>
</VirtualHost>
```
> **Note:** The Laravel application automatically detects the incoming `Host` header and routes the request to the correct short URL via the fallback route. No additional application configuration is needed beyond registering the domain in the Filament UI.
### Server Requirements
- DNS verification runs natively using PHP's built-in `dns_get_record()` function.
- If native DNS functions are disabled or fail, the plugin falls back to using the `dig` CLI tool (`apt install dnsutils` on Debian/Ubuntu, `yum install bind-utils` on CentOS/RHEL).
- If neither is available, the plugin falls back to `$_SERVER['SERVER_ADDR']` for A record matching.
- The DNS verification runs synchronously in the admin panel when the **Verify** button is clicked. For production use with many domains, consider wrapping it in a queued job.
### Database Schema
Custom domains are stored in the `short_url_custom_domains` table:
| Column | Type | Description |
|--------------|-----------|---------------------------------------------------|
| `id` | bigint | Primary key |
| `user_id` | bigint | Owner (nullable, references `users.id`) |
| `domain` | string | The domain name (e.g. `go.company.com`) |
| `is_verified`| boolean | Whether DNS verification passed |
| `is_active` | boolean | Whether the domain is enabled for use |
| `created_at` | timestamp | Creation timestamp |
| `updated_at` | timestamp | Last update timestamp |
Short URLs reference their custom domain via `custom_domain_id` (foreign key on `short_urls`).
---
## Developer REST API (new in v1.5.0)
The plugin exposes a REST API that allows external systems (CRMs, Zapier, Make, custom integrations) to manage short URLs programmatically.
### Enabling the API
@@ -609,9 +825,26 @@ For security, new API keys are hashed using SHA-256 and stored securely in the d
> If the API is disabled globally, all endpoints return `503 Service Unavailable` regardless of the key provided.
### API Key Scopes
Each API key can be assigned a **scope** that restricts its access level:
| Scope | Allowed Methods | Use Case |
|---|---|---|
| `links:read-write` | `GET`, `POST`, `PUT`, `PATCH`, `DELETE` | Full control (default) |
| `links:read-only` | `GET` only | Integrations that only need to read link data (e.g., dashboards, reporting tools) |
A `links:read-only` key attempting a write operation (`POST`, `PUT`, `PATCH`, `DELETE`) will receive a `403 Forbidden` response.
### Per-Key Rate Limiting
Each API key can have its own **individual rate limit** (requests per minute), independent of the global route throttle. This allows high-trust integrations to use a higher limit while restricting untrusted keys more aggressively.
Configure the rate limit for each key in **Settings → API & Webhooks → Developer API Keys**. The default is **60 requests per minute**.
### Endpoints
All endpoints are prefix-grouped under `/api/short-url/` and are protected by the API Key middleware and rate-limited to **60 requests per minute** (`throttle:60,1`).
All endpoints are prefix-grouped under `/api/short-url/` and are protected by the API Key middleware. The effective rate limit is the lower of the global route throttle and the per-key rate limit.
#### `GET /api/short-url/links`
List all short URLs (paginated, 30 per page).
@@ -748,23 +981,24 @@ curl -X PATCH https://yourdomain.com/api/short-url/links/promo26 \
| Field | Type | Required (POST) | Description |
|---|---|---|---|
| `destination_url` | string (URL) | ✅ | Target URL (max 2048 chars) |
| `custom_domain_id` | integer | ❌ | ID of a verified custom domain record |
| `url_key` | string | ❌ | Custom unique slug/key (max 32 chars, alpha-dash; auto-generated if omitted) |
| `notes` | string | ❌ | Internal admin notes (max 1000 chars) |
| `notes` | string | ❌ | Internal admin notes (max 255 chars) |
| `is_enabled` | boolean | ❌ | Active status (default: `true`) |
| `redirect_status_code` | integer (301/302) | ❌ | HTTP redirect status code |
| `single_use` | boolean | ❌ | Expire the short link immediately after the first visit |
| `forward_query_params` | boolean | ❌ | Forward visitor query parameters to target destination |
| `max_visits` | integer | ❌ | Maximum click threshold limit |
| `expiration_redirect_url` | string (URL) | ❌ | Fallback URL to redirect to upon link expiration |
| `expiration_redirect_url` | string (URL) | ❌ | Fallback URL to redirect to upon link expiration (max 255 chars) |
| `activated_at` | datetime | ❌ | Activation timestamp (must be after or equal to today) |
| `expires_at` | datetime | ❌ | Expiration timestamp (must be after or equal to `activated_at`) |
| `pixels` | array of integers | ❌ | List of registered retargeting pixel IDs to associate with the link |
| `webhook_url` | string (URL) | ❌ | Per-link webhook URL for immediate event notifications |
| `targeting_rules` | array | ❌ | Targeting rules JSON schema (device, geo, rotation, language) |
| `targeting_rules` | array | ❌ | Advanced Multi-Filter Targeting Rules JSON schema (see [Smart Link Targeting](#smart-link-targeting-updated-in-v340) for schema details) |
| `password` | string | ❌ | Password to protect the short URL |
| `show_warning_page` | boolean | ❌ | Toggle redirect warning interstitial page |
| `auto_open_app_mobile` | boolean | ❌ | Auto open deep link in native application on mobile devices |
| `ga_tracking_id` | string | ❌ | Custom Google Analytics 4 Measurement ID for this link (`G-XXXXXXXXXX`) |
| `ga_tracking_id` | string | ❌ | Custom Google Analytics 4 Measurement ID for this link (`G-XXXXXXXXXX`, max 50 chars) |
| `track_visits` | boolean | ❌ | Toggle tracking/analytics logging for this link (default: `true`) |
| `track_ip_address` | boolean | ❌ | Track client IP address (default: `true`) |
| `track_browser` | boolean | ❌ | Track client browser name (default: `true`) |
@@ -1000,11 +1234,42 @@ You can also pre-configure all parameters via your `.env` file:
| Environment Variable | Config Path | Default | Description |
|---|---|---|---|
| `SHORT_URL_PREFIX` | `route_prefix` | `'s'` | URL prefix for short URL redirects. |
| `SHORT_URL_SITE_NAME` | `site_name` | `null` | Brand/Site name override for warning/interstitial pages. |
### User Integration Configuration
The `user` configuration block (new in v3.5.0) connects the package to your application's `User` model so that creator avatars and details are displayed in the admin table:
```php
// config/filament-short-url.php
'user' => [
'model' => \App\Models\User::class,
'name_column' => 'name',
'email_column' => 'email',
'avatar_column' => 'avatar_url', // attribute name, method name, or null to auto-detect
],
```
**Avatar resolution order:**
1. Call `$user->{avatar_column}()` if it is a method on the model.
2. Read `$user->{avatar_column}` as a direct attribute.
3. Call `$user->getFilamentAvatarUrl()` if the model implements `Filament\Models\Contracts\HasAvatar`.
4. Fall back to a Gravatar URL derived from the user's email.
Set `avatar_column` to `null` to skip steps 12 and start from the `HasAvatar` interface check.
The package automatically attaches the currently authenticated user's ID to every newly created short URL via a model `creating` event on `ShortUrl`.
### Full .env Reference
| Environment Variable | Config Path | Default | Description |
|---|---|---|---|
| `SHORT_URL_GEO_IP` | `geo_ip.enabled` | `true` | Globally enable/disable Geo-IP tracking. |
| `SHORT_URL_GEO_IP_DRIVER` | `geo_ip.driver` | `'headers'` | Geo-IP resolver driver (`headers`, `maxmind`, `ip-api`). |
| `SHORT_URL_MAXMIND_DB` | `geo_ip.maxmind.database_path` | `storage_path('geoip/GeoLite2-Country.mmdb')` | Path to local MaxMind db. |
| `SHORT_URL_STATS_CACHE_TTL` | `geo_ip.stats_cache_ttl` | `300` | Caching TTL in seconds for dashboard charts. |
| `SHORT_URL_QUEUE` | `queue_connection` | `'sync'` | Queue connection for recording visits. |
| `SHORT_URL_QUEUE_NAME` | `queue_name` | `'default'` | Queue name to which visit tracking jobs are dispatched. |
| `SHORT_URL_CACHE_TTL` | `cache_ttl` | `3600` | Redirection model caching TTL (set to `0` to disable). |
| `GA4_API_SECRET` | `ga4.api_secret` | `null` | Google Analytics 4 Measurement Protocol API Secret. |
| `FIREBASE_APP_ID` | `ga4.firebase_app_id` | `null` | Google Analytics 4 Firebase App ID (or uses Measurement ID). |
@@ -1015,6 +1280,11 @@ You can also pre-configure all parameters via your `.env` file:
| `SHORT_URL_RATE_LIMITING` | `rate_limiting.enabled` | `false` | Enable per-IP redirect rate limiting. |
| `SHORT_URL_RATE_LIMIT_MAX` | `rate_limiting.max_attempts` | `60` | Max redirect requests within the decay window. |
| `SHORT_URL_RATE_LIMIT_DECAY` | `rate_limiting.decay_seconds` | `60` | Rate limiter rolling window in seconds. |
| `SHORT_URL_DEEP_LINKING_ENABLED` | `deep_linking.enabled` | `false` | Enable serving domain association files for deep linking. |
| `SHORT_URL_ENABLE_FALLBACK` | `enable_fallback_route` | `true` | Toggle registration of the global fallback redirection route. |
> [!TIP]
> **Database Configuration Preferred**: Avoid configuring large, multi-line JSON blocks (such as `apple-app-site-association` and `assetlinks.json`) via `.env` file environment variables as it is error-prone and can cause parsing issues. The recommended approach is to configure them dynamically via the **Filament Settings Panel**, which stores them securely in the database with real-time JSON validation.
---
@@ -1159,6 +1429,64 @@ All migrations are compatible with **SQLite**, **MySQL**, and **PostgreSQL**:
## Changelog
### v5.1.0
#### Request Lifecycle & Middleware Optimization
- **Bypassed `web` middleware group** — Standard redirect paths (`s/{key}` and fallback routes) bypass session/cookie instantiation entirely. Under active caching, redirects resolve statelessly in `< 15ms`.
- **Dynamic Stateful Route Transition** — Password-protected redirects are dynamically routed to a stateful `/s-auth/{key}` route wrapped under the `web` group. Handles session verification, password prompts, brute force rate-limiting, and warning interstitials.
- **Robust Integration Testing** — Refactored the entire package test suite and main application feature tests to accommodate the dual stateful/stateless redirect routing architectures without coverage loss.
### v5.0.0
#### Analytics — Live Activity Feed
- **Dedicated `/stats/live` page** — Live Feed is now a full standalone page at `/{record}/stats/live`, on par with Visit Logs at `/{record}/stats/logs`. Tab navigation is consistent across all three stat sub-pages (Statistics / Live Feed / Visit Logs).
- **Intelligent no-change skip** — Each poll tick now calls `checkForUpdates()`, which performs a single O(1) `MAX(id)` query against the composite index. If the highest visit ID hasn't changed since the last render, `skipRender()` is called and Livewire returns a ~100-byte "nothing changed" response — zero PHP rendering, zero DOM operations.
- **Correct cursor initialization** — `$latestVisitId` is initialized to `-1` (sentinel). After the first `getViewData()` call it is set to the actual `MAX(id)`, so the very first poll does not trigger an unnecessary re-render.
- **Cache key versioned by `latestVisitId`** — Fixed a bug where two concurrent users detecting the same new visit could share a thundering-herd cache entry that didn't include the visit that triggered their render, causing them to permanently miss that visit until the next new visit arrived.
- **3-second thundering-herd cache** — Collapses N concurrent admin users watching the same URL into one DB query per 3-second window. Cache key includes URL id, date range, active filters, and `latestVisitId` version.
- **Country flags via `flagcdn.com`** — Flag images are rendered as `<img src="https://flagcdn.com/h20/{cc}.webp">` instead of emoji. The URL is precomputed once in PHP (`getViewData()`), not per-row in Blade. Alpine.js `x-on:error` handles broken images without CSP-unsafe inline `onerror` handlers.
- **Blade performance** — `time_ago` is precomputed once in PHP using `$visit->visited_at->diffForHumans()` (no `Carbon::parse()` double-instantiation). All per-row presentation values are plain PHP arrays, not Eloquent model calls in Blade.
- **Optimized DB query** — Selects only 12 needed columns (`id`, `visited_at`, `country`, `country_code`, `city`, `browser`, `operating_system`, `device_type`, `referer_host`, `referer_url`, `ip_address`, `is_qr_scan`, `selected_variant`) instead of `SELECT *`. Limit reduced to 25 rows.
- **Polling stops when hidden** — `wire:poll.5s.visible` pauses polling completely when the widget is scrolled out of viewport.
#### Link Organization
- **Folders** — Each short URL can be assigned to one folder. Clicking a folder in the sidebar navigates to a filtered link list showing only that folder's links. Link count displayed per folder.
- **Tags** — Each short URL can have up to 5 tags. Clicking a tag shows a filtered link list. Link counts displayed per tag.
- **Archiving** — Short URLs can be archived instead of permanently deleted. Archived links are hidden from the default list view and can be restored at any time via the Filament panel.
#### REST API
- **Per-key API scopes** — Each API key now supports either `links:read-only` (GET only) or `links:read-write` (full CRUD) scope. Read-only keys return `403 Forbidden` on any write attempt.
- **Per-key rate limiting** — Each API key can have its own individual rate limit (requests per minute), independent of the global route throttle. Configure in **Settings → API & Webhooks → Developer API Keys**.
### v4.0.0
- **Custom Domains Branding** — Let users connect branded custom domains with real-time CNAME/A record DNS verification and automatic prefix-free root routing.
- **Cache Leak Fix** — Solved a critical caching bug where single-use links could be visited multiple times during the cache TTL by passing the host-specific key suffix to cache invalidations.
- **Aggregator Performance Tuning** — Restructured daily aggregation SQL queries to filter only active URL IDs, leveraging composite indexes and running native DB aggregations.
- **Logo Upload Authorization** — Secured the logo upload API with resource-level permission and policy checks.
- **DB-Free Registration Phase** — Deferred settings overrides to the service provider boot phase to prevent database errors during offline configuration caching.
- **Conditional Redirection Fallback** — Added configurations to optionally disable global fallback route registration.
### v3.5.0
- **User Attribution** — Short URLs now record the creator. Their avatar and hover card (name + email) are displayed in the list table.
- **Relative Date Badges** — Creation dates show compact relative strings (`2h`, `5d`, `3mo`). Hover to see the precise date and timezone.
- **Keyboard Shortcuts** — Hover any table row and press `E` (edit), `Q` (QR code), `I` (share), `S` (statistics), or `X` (delete).
- **Unified Action Dropdown** — All per-row actions consolidated under a single 3-dot button with keyboard shortcut badges.
- **Row Click → Statistics** — Clicking a table row navigates directly to the link's Statistics page.
- **A/B Split Testing** — Weighted traffic rotation in root-level links and nested targeting rules, with drag-based sliders and a one-click "Balance weights" action.
- **Variant Analytics** — Tracks which A/B variant was served and shows a "Variant Clicks Distribution" chart in the analytics dashboard.
- **Query Optimization** — Composite DB index on the visits table; queries filter by indexed `country_code`; Eloquent hydration bypassed with `toBase()->get()` for large sets.
- **GDPR IP Anonymization** — Opt-in IP masking (IPv4/IPv6) with SHA-256 hashing for unique visitor identification, managed via Settings.
- **Google Safe Browsing in REST API** — Safety checks now apply to all incoming URLs in the API (destination, A/B variants, targeting rules).
- **Settings URL Cleanup** — Tab query parameters are now human-readable (e.g. `?tab=qr` instead of `?tab=qr-defaults::data::tab`).
- **Analytics Enhancements** — QR Scan Conversion Rate card with period-over-period delta, browser/OS version subtexts, A/B click-share vs. weight comparison.
### v3.3.0
- **Advanced Multi-Filter Targeting Engine** — Replaced the legacy single-strategy selection panel with a highly flexible rule engine supporting custom `AND` / `OR` logic matching.
- **Granular Filter Categories** — Added support for filtering by devices (Desktop, Mobile, Tablet), platform operating systems (Windows, macOS, Linux, iOS, Android, Fire OS), countries (multiselect with search), and browser languages (multiselect with search).
- **Client-Side Flag Icons** — Integrated high-quality country flag icons dynamically loaded from a trusted CDN (`flagcdn.com`) inside both the Filament form targeting dropdown and the analytics country statistics widget.
- **Redirection Performance Boost** — Bypassed full user agent parsing (which involves regular expression scanning for versions) during redirections by introducing specialized fast-path `getDeviceType` and `getOs` helper functions.
- **Dynamic Legacy Data Adapter** — Added automatic on-the-fly hydration and upgrade of legacy database records to the new rule format when loaded in the Filament form.
### v3.2.0
- **Expanded Developer REST API** — Added new endpoints to inspect single short URLs (`GET /api/short-url/links/{idOrKey}`), fetch real-time click metrics (`GET /api/short-url/links/{idOrKey}/stats`), and fully update links programmatically (`PUT/PATCH /api/short-url/links/{idOrKey}`). Enabled flexible lookup dynamically by ID or URL key.
- **REST API Throttling & Rate Limiting** — Configured built-in route middleware to rate limit the Developer REST API to 60 requests per minute (`throttle:60,1`) to protect from abuse.
@@ -1182,7 +1510,6 @@ All migrations are compatible with **SQLite**, **MySQL**, and **PostgreSQL**:
- **Standalone Settings Page** — Relocated the Settings interface from a resource header sub-action to a standalone sidebar navigation page under the default plugin group.
- **Retargeting Pixel API** — The REST API now fully exposes the `pixels` relationship array parameter for registering and linking retargeting pixels programmatically.
- **Enhanced Browser Language Redirection** — Robust double-pass language targeting logic matching exact locales first (e.g. `en-US`, `zh-CN`) and falling back to base language codes (e.g. `en`, `zh`).
- **Full Localization & WhatsApp Favicon Fix** — Added friendly translation strings in English and Polish across the entire app-linking preview and redirect interfaces, and adjusted domains order to restore the WhatsApp favicon.
- **Custom Branded Expiry Pages** — Replaced raw 410 HTTP errors with a beautiful, fully localized, dark-mode compatible HTML expiry page displaying the Site Name, expired link details, and a homepage button.
### v2.0.0
@@ -1190,9 +1517,7 @@ All migrations are compatible with **SQLite**, **MySQL**, and **PostgreSQL**:
- **Dedicated QR Code Scan Tracking** — Differentiates visitor clicks from physical QR code scans by dynamically appending source tags (`?source=qr`). Added a new database tracking column (`is_qr_scan` on visits, `qr_scans` on short URLs, and `qr_visits_count` on daily stats). Displays a dedicated scans counter badge in the Filament list table.
- **Browser Language Detection & Statistics** — Captures visitor browser preferred language headers (`browser_language` field) and aggregates them into the daily stats table. Displays a new "Top Languages" widget breakdown in the link statistics dashboard.
- **High-Traffic Performance Safeguards & Robust Rollbacks** — Atomic database transactions for buffered counter updates with fail-safe rollback that restores cache values in case of DB connection failures. Prevents N+1 queries by preloading request-wide counters in a single batch cache lookup.
- **Early Boot & Test Container Safety** — Safe settings caching that checks app container state before resolving `cache`, preventing early boot container exceptions during tests or artisan boots.
- **Atomic Duplicate Visit Caching** — Bypasses database exists checks for duplicate visitors by utilizing atomic `cache()->add` keys to prevent database bottlenecking.
- **Support for Empty Route Prefix (Root-Level URLs)** — Enhanced `getShortUrl()` to support empty route prefixes without generating double slashes (e.g. `domain.com/abc123` instead of `domain.com//abc123`). This allows clean root-level redirection domains. Added defensive slash trimming to standard prefixes.
- **Support for Empty Route Prefix (Root-Level URLs)** — Enhanced `getShortUrl()` to support empty route prefixes without generating double slashes (e.g. `domain.com/abc123` instead of `domain.com//abc123`).
### v1.7.0
- **Role-based Settings Access Control** — New `authorizeSettingsUsing(Closure)` method on the plugin to restrict who can access the Settings page. Supports any callable returning a `bool`. Also auto-detects a `manageSettings` method on a registered `ShortUrl` policy. The Settings button in the table header is hidden automatically when access is denied.
@@ -1201,10 +1526,9 @@ All migrations are compatible with **SQLite**, **MySQL**, and **PostgreSQL**:
- **Google Safe Browsing Integration** — Automatic safety checks against Google's API during link creation or modification. Includes bypass settings, asynchronous checking option, and alert badges.
- **VPN / Proxy / Bot Filtering** — Detect and filter out VPN/proxy traffic and Tor nodes using external proxy detection APIs to keep traffic analytics clean.
- **Visitor World Map Widget** — Live interactive SVG world map showing clicks distribution per country, custom intensity highlighting, and hover details.
- **Enhanced Caching & Chart Formatting** — Improved analytics caching for high volumes, and clean `d.m` date formatting (removed year) on visitor stats charts.
### v1.5.1
- **REST API On/Off Toggle** — Enable or disable the entire developer REST API from Settings → API & Webhooks without touching code. Returns `503 Service Unavailable` when disabled. Toggle takes effect immediately without route cache clearing.
- **REST API On/Off Toggle** — Enable or disable the entire developer REST API from Settings → API & Webhooks without touching code. Returns `503 Service Unavailable` when disabled.
### v1.5.0
- **Social Retargeting Pixels** — Attach Meta Pixel, Google Tag (GA4/Ads), and LinkedIn Insight Tag to any short URL. A premium glassmorphic interstitial page executes pixel scripts in the visitor's browser before forwarding them to the destination. Enables building remarketing audiences even on external domains.
@@ -1216,16 +1540,12 @@ All migrations are compatible with **SQLite**, **MySQL**, and **PostgreSQL**:
- **Validity Date Ranges (From-To)** — Set activation dates (`activated_at` and `expires_at`) to control exactly when a short link is active.
- **Custom Visit Limit Counters** — Define a custom maximum visit limit (`max_visits`) after which a link automatically expires (e.g., active for 3 hits).
- **Custom Expiration Fallbacks** — Redirect expired/inactive visitors to a custom `expiration_redirect_url` rather than showing a static 410 Gone error page.
- **Reactive Validity Controls** — Master switch to toggle date limits, including bidirectional datetime picker constraints (Active From cannot exceed Expires At and vice versa) and custom UI field visibility.
- **Smart Model Observers** — Automatic cleanup of unused parameters (like clearing `max_visits` if `single_use` is enabled, and clearing expiration fallbacks if date limits are off) to guarantee database consistency.
- **Fluent Builder APIs** — Fluent method additions (`activatedAt()`, `deactivatedAt()`, `maxVisits()`, `expirationRedirectUrl()`) in the developer query builder.
### v1.3.0
- **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.
@@ -1233,8 +1553,6 @@ All migrations are compatible with **SQLite**, **MySQL**, and **PostgreSQL**:
- **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.

BIN
art/logo-fism.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
art/preview.webm Normal file

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 451 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 484 KiB

BIN
art/v3_1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

BIN
art/v3_2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

BIN
art/v3_3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 467 KiB

BIN
art/v3_4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

BIN
art/v3_5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 KiB

View File

@@ -1,5 +1,6 @@
<?php
use App\Models\User;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit;
@@ -42,6 +43,24 @@ return [
*/
'key_length' => 6,
/*
|--------------------------------------------------------------------------
| Lock URL Key
|--------------------------------------------------------------------------
| Globally disable changing the short key after a link is created.
|
*/
'lock_url_key' => env('SHORT_URL_LOCK_KEY', false),
/*
|--------------------------------------------------------------------------
| Disable Default Domain
|--------------------------------------------------------------------------
| Disable the default app domain for short links.
|
*/
'disable_default_domain' => env('SHORT_URL_DISABLE_DEFAULT_DOMAIN', false),
/*
|--------------------------------------------------------------------------
| Geo-IP Settings
@@ -70,6 +89,7 @@ return [
*/
'tracking' => [
'enabled' => true,
'anonymize_ips' => env('SHORT_URL_ANONYMIZE_IPS', false),
'fields' => [
'ip_address' => true,
'browser' => true,
@@ -204,6 +224,15 @@ return [
'decay_seconds' => env('SHORT_URL_RATE_LIMIT_DECAY', 60),
],
/*
|--------------------------------------------------------------------------
| Enable Fallback Route
|--------------------------------------------------------------------------
| Register a fallback route to catch redirects for custom domains.
| Set to false if you wish to define custom domain routing manually.
*/
'enable_fallback_route' => env('SHORT_URL_ENABLE_FALLBACK', true),
/*
|--------------------------------------------------------------------------
| Redirect Route Middleware
@@ -226,37 +255,21 @@ return [
*/
'deep_linking' => [
'enabled' => env('SHORT_URL_DEEP_LINKING_ENABLED', false),
'aasa_json' => env('SHORT_URL_AASA_JSON', <<<'JSON'
{
"applinks": {
"apps": [],
"details": [
{
"appID": "YOUR_TEAM_ID.com.yourcompany.app",
"paths": [
"/s/*"
]
}
]
}
}
JSON),
'assetlinks_json' => env('SHORT_URL_ASSETLINKS_JSON', <<<'JSON'
[
{
"relation": [
"delegate_permission/common.handle_all_urls"
],
"target": {
"namespace": "android_app",
"package_name": "com.yourcompany.app",
"sha256_cert_fingerprints": [
"14:6D:E9:..."
]
}
}
]
JSON),
],
/*
|--------------------------------------------------------------------------
| User Integration Settings
|--------------------------------------------------------------------------
| Configure how the package interacts with the User model for showing avatars
| and user details (name, email) in the Filament admin panel.
|
*/
'user' => [
'model' => User::class,
'name_column' => 'name',
'email_column' => 'email',
'avatar_column' => 'avatar_url', // can be attribute/method on model or null to auto-detect HasAvatar
],
];

View File

@@ -6,15 +6,21 @@ use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('short_url_settings', function (Blueprint $table): void {
Schema::create('short_url_settings', function (Blueprint $table) {
$table->string('key')->primary();
$table->longText('value')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('short_url_settings');

View File

@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('short_url_pixels', function (Blueprint $table) {
$table->id();
$table->string('name', 150);
$table->string('type', 50); // meta, google, linkedin, tiktok, pinterest
$table->string('pixel_id', 100);
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->unique(['type', 'pixel_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('short_url_pixels');
}
};

View File

@@ -1,52 +0,0 @@
<?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_visits', function (Blueprint $table): void {
$table->id();
$table->foreignId('short_url_id')
->constrained('short_urls')
->cascadeOnDelete();
// Visitor fingerprint
$table->ipAddress('ip_address')->nullable();
$table->string('ip_hash', 64)->nullable()->index(); // SHA-256 of IP for unique counting
// Browser detection
$table->string('browser', 100)->nullable();
$table->string('browser_version', 50)->nullable();
// OS detection
$table->string('operating_system', 100)->nullable();
$table->string('operating_system_version', 50)->nullable();
// Device classification ('desktop', 'mobile', 'tablet', 'robot') — validated at PHP level
$table->string('device_type', 20)->nullable()->index();
// Traffic source
$table->text('referer_url')->nullable();
// Geo-location
$table->string('country', 100)->nullable()->index();
$table->char('country_code', 2)->nullable()->index();
$table->timestamp('visited_at')->useCurrent()->index();
// Composite indexes for performance on millions of visits
$table->index(['short_url_id', 'ip_hash']);
$table->index(['short_url_id', 'visited_at']);
});
}
public function down(): void
{
Schema::dropIfExists('short_url_visits');
}
};

View File

@@ -6,13 +6,18 @@ use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('short_urls', function (Blueprint $table): void {
Schema::create('short_urls', function (Blueprint $table) {
$table->id();
// Core
$table->text('destination_url');
$table->string('destination_type', 20)->default('single'); // single, split
$table->text('destination_url')->nullable();
$table->json('rotation_variants')->nullable();
$table->string('url_key', 32)->unique()->index();
$table->string('notes')->nullable();
@@ -22,10 +27,19 @@ return new class extends Migration
$table->timestamp('activated_at')->nullable();
$table->timestamp('deactivated_at')->nullable();
$table->timestamp('expires_at')->nullable()->index();
$table->unsignedInteger('max_visits')->nullable();
$table->string('expiration_redirect_url')->nullable();
// Behaviour
$table->boolean('single_use')->default(false);
$table->boolean('forward_query_params')->default(false);
$table->boolean('auto_open_app_mobile')->default(false);
// Security, Webhooks & QR Counters
$table->string('password')->nullable();
$table->boolean('show_warning_page')->default(false);
$table->string('webhook_url', 2048)->nullable();
$table->integer('qr_scans')->default(0);
// Tracking — master switch
$table->boolean('track_visits')->default(true);
@@ -38,23 +52,38 @@ return new class extends Migration
$table->boolean('track_operating_system_version')->default(true);
$table->boolean('track_device_type')->default(true);
$table->boolean('track_referer_url')->default(true);
$table->boolean('track_browser_language')->default(true);
// QR code design (JSON blob)
// QR code options & branding logo
$table->json('qr_options')->nullable();
$table->string('qr_logo')->nullable();
// Google Analytics 4 integration
$table->string('ga_tracking_id', 50)->nullable();
// Advanced targeting rules (AND/OR, Multi-filter)
$table->json('targeting_rules')->nullable();
// Denormalized counters for fast reads (updated atomically)
$table->unsignedBigInteger('total_visits')->default(0);
$table->unsignedBigInteger('unique_visits')->default(0);
$table->timestamps();
});
Schema::create('short_url_pixel', function (Blueprint $table) {
$table->foreignId('short_url_id')->constrained('short_urls')->cascadeOnDelete();
$table->foreignId('pixel_id')->constrained('short_url_pixels')->cascadeOnDelete();
$table->primary(['short_url_id', 'pixel_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('short_url_pixel');
Schema::dropIfExists('short_urls');
}
};

View File

@@ -0,0 +1,63 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('short_url_visits', function (Blueprint $table) {
$table->id();
$table->foreignId('short_url_id')->constrained('short_urls')->cascadeOnDelete();
// Client info
$table->ipAddress('ip_address')->nullable();
$table->string('ip_hash', 64)->nullable()->index();
$table->char('country_code', 2)->nullable()->index();
$table->string('country', 100)->nullable();
$table->string('city', 100)->nullable();
$table->string('browser', 100)->nullable();
$table->string('browser_version', 50)->nullable();
$table->string('operating_system', 100)->nullable();
$table->string('operating_system_version', 50)->nullable();
$table->string('device_type', 50)->nullable()->index();
$table->string('browser_language', 10)->nullable();
// Referrer
$table->text('referer_url')->nullable();
$table->string('referer_host', 150)->nullable()->index();
// Marketing UTM Tracing
$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();
// Context flags
$table->boolean('is_qr_scan')->default(false)->index();
$table->boolean('is_bot')->default(false)->index();
$table->boolean('is_proxy')->default(false)->index();
// A/B Testing chosen variant label/URL
$table->string('selected_variant', 255)->nullable()->index();
$table->timestamp('visited_at')->useCurrent()->index();
$table->index(['short_url_id', 'is_bot', 'is_proxy', 'visited_at'], 'visits_performance_index');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('short_url_visits');
}
};

View File

@@ -6,31 +6,48 @@ use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('short_url_daily_stats', function (Blueprint $table): void {
Schema::create('short_url_daily_stats', function (Blueprint $table) {
$table->id();
$table->foreignId('short_url_id')
->constrained('short_urls')
->cascadeOnDelete();
$table->date('date');
$table->foreignId('short_url_id')->constrained('short_urls')->cascadeOnDelete();
$table->date('date')->index();
// Aggregated click counts
$table->integer('visits_count')->default(0);
$table->integer('unique_visits_count')->default(0);
$table->integer('qr_visits_count')->default(0);
// JSON dimensions aggregates
$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('language_stats')->nullable();
// Marketing aggregates
$table->json('utm_source_stats')->nullable();
$table->json('utm_medium_stats')->nullable();
$table->json('utm_campaign_stats')->nullable();
// A/B testing variant aggregates
$table->json('variant_stats')->nullable();
$table->timestamps();
// Enforce unique record per short URL and date
$table->unique(['short_url_id', 'date']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('short_url_daily_stats');

View File

@@ -1,36 +0,0 @@
<?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

@@ -1,28 +0,0 @@
<?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

@@ -1,23 +0,0 @@
<?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->unsignedInteger('max_visits')->nullable();
$table->string('expiration_redirect_url', 2048)->nullable();
});
}
public function down(): void
{
Schema::table('short_urls', function (Blueprint $table): void {
$table->dropColumn(['max_visits', 'expiration_redirect_url']);
});
}
};

View File

@@ -1,36 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('short_urls', function (Blueprint $table) {
$table->string('pixel_meta_id', 100)->nullable();
$table->string('pixel_google_id', 100)->nullable();
$table->string('pixel_linkedin_id', 100)->nullable();
$table->string('webhook_url', 2048)->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('short_urls', function (Blueprint $table) {
$table->dropColumn([
'pixel_meta_id',
'pixel_google_id',
'pixel_linkedin_id',
'webhook_url',
]);
});
}
};

View File

@@ -1,23 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('short_url_visits', function (Blueprint $table): void {
$table->boolean('is_bot')->default(false)->index();
$table->boolean('is_proxy')->default(false)->index();
});
}
public function down(): void
{
Schema::table('short_url_visits', function (Blueprint $table): void {
$table->dropColumn(['is_bot', 'is_proxy']);
});
}
};

View File

@@ -1,43 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('short_urls', function (Blueprint $table): void {
$table->integer('qr_scans')->default(0);
});
Schema::table('short_url_visits', function (Blueprint $table): void {
$table->boolean('is_qr_scan')->default(false)->index();
$table->string('browser_language', 10)->nullable()->index();
});
Schema::table('short_url_daily_stats', function (Blueprint $table): void {
$table->integer('qr_visits_count')->default(0);
$table->json('language_stats')->nullable();
});
}
public function down(): void
{
Schema::table('short_urls', function (Blueprint $table): void {
$table->dropColumn('qr_scans');
});
Schema::table('short_url_visits', function (Blueprint $table): void {
$table->dropIndex(['is_qr_scan']);
$table->dropColumn('is_qr_scan');
$table->dropIndex(['browser_language']);
$table->dropColumn('browser_language');
});
Schema::table('short_url_daily_stats', function (Blueprint $table): void {
$table->dropColumn(['qr_visits_count', 'language_stats']);
});
}
};

View File

@@ -1,22 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('short_urls', function (Blueprint $table): void {
$table->boolean('track_browser_language')->default(true);
});
}
public function down(): void
{
Schema::table('short_urls', function (Blueprint $table): void {
$table->dropColumn('track_browser_language');
});
}
};

View File

@@ -1,155 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// 1. Create short_url_pixels table
Schema::create('short_url_pixels', function (Blueprint $table) {
$table->id();
$table->string('name', 150);
$table->string('type', 50); // meta, google, linkedin, tiktok, pinterest
$table->string('pixel_id', 100);
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->unique(['type', 'pixel_id']);
});
// 2. Create short_url_pixel pivot table
Schema::create('short_url_pixel', function (Blueprint $table) {
$table->foreignId('short_url_id')->constrained('short_urls')->cascadeOnDelete();
$table->foreignId('pixel_id')->constrained('short_url_pixels')->cascadeOnDelete();
$table->primary(['short_url_id', 'pixel_id']);
});
// 3. Data migration: Migrate existing pixel IDs from short_urls to short_url_pixels and pivot
if (Schema::hasColumn('short_urls', 'pixel_meta_id')) {
$oldUrls = DB::table('short_urls')
->whereNotNull('pixel_meta_id')
->orWhereNotNull('pixel_google_id')
->orWhereNotNull('pixel_linkedin_id')
->get();
foreach ($oldUrls as $url) {
// Migrate Meta Pixel
if (! empty($url->pixel_meta_id)) {
$pixelId = DB::table('short_url_pixels')
->where('type', 'meta')
->where('pixel_id', $url->pixel_meta_id)
->value('id');
if (! $pixelId) {
$pixelId = DB::table('short_url_pixels')->insertGetId([
'name' => 'Meta Pixel ('.$url->pixel_meta_id.')',
'type' => 'meta',
'pixel_id' => $url->pixel_meta_id,
'is_active' => true,
'created_at' => now(),
'updated_at' => now(),
]);
}
DB::table('short_url_pixel')->insertOrIgnore([
'short_url_id' => $url->id,
'pixel_id' => $pixelId,
]);
}
// Migrate Google Tag / GA4
if (! empty($url->pixel_google_id)) {
$pixelId = DB::table('short_url_pixels')
->where('type', 'google')
->where('pixel_id', $url->pixel_google_id)
->value('id');
if (! $pixelId) {
$pixelId = DB::table('short_url_pixels')->insertGetId([
'name' => 'Google Tag ('.$url->pixel_google_id.')',
'type' => 'google',
'pixel_id' => $url->pixel_google_id,
'is_active' => true,
'created_at' => now(),
'updated_at' => now(),
]);
}
DB::table('short_url_pixel')->insertOrIgnore([
'short_url_id' => $url->id,
'pixel_id' => $pixelId,
]);
}
// Migrate LinkedIn Insight
if (! empty($url->pixel_linkedin_id)) {
$pixelId = DB::table('short_url_pixels')
->where('type', 'linkedin')
->where('pixel_id', $url->pixel_linkedin_id)
->value('id');
if (! $pixelId) {
$pixelId = DB::table('short_url_pixels')->insertGetId([
'name' => 'LinkedIn Insight ('.$url->pixel_linkedin_id.')',
'type' => 'linkedin',
'pixel_id' => $url->pixel_linkedin_id,
'is_active' => true,
'created_at' => now(),
'updated_at' => now(),
]);
}
DB::table('short_url_pixel')->insertOrIgnore([
'short_url_id' => $url->id,
'pixel_id' => $pixelId,
]);
}
}
// 4. Drop the old columns
Schema::table('short_urls', function (Blueprint $table) {
$table->dropColumn(['pixel_meta_id', 'pixel_google_id', 'pixel_linkedin_id']);
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// 1. Re-add the columns
Schema::table('short_urls', function (Blueprint $table) {
$table->string('pixel_meta_id', 100)->nullable();
$table->string('pixel_google_id', 100)->nullable();
$table->string('pixel_linkedin_id', 100)->nullable();
});
// 2. Re-populate the old columns from pivot data
$associations = DB::table('short_url_pixel')
->join('short_url_pixels', 'short_url_pixel.pixel_id', '=', 'short_url_pixels.id')
->select('short_url_pixel.short_url_id', 'short_url_pixels.type', 'short_url_pixels.pixel_id')
->get();
foreach ($associations as $assoc) {
if ($assoc->type === 'meta') {
DB::table('short_urls')->where('id', $assoc->short_url_id)->update(['pixel_meta_id' => $assoc->pixel_id]);
} elseif ($assoc->type === 'google') {
DB::table('short_urls')->where('id', $assoc->short_url_id)->update(['pixel_google_id' => $assoc->pixel_id]);
} elseif ($assoc->type === 'linkedin') {
DB::table('short_urls')->where('id', $assoc->short_url_id)->update(['pixel_linkedin_id' => $assoc->pixel_id]);
}
}
// 3. Drop tables
Schema::dropIfExists('short_url_pixel');
Schema::dropIfExists('short_url_pixels');
}
};

View File

@@ -12,7 +12,7 @@ return new class extends Migration
public function up(): void
{
Schema::table('short_urls', function (Blueprint $table) {
$table->string('qr_logo')->nullable();
$table->unsignedBigInteger('user_id')->nullable()->after('id')->index();
});
}
@@ -22,7 +22,7 @@ return new class extends Migration
public function down(): void
{
Schema::table('short_urls', function (Blueprint $table) {
$table->dropColumn('qr_logo');
$table->dropColumn('user_id');
});
}
};

View File

@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('short_url_custom_domains', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('user_id')->nullable()->index();
$table->string('domain', 191)->unique()->index();
$table->boolean('is_verified')->default(false);
$table->boolean('is_active')->default(true)->index();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('short_url_custom_domains');
}
};

View File

@@ -6,17 +6,23 @@ use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('short_urls', function (Blueprint $table) {
$table->boolean('auto_open_app_mobile')->default(false)->after('forward_query_params');
$table->unsignedBigInteger('custom_domain_id')->nullable()->after('user_id')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('short_urls', function (Blueprint $table) {
$table->dropColumn('auto_open_app_mobile');
$table->dropColumn('custom_domain_id');
});
}
};

View File

@@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('short_url_folders', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->index();
$table->string('color')->nullable();
$table->unsignedBigInteger('user_id')->nullable()->index();
$table->timestamps();
});
Schema::create('short_url_tags', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->index();
$table->string('color')->nullable();
$table->unsignedBigInteger('user_id')->nullable()->index();
$table->timestamps();
});
Schema::create('short_url_tag', function (Blueprint $table) {
$table->unsignedBigInteger('short_url_id')->index();
$table->unsignedBigInteger('tag_id')->index();
$table->primary(['short_url_id', 'tag_id']);
});
Schema::table('short_urls', function (Blueprint $table) {
$table->unsignedBigInteger('folder_id')->nullable()->after('custom_domain_id')->index();
$table->boolean('is_archived')->default(false)->after('is_enabled')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('short_urls', function (Blueprint $table) {
$table->dropColumn(['folder_id', 'is_archived']);
});
Schema::dropIfExists('short_url_tag');
Schema::dropIfExists('short_url_tags');
Schema::dropIfExists('short_url_folders');
}
};

View File

@@ -1,12 +1,23 @@
@import "tailwindcss";
@import "tailwindcss/theme";
@import "tailwindcss/utilities";
@source "../../src/**/*.php";
@source "../../resources/views/**/*.blade.php";
@source "../views/**/*.blade.php";
@source "packages/filament-short-url/src/**/*.php";
@source "packages/filament-short-url/resources/views/**/*.blade.php";
.short-url-card {
position: relative !important;
}
.short-url-card:hover {
z-index: 20 !important;
}
.short-url-card:focus-within {
z-index: 30 !important;
}
.short-url-card .fi-ta-actions {
position: absolute !important;
top: 1.25rem !important;
@@ -15,3 +26,100 @@
margin: 0 !important;
padding: 0 !important;
}
.ab-test-slider {
margin-top: 0;
}
.ab-test-slider .noUi-tooltip {
position: absolute;
top: -24px;
width: 30px;
height: 20px;
bottom: inherit !important;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 100px;
font-size: 12px;
background: #ffffff26;
backdrop-filter: blur(4px);
}
/* 3-dot button trigger styling */
.action-trigger-btn {
border: 1px solid transparent !important;
background-color: transparent !important;
transition: all 0.2s ease-in-out;
}
/* On card hover, show a subtle border */
.short-url-card:hover .action-trigger-btn {
border-color: #e5e7eb !important;
background-color: #f9fafb !important;
}
.dark .short-url-card:hover .action-trigger-btn {
border-color: #374151 !important;
background-color: #1f2937 !important;
}
/* When the dropdown is open (trigger has focus or aria-expanded="true"), show active border */
.action-trigger-btn[aria-expanded="true"] {
border-color: #9ca3af !important;
background-color: #f3f4f6 !important;
box-shadow: 0 0 0 4px rgba(229, 230, 235, 0.5) !important;
}
.dark .action-trigger-btn[aria-expanded="true"] {
border-color: #4b5563 !important;
background-color: #111827 !important;
box-shadow: 0 0 0 4px rgba(55, 65, 81, 0.5) !important;
}
.custom-fused .fi-input-wrp {
border: 0 !important;
}
/* Ukrycie strzałki rozwijania selecta, gdy jest zablokowany */
select.fi-select-input:disabled,
.fi-select-input .fi-select-input-btn[disabled],
.fi-select-input .fi-select-input-btn.fi-disabled {
background-image: none !important;
padding-right: 0.75rem !important;
}
/* Folder Card overrides to reset default Filament record-content padding and force height/width stretch */
.folder-card .fi-ta-record-content,
.folder-card .fi-ta-record-content-ctn {
padding-left: 0 !important;
padding-right: 0 !important;
padding-top: 0 !important;
padding-bottom: 0 !important;
height: 100% !important;
width: 100% !important;
}
.folder-card .fi-ta-record-content {
display: flex !important;
flex-direction: column !important;
}
/* Position action buttons absolute on the card wrapper to keep dropdown aligned */
.folder-card .fi-ta-actions {
position: absolute !important;
top: 1rem !important;
right: 1rem !important;
z-index: 20 !important;
margin: 0 !important;
padding: 0 !important;
}
/* Force height down the Filament table layout tree so justify-between works */
.folder-card .fi-ta-stack,
.folder-card .fi-ta-stack > div,
.folder-card .fi-ta-text,
.folder-card .fi-ta-text > div {
height: 100% !important;
}

File diff suppressed because one or more lines are too long

View File

@@ -1,9 +1,15 @@
<?php
return [
'navigation_label' => 'Short Links',
'navigation_group' => 'Tools',
'navigation_label' => 'Links',
'navigation_group' => 'URL Shortener',
'resource_title' => 'Short URL',
'empty_state_heading' => 'No links yet',
'empty_state_description' => 'Start creating short links for your marketing campaigns, referral programs, and more.',
'empty_state_action' => 'Create link',
'empty_state_pixel_heading' => 'No pixels yet',
'empty_state_pixel_description' => 'Add tracking pixels from Meta, Google, TikTok and more to retarget visitors who click your links.',
'empty_state_pixel_action' => 'Create pixel',
// Form Tabs
'tab_link' => 'Link Details',
@@ -21,6 +27,9 @@ return [
'matched' => 'Matched',
// Link Form Fields
'destination_type' => 'Destination Type',
'destination_type_single' => 'Single URL',
'destination_type_split' => 'A/B Split Test',
'destination_url' => 'Destination URL',
'destination_url_helper' => 'The original URL you want to redirect visitors to.',
'url_key' => 'Short Key',
@@ -144,7 +153,10 @@ return [
'action_qr' => 'QR Code',
'action_edit' => 'Edit URL',
'action_delete' => 'Delete URL',
'url_key_change_confirmation_heading' => 'Replace link?',
'url_key_change_confirmation' => "You've modified the back-half of this link's URL. Saving these changes will update the key. Any references to the original link will break.",
'action_share' => 'Share Link',
'action_move' => 'Move',
'share_title' => 'Share Link',
'share_description' => 'Share this short link via:',
'share_copy' => 'Copy',
@@ -184,6 +196,8 @@ return [
'stats_table_title' => 'Visit Logs',
'stats_btn_back' => 'Back to list',
'stats_btn_copy' => 'Copy Short URL',
'stats_btn_refresh' => 'Refresh',
'stats_refresh_success' => 'Statistics refreshed in real-time!',
'stats_col_time' => 'Time',
'stats_col_country' => 'Country',
'stats_col_device' => 'Device',
@@ -210,6 +224,10 @@ return [
'settings_site_name_helper' => 'Custom brand/site name displayed on redirect prompt screens. If empty, falls back to config("app.name").',
'settings_route_prefix' => 'Route Prefix',
'settings_route_prefix_helper' => 'URL segment before the short key, e.g. "/s/abc123". Leave empty to serve links directly from the root domain (e.g. "domain.com/abc123"). Change requires a config:clear.',
'settings_lock_url_key' => 'Lock Short Key',
'settings_lock_url_key_helper' => 'Globally disable changing the short key after a link is created.',
'settings_disable_default_domain' => 'Disable Default Domain',
'settings_disable_default_domain_helper' => 'Prevent users from using the default app domain for new short links.',
'settings_redirect_code_helper' => 'Temporary redirect (302) forces browsers to request the short URL every time, ensuring accurate tracking of all visits. Permanent redirect (301) is cached by browsers and search engines (better for SEO), but subsequent visits from the same device might not be logged in statistics.',
'settings_key_length' => 'Auto-generated Key Length',
'settings_key_length_helper' => 'Number of characters for auto-generated short keys (base62). 6 chars = ~56 billion unique keys.',
@@ -231,7 +249,7 @@ 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_info_callout' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M8 0C3.58125 0 0 3.58125 0 8C0 12.4187 3.58125 16 8 16C12.4187 16 16 12.4187 16 8C16 3.58125 12.4187 0 8 0ZM8 14.5C4.41563 14.5 1.5 11.5841 1.5 8C1.5 4.41594 4.41563 1.5 8 1.5C11.5844 1.5 14.5 4.41594 14.5 8C14.5 11.5841 11.5844 14.5 8 14.5ZM9.25 10.5H8.75V7.75C8.75 7.3375 8.41563 7 8 7H7C6.5875 7 6.25 7.3375 6.25 7.75C6.25 8.1625 6.5875 8.5 7 8.5H7.25V10.5H6.75C6.3375 10.5 6 10.8375 6 11.25C6 11.6625 6.3375 12 6.75 12H9.25C9.66406 12 10 11.6641 10 11.25C10 10.8359 9.66563 10.5 9.25 10.5ZM8 6C8.55219 6 9 5.55219 9 5C9 4.44781 8.55219 4 8 4C7.44781 4 7 4.44687 7 5C7 5.55313 7.44687 6 8 6Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>How to configure MaxMind (Offline Geo-IP):</strong><br>1. Register for a free account at <a href="https://www.maxmind.com" target="_blank" class="underline text-primary-600 dark:text-primary-400">maxmind.com</a>.<br>2. Download the free <strong>GeoLite2 Country</strong> or <strong>GeoLite2 City</strong> database in binary format (file extension <code class="px-1 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">.mmdb</code>).<br>3. Upload the file to your server, e.g. under the path <code class="px-1 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">storage/geoip/GeoLite2-Country.mmdb</code>.<br>4. Paste the absolute path below and click the <strong>Verify file</strong> button on the right side of the field.</span></div></div>',
'settings_maxmind_info_callout' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm.75-11.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm0 2.5a.75.75 0 0 1 .75.75v4a.75.75 0 0 1-1.5 0v-4a.75.75 0 0 1 .75-.75Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>How to configure MaxMind (Offline Geo-IP):</strong><br>1. Register for a free account at <a href="https://www.maxmind.com" target="_blank" class="underline text-primary-600 dark:text-primary-400">maxmind.com</a>.<br>2. Download the free <strong>GeoLite2 Country</strong> or <strong>GeoLite2 City</strong> database in binary format (file extension <code class="px-1 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">.mmdb</code>).<br>3. Upload the file to your server, e.g. under the path <code class="px-1 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">storage/geoip/GeoLite2-Country.mmdb</code>.<br>4. Paste the absolute path below and click the <strong>Verify file</strong> button on the right side of the field.</span></div></div>',
'settings_maxmind_verify' => 'Verify file',
'settings_maxmind_verify_ok' => '✅ File found & readable',
'settings_maxmind_verify_fail' => '❌ File not found or not readable',
@@ -250,7 +268,7 @@ return [
'settings_section_buffering' => 'Visit Counters Buffering',
'settings_buffering_enabled' => 'Buffer Visit Counts in Cache',
'settings_buffering_helper' => 'When enabled, visit count increments are temporarily buffered in the application cache and must be flushed periodically to the database via "php artisan short-url:sync-counters". This prevents row-locking issues and performance degradation under high-traffic spikes.',
'settings_buffering_worker_info' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M8 0C3.58125 0 0 3.58125 0 8C0 12.4187 3.58125 16 8 16C12.4187 16 16 12.4187 16 8C16 3.58125 12.4187 0 8 0ZM8 14.5C4.41563 14.5 1.5 11.5841 1.5 8C1.5 4.41594 4.41563 1.5 8 1.5C11.5844 1.5 14.5 4.41594 14.5 8C14.5 11.5841 11.5844 14.5 8 14.5ZM9.25 10.5H8.75V7.75C8.75 7.3375 8.41563 7 8 7H7C6.5875 7 6.25 7.3375 6.25 7.75C6.25 8.1625 6.5875 8.5 7 8.5H7.25V10.5H6.75C6.3375 10.5 6 10.8375 6 11.25C6 11.6625 6.3375 12 6.75 12H9.25C9.66406 12 10 11.6641 10 11.25C10 10.8359 9.66563 10.5 9.25 10.5ZM8 6C8.55219 6 9 5.55219 9 5C9 4.44781 8.55219 4 8 4C7.44781 4 7 4.44687 7 5C7 5.55313 7.44687 6 8 6Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full [&_kbd]:bg-background-light dark:[&_kbd]:bg-background-dark [_code]:!text-current [_kbd]:!text-current [_a]:!text-current [_a]:border-current [_strong]:!text-current text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Required Cron Task (Scheduler):</strong> You have enabled click counter buffering. You must add the following task to your server\'s crontab:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1</code><br>Command run automatically in the background by the scheduler:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">php artisan short-url:sync-counters</code></span></div></div>',
'settings_buffering_worker_info' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm.75-11.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm0 2.5a.75.75 0 0 1 .75.75v4a.75.75 0 0 1-1.5 0v-4a.75.75 0 0 1 .75-.75Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full [&_kbd]:bg-background-light dark:[&_kbd]:bg-background-dark [_code]:!text-current [_kbd]:!text-current [_a]:!text-current [_a]:border-current [_strong]:!text-current text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Required Cron Task (Scheduler):</strong> You have enabled click counter buffering. You must add the following task to your server\'s crontab:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1</code><br>Command run automatically in the background by the scheduler:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">php artisan short-url:sync-counters</code></span></div></div>',
// CDN Trust Settings
'settings_trust_cdn_headers' => 'Trust CDN & Proxy Headers',
@@ -269,6 +287,15 @@ return [
'stats_csv_browser' => 'Browser',
'stats_csv_os' => 'OS',
'stats_csv_referer' => 'Referer',
'stats_csv_utm_source' => 'UTM Source',
'stats_csv_utm_medium' => 'UTM Medium',
'stats_csv_utm_campaign' => 'UTM Campaign',
'stats_csv_utm_term' => 'UTM Term',
'stats_csv_utm_content' => 'UTM Content',
'stats_csv_variant' => 'A/B Variant',
'stats_csv_qr_scan' => 'QR Scan',
'stats_csv_bot' => 'Bot',
'stats_csv_proxy' => 'Proxy/VPN',
// Bulk Actions Localization
'action_enable_selected' => 'Enable selected',
@@ -292,9 +319,11 @@ return [
'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_breakdown_utm_source' => 'Source',
'stats_breakdown_utm_medium' => 'Medium',
'stats_breakdown_utm_campaign' => 'Campaign',
'stats_breakdown_utm_term' => 'Term',
'stats_breakdown_utm_content' => 'Content',
'stats_no_utm_data' => 'No UTM data recorded.',
// New Targeting & Security Tab and fields
@@ -332,6 +361,31 @@ return [
'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.',
'rotation_variants' => 'A/B Split Variants',
'rotation_variants_helper' => 'Define the URLs and weight distribution for the A/B Split Test.',
'variant_label' => 'Variant Label',
'variant_url' => 'Variant URL',
'variant_weight' => 'Traffic Share (%)',
'safe_browsing_error' => 'This URL has been flagged by Google Safe Browsing as unsafe.',
// New Advanced Targeting Builder
'targeting_rules' => 'Targeting Rules',
'add_filter' => 'Add a filter',
'match' => 'Match',
'match_or' => 'Any of the following filters (OR)',
'match_and' => 'All the following filters (AND)',
'filter_device' => 'Device',
'filter_platform' => 'Platform',
'filter_country' => 'Country',
'filter_language' => 'Browser Language',
'direct_to_url' => 'Direct to URL',
'select_devices' => 'Select devices',
'select_platforms' => 'Select operating systems',
'select_countries' => 'Select countries',
'select_languages' => 'Select browser languages',
'device_desktop_label' => 'Desktop',
'device_mobile_label' => 'Smartphone',
'device_tablet_label' => 'Tablet',
// New Settings Page Fields
'settings_tab_advanced' => 'Performance & Security',
@@ -357,7 +411,7 @@ return [
// Queue Settings Additions
'settings_queue_name' => 'Queue Name',
'settings_queue_name_helper' => 'The target queue to which tracking and buffering sync jobs are dispatched. Default: "default".',
'settings_queue_worker_info' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M8 0C3.58125 0 0 3.58125 0 8C0 12.4187 3.58125 16 8 16C12.4187 16 16 12.4187 16 8C16 3.58125 12.4187 0 8 0ZM8 14.5C4.41563 14.5 1.5 11.5841 1.5 8C1.5 4.41594 4.41563 1.5 8 1.5C11.5844 1.5 14.5 4.41594 14.5 8C14.5 11.5841 11.5844 14.5 8 14.5ZM9.25 10.5H8.75V7.75C8.75 7.3375 8.41563 7 8 7H7C6.5875 7 6.25 7.3375 6.25 7.75C6.25 8.1625 6.5875 8.5 7 8.5H7.25V10.5H6.75C6.3375 10.5 6 10.8375 6 11.25C6 11.6625 6.3375 12 6.75 12H9.25C9.66406 12 10 11.6641 10 11.25C10 10.8359 9.66563 10.5 9.25 10.5ZM8 6C8.55219 6 9 5.55219 9 5C9 4.44781 8.55219 4 8 4C7.44781 4 7 4.44687 7 5C7 5.55313 7.44687 6 8 6Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full [&_kbd]:bg-background-light dark:[&_kbd]:bg-background-dark [_code]:!text-current [_kbd]:!text-current [_a]:!text-current [_a]:border-current [_strong]:!text-current text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Queue Worker Required:</strong> The selected connection runs asynchronously. You must run a background worker to process these jobs:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">php artisan queue:work --queue=:queue</code></span></div></div>',
'settings_queue_worker_info' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm.75-11.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm0 2.5a.75.75 0 0 1 .75.75v4a.75.75 0 0 1-1.5 0v-4a.75.75 0 0 1 .75-.75Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full [&_kbd]:bg-background-light dark:[&_kbd]:bg-background-dark [_code]:!text-current [_kbd]:!text-current [_a]:!text-current [_a]:border-current [_strong]:!text-current text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Queue Worker Required:</strong> The selected connection runs asynchronously. You must run a background worker to process these jobs:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">php artisan queue:work --queue=:queue</code></span></div></div>',
'settings_geoip_stats_cache_ttl' => 'Stats Dashboard Cache TTL',
'settings_geoip_stats_cache_ttl_helper' => 'Time (in seconds) to cache stats calculation on the dashboard. When viewing charts and logs, the system loads calculated numbers from cache instead of querying millions of rows from SQL on every refresh. Default: 300 (5 minutes). Max: 86400 (24 hours).',
@@ -374,6 +428,9 @@ return [
'settings_track_referer_default' => 'Default Referer URL Tracking',
'settings_track_device_type_default' => 'Default Device Type Tracking',
'settings_track_browser_language_default' => 'Default Browser Language Tracking',
'settings_track_anonymize_ips' => 'IP Address Anonymization (GDPR/RODO)',
'settings_track_anonymize_ips_helper' => 'Replace the last octet of IPv4 addresses or last 80 bits of IPv6 addresses with zeros before saving them to the database to comply with GDPR/RODO regulations. The raw IP address is still hashed privately to ensure unique click statistics remain accurate.',
'stats_filter_limit_exceeded' => 'Maximum of 5 filters can be applied simultaneously.',
// QR Defaults Tab
'settings_tab_qr' => 'QR Defaults',
@@ -437,6 +494,8 @@ return [
'api_key_name' => 'Key Name (Description)',
'api_key' => 'API Key',
'active' => 'Active',
'api_key_generated' => 'API Key Generated',
'api_key_warning' => "Please copy this key now. You won't be able to see it again!",
// Security v2.0
'settings_section_security_v2' => 'Security & Anti-Fraud v2.0',
@@ -473,6 +532,7 @@ return [
'world_map_no_data' => 'No geographic data yet.',
'world_map_no_data_sub' => 'Enable Geo-IP detection to start recording visitor locations.',
'stats_card_qr_scans' => 'QR Code Scans',
'stats_card_qr_rate' => 'QR Scan Conversion Rate',
'stats_breakdown_languages' => 'Top Languages',
'stats_no_language_data' => 'No language data.',
'badge_clicks' => 'clicks',
@@ -523,4 +583,168 @@ return [
'expired_title' => 'Link Inactive or Expired',
'expired_description' => 'This link is no longer active. It may have reached its click limit, expiration date, or has been deactivated.',
'expired_btn_home' => 'Go to Homepage',
'weights_sum_error' => 'The sum of all variant weights must be exactly 100%. Current sum: :sum%.',
'balance_weights' => 'Adjust to 100%',
'url_key_locked_error' => 'The URL key is locked and cannot be changed.',
'custom_domain_locked_error' => 'The custom domain is locked and cannot be changed.',
// Stats View & Granularity & PoP Trends
'stats_prev_period' => 'prev period',
'stats_granularity_hourly' => 'Hourly',
'stats_granularity_daily' => 'Daily',
'stats_granularity_weekly' => 'Weekly',
'stats_granularity_monthly' => 'Monthly',
// Stats Security Widget
'stats_security_title' => 'Security & Bot Analytics',
'stats_security_real_users' => 'Real Users',
'stats_security_bots' => 'Bots & Crawlers',
'stats_security_vpn_blocked' => 'Blocked VPN/Proxy Traffic',
'stats_security_no_vpn_blocks' => 'No blocked VPN visits',
'stats_security_bot_ratio' => 'Bot Traffic Ratio',
'stats_security_human_clicks' => 'human clicks',
'stats_security_bot_clicks' => 'bot clicks',
'stats_security_proxy_clicks' => 'VPN/Proxy detected',
// Visit Logs Column & Modal translation enhancements
'stats_col_link' => 'Link',
'stats_col_referrer' => 'Referrer',
'stats_col_language' => 'Language',
'stats_col_utm_source' => 'UTM Source',
'stats_col_utm_medium' => 'UTM Medium',
'stats_col_utm_campaign' => 'UTM Campaign',
'stats_col_utm_term' => 'UTM Term',
'stats_col_utm_content' => 'UTM Content',
'stats_col_variant' => 'A/B Variant',
'stats_col_qr_scan' => 'QR Scan',
'stats_col_bot' => 'Bot',
'stats_col_proxy' => 'Proxy/VPN',
'stats_referer_direct' => '(direct)',
'stats_device_desktop' => 'Desktop',
'stats_device_mobile' => 'Mobile',
'stats_device_tablet' => 'Tablet',
'stats_device_bot' => 'Robot / Bot',
'stats_action_view_details' => 'View Details',
'stats_modal_visit_details' => 'Visit Details',
'stats_detail_location' => 'Location',
'stats_detail_is_bot' => 'Is Bot?',
'stats_detail_is_proxy' => 'Is Proxy/VPN?',
'stats_detail_is_qr' => 'Is QR Scan?',
'stats_detail_visited_at' => 'Visited At',
'stats_yes' => 'Yes',
'stats_no' => 'No',
'add_url' => 'Add URL',
'traffic_split' => 'Traffic Split',
'delete_confirmation_desc' => 'Are you sure you want to delete the following link? Deleting these links will remove all of their analytics. This action cannot be undone proceed with caution.',
'delete_verification_label' => 'To verify, type <strong>:short_url</strong> below',
'delete_verification_error' => 'The entered URL does not match.',
// Custom Domains
'empty_state_domain_heading' => 'No custom domains yet',
'empty_state_domain_description' => 'Add your own custom domains to serve short links using your brand identity (e.g. links.acme.com) instead of the default app domain.',
'empty_state_domain_action' => 'Add domain',
'dns_configuration_guide' => 'DNS Configuration Guide',
'dns_for_subdomains' => 'For Subdomains (CNAME)',
'dns_for_root_domains' => 'For Root Domains (A Record)',
'copied' => 'Copied!',
'domains_navigation_label' => 'Custom Domains',
'domain_resource_title' => 'Custom Domain',
'domain_label' => 'Domain Name',
'domain_status_active' => 'Active',
'domain_status_verified' => 'DNS Verified',
'domain_verification_status' => 'Verification Status',
'action_verify_dns' => 'Verify DNS',
'dns_verify_success' => 'DNS record verified successfully!',
'dns_verify_fail' => 'DNS verification failed. Please check your configuration and try again.',
'default_domain_label' => 'Default App Domain',
'dns_tab_a' => 'A Record (recommended)',
'dns_tab_cname' => 'CNAME Record',
'dns_instructions_apex' => 'To configure your apex domain :domain, set the following A record on your DNS provider:',
'dns_instructions_subdomain' => 'To configure your subdomain :domain, set the following CNAME record on your DNS provider:',
'dns_table_type' => 'Type',
'dns_table_name' => 'Name',
'dns_table_value' => 'Value',
'dns_table_ttl' => 'TTL',
'dns_warning_ttl' => 'If a TTL value of 86400 is not available, choose the highest available value. Domain propagation may take up to 12 hours.',
'dns_setup_title' => ':domain - DNS Setup',
'domain_status_valid' => 'Valid',
'domain_status_invalid' => 'Invalid',
'domain_no_redirects' => 'No redirects configured',
'domain_mapped_links' => 'Mapped to :count short link|Mapped to :count short links',
'action_delete_domain' => 'Delete Domain',
'delete_domain_confirmation_desc' => 'Are you sure you want to delete this custom domain? This action cannot be undone proceed with caution.',
'short_link_label' => 'Short Link',
// API Scopes & Rate Limits
'api_key_scope' => 'API Scope',
'api_key_scope_helper' => 'Choose the permission level for this API key.',
'api_key_scope_read_write' => 'Read & Write (Full Access)',
'api_key_scope_read_only' => 'Read Only',
'api_key_rate_limit' => 'Rate Limit',
'api_key_rate_limit_helper' => 'Define the maximum requests per minute allowed for this key.',
'api_key_rate_limit_unlimited' => 'Unlimited',
'api_key_rate_limit_rpm' => ':count requests / min',
// Folders
'folders_navigation_label' => 'Folders',
'folder_resource_title' => 'Folder',
'folder_name' => 'Folder Name',
'folder_slug' => 'Slug',
'folder_color' => 'Color',
'empty_state_folder_action' => 'Create Folder',
'empty_state_folders_heading' => 'No folders yet',
'empty_state_folders_description' => 'Organize your short links into folders.',
'action_edit_folder' => 'Edit Folder',
'action_delete_folder' => 'Delete Folder',
'link_count_one' => '1 link',
'link_count_many' => ':count links',
// Tags
'tags_navigation_label' => 'Tags',
'tag_resource_title' => 'Tag',
'tag_name' => 'Tag Name',
'tag_slug' => 'Slug',
'tag_color' => 'Color',
'empty_state_tag_action' => 'Create Tag',
'empty_state_tags_heading' => 'No tags yet',
'empty_state_tags_description' => 'Tag your short links to filter them easily.',
'action_edit_tag' => 'Edit Tag',
'action_delete_tag' => 'Delete Tag',
// Archiving
'is_archived' => 'Archived',
'is_archived_helper' => 'Archived links are hidden from the active list but continue to redirect.',
'action_archive' => 'Archive',
'action_restore' => 'Restore',
'action_archive_selected' => 'Archive Selected',
'action_restore_selected' => 'Restore Selected',
'tab_active_links' => 'Active',
'tab_archived_links' => 'Archived',
// Live Feed
'stats_tab_live_feed' => 'Live Feed',
'stats_live_feed_empty' => 'No visits recorded yet. Visits will appear here in real-time.',
'stats_live_feed_poll_interval' => 'Updates every 5s',
'stats_col_visitor' => 'Visitor / Location',
// Empty state mock values
'empty_state_tags_mock_redirecting' => 'Redirecting...',
'empty_state_tags_mock_promo' => 'promo',
'empty_state_tags_mock_social' => 'social',
'empty_state_tags_mock_dev' => 'dev',
// Folder links count
'folder_one_link' => '1 link',
'folder_many_links' => ':count links',
// Colors
'color_gray' => 'Gray',
'color_red' => 'Red',
'color_blue' => 'Blue',
'color_green' => 'Green',
'color_yellow' => 'Yellow',
'color_indigo' => 'Indigo',
'color_purple' => 'Purple',
'color_pink' => 'Pink',
];

View File

@@ -1,9 +1,15 @@
<?php
return [
'navigation_label' => 'Krótkie linki',
'navigation_group' => 'Narzędzia',
'navigation_label' => 'Linki',
'navigation_group' => 'Skracacz linków',
'resource_title' => 'Krótki URL',
'empty_state_heading' => 'Brak linków',
'empty_state_description' => 'Zacznij tworzyć krótkie linki dla swoich kampanii marketingowych, programów partnerskich i nie tylko.',
'empty_state_action' => 'Utwórz link',
'empty_state_pixel_heading' => 'Brak pikseli',
'empty_state_pixel_description' => 'Dodaj piksele śledzące z Meta, Google, TikTok i innych platform, aby kierować reklamy do użytkowników klikających Twoje linki.',
'empty_state_pixel_action' => 'Utwórz piksel',
// Form Tabs
'tab_link' => 'Szczegóły linku',
@@ -17,6 +23,9 @@ return [
'auto_open_app_mobile_helper' => 'Włącz tę opcję, jeśli chcesz, aby po kliknięciu linku na telefonie automatycznie otwierała się dedykowana aplikacja mobilna zamiast przeglądarki.',
// Link Form Fields
'destination_type' => 'Typ docelowy',
'destination_type_single' => 'Pojedynczy URL',
'destination_type_split' => 'Rotator / Test A/B',
'destination_url' => 'Docelowy URL',
'destination_url_helper' => 'Oryginalny adres URL, na który chcesz przekierować odwiedzających.',
'url_key' => 'Krótki klucz',
@@ -140,7 +149,10 @@ return [
'action_qr' => 'Kod QR',
'action_edit' => 'Edytuj URL',
'action_delete' => 'Usuń URL',
'url_key_change_confirmation_heading' => 'Zastąpić link?',
'url_key_change_confirmation' => 'Zmieniłeś krótki klucz (Short Key) tego linku. Zapisanie tych zmian spowoduje, że dotychczasowy krótki URL przestanie działać, a wszelkie udostępnione wcześniej linki z poprzednim kluczem wygasną.',
'action_share' => 'Udostępnij link',
'action_move' => 'Przenieś',
'share_title' => 'Udostępnij Link',
'share_description' => 'Udostępnij ten krótki link przez:',
'share_copy' => 'Skopiuj',
@@ -180,6 +192,8 @@ return [
'stats_table_title' => 'Logi odwiedzin',
'stats_btn_back' => 'Wróć do listy',
'stats_btn_copy' => 'Kopiuj krótki URL',
'stats_btn_refresh' => 'Odśwież',
'stats_refresh_success' => 'Statystyki zostały zaktualizowane w czasie rzeczywistym!',
'stats_col_time' => 'Czas',
'stats_col_country' => 'Kraj',
'stats_col_device' => 'Urządzenie',
@@ -207,6 +221,10 @@ return [
'settings_site_name_helper' => 'Własna nazwa witryny/marki wyświetlana na ekranach przekierowań (monit o hasło, ostrzeżenia, piksele). W przypadku braku wartości, zostanie użyta nazwa z konfiguracji config("app.name").',
'settings_route_prefix' => 'Prefiks trasy',
'settings_route_prefix_helper' => 'Segment URL przed kluczem, np. "/s/abc123". Pozostaw puste, aby serwować linki bezpośrednio w głównej domenie (np. "domena.com/abc123"). Zmiana wymaga php artisan config:clear.',
'settings_lock_url_key' => 'Blokuj krótki klucz (Short Key)',
'settings_lock_url_key_helper' => 'Globalnie wyłącz możliwość edycji krótkiego klucza po utworzeniu linku.',
'settings_disable_default_domain' => 'Wyłącz domyślną domenę',
'settings_disable_default_domain_helper' => 'Ukryj domyślną domenę aplikacji na liście wyboru podczas tworzenia i edycji linków.',
'settings_redirect_code_helper' => 'Przekierowanie tymczasowe (302) zmusza przeglądarki do każdorazowego odpytywania serwera, co pozwala na dokładne zliczanie wszystkich wizyt. Przekierowanie stałe (301) jest zapisywane w pamięci podręcznej przeglądarek i wyszukiwarek (lepsze pod SEO), przez co kolejne przejścia tego samego użytkownika mogą nie być rejestrowane w statystykach.',
'settings_key_length' => 'Długość auto-generowanego klucza',
'settings_key_length_helper' => 'Liczba znaków klucza (base62). 6 znaków = ~56 miliardów unikalnych kluczy.',
@@ -228,7 +246,7 @@ 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_info_callout' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M8 0C3.58125 0 0 3.58125 0 8C0 12.4187 3.58125 16 8 16C12.4187 16 16 12.4187 16 8C16 3.58125 12.4187 0 8 0ZM8 14.5C4.41563 14.5 1.5 11.5841 1.5 8C1.5 4.41594 4.41563 1.5 8 1.5C11.5844 1.5 14.5 4.41594 14.5 8C14.5 11.5841 11.5844 14.5 8 14.5ZM9.25 10.5H8.75V7.75C8.75 7.3375 8.41563 7 8 7H7C6.5875 7 6.25 7.3375 6.25 7.75C6.25 8.1625 6.5875 8.5 7 8.5H7.25V10.5H6.75C6.3375 10.5 6 10.8375 6 11.25C6 11.6625 6.3375 12 6.75 12H9.25C9.66406 12 10 11.6641 10 11.25C10 10.8359 9.66563 10.5 9.25 10.5ZM8 6C8.55219 6 9 5.55219 9 5C9 4.44781 8.55219 4 8 4C7.44781 4 7 4.44687 7 5C7 5.55313 7.44687 6 8 6Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Jak skonfigurować MaxMind (Offline Geo-IP):</strong><br>1. Zarejestruj darmowe konto na <a href="https://www.maxmind.com" target="_blank" class="underline text-primary-600 dark:text-primary-400">maxmind.com</a>.<br>2. Pobierz bezpłatną bazę danych <strong>GeoLite2 Country</strong> lub <strong>GeoLite2 City</strong> w formacie binarnym (plik z rozszerzeniem <code class="px-1 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">.mmdb</code>).<br>3. Prześlij plik na swój serwer, np. do katalogu <code class="px-1 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">storage/geoip/GeoLite2-Country.mmdb</code>.<br>4. Wpisz pełną ścieżkę bezwzględną poniżej i kliknij przycisk <strong>Zweryfikuj plik</strong> z prawej strony pola.</span></div></div>',
'settings_maxmind_info_callout' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm.75-11.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm0 2.5a.75.75 0 0 1 .75.75v4a.75.75 0 0 1-1.5 0v-4a.75.75 0 0 1 .75-.75Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Jak skonfigurować MaxMind (Offline Geo-IP):</strong><br>1. Zarejestruj darmowe konto na <a href="https://www.maxmind.com" target="_blank" class="underline text-primary-600 dark:text-primary-400">maxmind.com</a>.<br>2. Pobierz bezpłatną bazę danych <strong>GeoLite2 Country</strong> lub <strong>GeoLite2 City</strong> w formacie binarnym (plik z rozszerzeniem <code class="px-1 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">.mmdb</code>).<br>3. Prześlij plik na swój serwer, np. do katalogu <code class="px-1 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">storage/geoip/GeoLite2-Country.mmdb</code>.<br>4. Wpisz pełną ścieżkę bezwzględną poniżej i kliknij przycisk <strong>Zweryfikuj plik</strong> z prawej strony pola.</span></div></div>',
'settings_maxmind_verify' => 'Weryfikuj plik',
'settings_maxmind_verify_ok' => '✅ Plik znaleziony i dostępny',
'settings_maxmind_verify_fail' => '❌ Plik nie istnieje lub jest niedostępny',
@@ -247,7 +265,7 @@ return [
'settings_section_buffering' => 'Buforowanie liczników wizyt',
'settings_buffering_enabled' => 'Buforuj kliknięcia w pamięci podręcznej (Cache)',
'settings_buffering_helper' => 'Po włączeniu, zliczenia kliknięć będą buforowane w pamięci podręcznej aplikacji i muszą być okresowo synchronizowane z bazą danych za pomocą komendy "php artisan short-url:sync-counters". Zapobiega to blokowaniu wierszy bazy danych i spadkom wydajności przy masowym ruchu.',
'settings_buffering_worker_info' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M8 0C3.58125 0 0 3.58125 0 8C0 12.4187 3.58125 16 8 16C12.4187 16 16 12.4187 16 8C16 3.58125 12.4187 0 8 0ZM8 14.5C4.41563 14.5 1.5 11.5841 1.5 8C1.5 4.41594 4.41563 1.5 8 1.5C11.5844 1.5 14.5 4.41594 14.5 8C14.5 11.5841 11.5844 14.5 8 14.5ZM9.25 10.5H8.75V7.75C8.75 7.3375 8.41563 7 8 7H7C6.5875 7 6.25 7.3375 6.25 7.75C6.25 8.1625 6.5875 8.5 7 8.5H7.25V10.5H6.75C6.3375 10.5 6 10.8375 6 11.25C6 11.6625 6.3375 12 6.75 12H9.25C9.66406 12 10 11.6641 10 11.25C10 10.8359 9.66563 10.5 9.25 10.5ZM8 6C8.55219 6 9 5.55219 9 5C9 4.44781 8.55219 4 8 4C7.44781 4 7 4.44687 7 5C7 5.55313 7.44687 6 8 6Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full [&_kbd]:bg-background-light dark:[&_kbd]:bg-background-dark [_code]:!text-current [_kbd]:!text-current [_a]:!text-current [_a]:border-current [_strong]:!text-current text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Wymagane zadanie Cron (Scheduler):</strong> Włączyłeś buforowanie kliknięć. Musisz dodać do harmonogramu zadań (cron) komendę synchronizującą liczniki z bazą danych:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">* * * * * cd /sciezka-do-twojego-projektu && php artisan schedule:run >> /dev/null 2>&1</code><br>Komenda uruchamiana automatycznie w tle przez harmonogram:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">php artisan short-url:sync-counters</code></span></div></div>',
'settings_buffering_worker_info' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm.75-11.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm0 2.5a.75.75 0 0 1 .75.75v4a.75.75 0 0 1-1.5 0v-4a.75.75 0 0 1 .75-.75Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full [&_kbd]:bg-background-light dark:[&_kbd]:bg-background-dark [_code]:!text-current [_kbd]:!text-current [_a]:!text-current [_a]:border-current [_strong]:!text-current text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Wymagane zadanie Cron (Scheduler):</strong> Włączyłeś buforowanie kliknięć. Musisz dodać do harmonogramu zadań (cron) komendę synchronizującą liczniki z bazą danych:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">* * * * * cd /sciezka-do-twojego-projektu && php artisan schedule:run >> /dev/null 2>&1</code><br>Komenda uruchamiana automatycznie w tle przez harmonogram:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">php artisan short-url:sync-counters</code></span></div></div>',
// CDN Trust Settings
'settings_trust_cdn_headers' => 'Ufaj nagłówkom CDN i proxy',
@@ -266,6 +284,15 @@ return [
'stats_csv_browser' => 'Przeglądarka',
'stats_csv_os' => 'System operacyjny',
'stats_csv_referer' => 'Źródło (Referer)',
'stats_csv_utm_source' => 'Źródło UTM (utm_source)',
'stats_csv_utm_medium' => 'Medium UTM (utm_medium)',
'stats_csv_utm_campaign' => 'Kampania UTM (utm_campaign)',
'stats_csv_utm_term' => 'Słowo kluczowe UTM (utm_term)',
'stats_csv_utm_content' => 'Treść UTM (utm_content)',
'stats_csv_variant' => 'Wariant A/B',
'stats_csv_qr_scan' => 'Skan QR',
'stats_csv_bot' => 'Bot',
'stats_csv_proxy' => 'Proxy/VPN',
// Bulk Actions Localization
'action_enable_selected' => 'Włącz zaznaczone',
@@ -289,9 +316,11 @@ return [
'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_breakdown_utm_source' => 'Źródło',
'stats_breakdown_utm_medium' => 'Medium',
'stats_breakdown_utm_campaign' => 'Kampania',
'stats_breakdown_utm_term' => 'Słowo kluczowe',
'stats_breakdown_utm_content' => 'Treść',
'stats_no_utm_data' => 'Brak danych o parametrach UTM.',
// New Targeting & Security Tab and fields
@@ -329,6 +358,31 @@ return [
'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.',
'rotation_variants' => 'Warianty rotacji A/B',
'rotation_variants_helper' => 'Zdefiniuj adresy URL oraz podział ruchu dla testów split A/B.',
'variant_label' => 'Etykieta wariantu',
'variant_url' => 'Docelowy URL wariantu',
'variant_weight' => 'Udział w ruchu (%)',
'safe_browsing_error' => 'Ten adres URL został oznaczony przez Google Safe Browsing jako niebezpieczny.',
// New Advanced Targeting Builder
'targeting_rules' => 'Reguły targetowania',
'add_filter' => 'Dodaj filtr',
'match' => 'Warunek dopasowania',
'match_or' => 'Dowolny z poniższych filtrów (OR)',
'match_and' => 'Wszystkie poniższe filtry (AND)',
'filter_device' => 'Urządzenie',
'filter_platform' => 'Platforma (System operacyjny)',
'filter_country' => 'Kraj',
'filter_language' => 'Język przeglądarki',
'direct_to_url' => 'Przekieruj na URL',
'select_devices' => 'Wybierz urządzenia',
'select_platforms' => 'Wybierz systemy operacyjne',
'select_countries' => 'Wybierz kraje',
'select_languages' => 'Wybierz języki przeglądarki',
'device_desktop_label' => 'Komputer stacjonarny',
'device_mobile_label' => 'Smartfon',
'device_tablet_label' => 'Tablet',
// New Settings Page Fields
'settings_tab_advanced' => 'Wydajność i bezpieczeństwo',
@@ -354,7 +408,7 @@ return [
// Queue Settings Additions
'settings_queue_name' => 'Nazwa kolejki',
'settings_queue_name_helper' => 'Docelowa nazwa kolejki, do której wysyłane są zadania śledzenia i synchronizacji liczników. Domyślnie: "default".',
'settings_queue_worker_info' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M8 0C3.58125 0 0 3.58125 0 8C0 12.4187 3.58125 16 8 16C12.4187 16 16 12.4187 16 8C16 3.58125 12.4187 0 8 0ZM8 14.5C4.41563 14.5 1.5 11.5841 1.5 8C1.5 4.41594 4.41563 1.5 8 1.5C11.5844 1.5 14.5 4.41594 14.5 8C14.5 11.5841 11.5844 14.5 8 14.5ZM9.25 10.5H8.75V7.75C8.75 7.3375 8.41563 7 8 7H7C6.5875 7 6.25 7.3375 6.25 7.75C6.25 8.1625 6.5875 8.5 7 8.5H7.25V10.5H6.75C6.3375 10.5 6 10.8375 6 11.25C6 11.6625 6.3375 12 6.75 12H9.25C9.66406 12 10 11.6641 10 11.25C10 10.8359 9.66563 10.5 9.25 10.5ZM8 6C8.55219 6 9 5.55219 9 5C9 4.44781 8.55219 4 8 4C7.44781 4 7 4.44687 7 5C7 5.55313 7.44687 6 8 6Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full [&_kbd]:bg-background-light dark:[&_kbd]:bg-background-dark [_code]:!text-current [_kbd]:!text-current [_a]:!text-current [_a]:border-current [_strong]:!text-current text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Wymagany proces w tle (Queue Worker):</strong> Wybrane połączenie działa asynchronicznie. Musisz uruchomić proces w tle (workera), aby przetwarzać te zadania:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">php artisan queue:work --queue=:queue</code></span></div></div>',
'settings_queue_worker_info' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm.75-11.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm0 2.5a.75.75 0 0 1 .75.75v4a.75.75 0 0 1-1.5 0v-4a.75.75 0 0 1 .75-.75Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full [&_kbd]:bg-background-light dark:[&_kbd]:bg-background-dark [_code]:!text-current [_kbd]:!text-current [_a]:!text-current [_a]:border-current [_strong]:!text-current text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Wymagany proces w tle (Queue Worker):</strong> Wybrane połączenie działa asynchronicznie. Musisz uruchomić proces w tle (workera), aby przetwarzać te zadania:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">php artisan queue:work --queue=:queue</code></span></div></div>',
'settings_geoip_stats_cache_ttl' => 'Czas życia (TTL) pamięci podręcznej statystyk',
'settings_geoip_stats_cache_ttl_helper' => 'Czas (w sekundach) buforowania obliczeń statystyk linków na pulpicie. Kiedy przeglądasz statystyki, system pobiera dane z cache zamiast każdorazowo przeliczać miliony rekordów w bazie danych. Domyślnie: 300 (5 minut). Maksymalnie: 86400 (24 godziny).',
@@ -371,6 +425,9 @@ return [
'settings_track_referer_default' => 'Domyślne śledzenie adresu referera',
'settings_track_device_type_default' => 'Domyślne śledzenie typu urządzenia',
'settings_track_browser_language_default' => 'Domyślne śledzenie języka przeglądarki',
'settings_track_anonymize_ips' => 'Anonimizacja adresów IP (RODO/GDPR)',
'settings_track_anonymize_ips_helper' => 'Zastępuje ostatni oktet adresów IPv4 lub ostatnie 80 bitów adresów IPv6 zerami przed zapisaniem ich w bazie danych w celu zapewnienia zgodności z RODO/GDPR. Surowy adres IP jest nadal prywatnie haszowany (SHA-256), co gwarantuje poprawność zliczania unikalnych odwiedzin.',
'stats_filter_limit_exceeded' => 'Maksymalnie 5 filtrów może być nałożonych jednocześnie.',
// QR Defaults Tab
'settings_tab_qr' => 'Domyślne QR',
@@ -434,6 +491,8 @@ return [
'api_key_name' => 'Nazwa klucza (Opis)',
'api_key' => 'Klucz API',
'active' => 'Aktywny',
'api_key_generated' => 'Wygenerowano klucz API',
'api_key_warning' => 'Skopiuj ten klucz teraz. Nie będzie możliwości ponownego jego wyświetlenia!',
// Ochrona v2.0
'settings_section_security_v2' => 'Bezpieczeństwo i ochrona przed nadużyciami v2.0',
@@ -470,6 +529,7 @@ return [
'world_map_no_data' => 'Brak danych geograficznych.',
'world_map_no_data_sub' => 'Włącz wykrywanie Geo-IP, aby zacząć rejestrować lokalizacje odwiedzających.',
'stats_card_qr_scans' => 'Zeskanowania QR',
'stats_card_qr_rate' => 'Konwersja kodów QR',
'stats_breakdown_languages' => 'Najpopularniejsze języki',
'stats_no_language_data' => 'Brak danych o językach.',
'badge_clicks' => 'kliknięć',
@@ -520,4 +580,169 @@ return [
'expired_title' => 'Link nieaktywny lub wygasł',
'expired_description' => 'Ten link jest już nieaktywny. Mógł osiągnąć limit kliknięć, minął jego termin ważności lub został wyłączony.',
'expired_btn_home' => 'Przejdź do strony głównej',
'weights_sum_error' => 'Suma udziałów w ruchu musi wynosić dokładnie 100%. Obecna suma: :sum%.',
'balance_weights' => 'Wyreguluj do 100%',
'url_key_locked_error' => 'Klucz URL jest zablokowany i nie może być zmieniony.',
'custom_domain_locked_error' => 'Domena niestandardowa jest zablokowana i nie może być zmieniona.',
// Stats View & Granularity & PoP Trends
'stats_prev_period' => 'poprzedni okres',
'stats_granularity_hourly' => 'Godzinowo',
'stats_granularity_daily' => 'Dziennie',
'stats_granularity_weekly' => 'Tygodniowo',
'stats_granularity_monthly' => 'Miesięcznie',
// Stats Security Widget
'stats_security_title' => 'Bezpieczeństwo i ruch botów',
'stats_security_real_users' => 'Prawdziwi użytkownicy',
'stats_security_bots' => 'Boty i skanery',
'stats_security_vpn_blocked' => 'Zablokowany ruch VPN/Proxy',
'stats_security_no_vpn_blocks' => 'Brak zablokowanych wejść z VPN',
'stats_security_bot_ratio' => 'Wskaźnik ruchu botów',
'stats_security_human_clicks' => 'kliknięć ludzi',
'stats_security_bot_clicks' => 'kliknięć botów',
'stats_security_proxy_clicks' => 'wykrytych VPN/Proxy',
// Visit Logs Column & Modal translation enhancements
'stats_col_link' => 'Link',
'stats_col_referrer' => 'Źródło (Referer)',
'stats_col_language' => 'Język',
'stats_col_utm_source' => 'Źródło UTM (utm_source)',
'stats_col_utm_medium' => 'Medium UTM (utm_medium)',
'stats_col_utm_campaign' => 'Kampania UTM (utm_campaign)',
'stats_col_utm_term' => 'Słowo kluczowe UTM (utm_term)',
'stats_col_utm_content' => 'Treść UTM (utm_content)',
'stats_col_variant' => 'Wariant A/B',
'stats_col_qr_scan' => 'Skan QR',
'stats_col_bot' => 'Bot',
'stats_col_proxy' => 'Proxy/VPN',
'stats_referer_direct' => '(bezpośrednie)',
'stats_device_desktop' => 'Komputer stacjonarny',
'stats_device_mobile' => 'Smartfon',
'stats_device_tablet' => 'Tablet',
'stats_device_bot' => 'Robot / Bot',
'stats_action_view_details' => 'Szczegóły',
'stats_modal_visit_details' => 'Szczegóły wizyty',
'stats_detail_location' => 'Lokalizacja',
'stats_detail_is_bot' => 'Czy bot?',
'stats_detail_is_proxy' => 'Czy Proxy/VPN?',
'stats_detail_is_qr' => 'Czy skan QR?',
'stats_detail_visited_at' => 'Czas wizyty',
'stats_yes' => 'Tak',
'stats_no' => 'Nie',
'add_url' => 'Dodaj URL',
'traffic_split' => 'Podział ruchu',
'delete_confirmation_desc' => 'Czy na pewno chcesz usunąć ten link? Usunięcie spowoduje bezpowrotne skasowanie wszystkich statystyk i analiz. Tej operacji nie można cofnąć postępuj ostrożnie.',
'delete_verification_label' => 'Aby zweryfikować, wpisz poniżej <strong>:short_url</strong>',
'delete_verification_error' => 'Wpisany adres URL się nie zgadza.',
// Custom Domains
'empty_state_domain_heading' => 'Brak własnych domen',
'empty_state_domain_description' => 'Dodaj własne domeny, aby skrócone linki były serwowane pod Twoją marką (np. links.acme.com) zamiast domyślnej domeny.',
'empty_state_domain_action' => 'Dodaj domenę',
'dns_configuration_guide' => 'Przewodnik konfiguracji DNS',
'dns_for_subdomains' => 'Dla subdomen (CNAME)',
'dns_for_root_domains' => 'Dla domen głównych (Rekord A)',
'copied' => 'Skopiowano!',
'domains_navigation_label' => 'Własne domeny',
'domain_resource_title' => 'Własna domena',
'domain_label' => 'Nazwa domeny',
'domain_status_active' => 'Aktywna',
'domain_status_verified' => 'DNS Zweryfikowany',
'domain_verification_status' => 'Status weryfikacji',
'action_verify_dns' => 'Zweryfikuj DNS',
'dns_verify_success' => 'Rekord DNS zweryfikowany pomyślnie!',
'dns_verify_fail' => 'Weryfikacja DNS nie powiodła się. Sprawdź konfigurację i spróbuj ponownie.',
'dns_instructions_title' => 'Instrukcje DNS',
'default_domain_label' => 'Domyślna domena aplikacji',
'dns_tab_a' => 'Rekord A (rekomendowany)',
'dns_tab_cname' => 'Rekord CNAME',
'dns_instructions_apex' => 'Aby skonfigurować domenę główną (apex) :domain, ustaw następujący rekord A u swojego dostawcy DNS:',
'dns_instructions_subdomain' => 'Aby skonfigurować subdomenę :domain, ustaw następujący rekord CNAME u swojego dostawcy DNS:',
'dns_table_type' => 'Typ',
'dns_table_name' => 'Nazwa',
'dns_table_value' => 'Wartość',
'dns_table_ttl' => 'TTL',
'dns_warning_ttl' => 'Jeśli wartość TTL 86400 nie jest dostępna, wybierz najwyższą dostępną wartość. Propagacja domeny może potrwać do 12 godzin.',
'dns_setup_title' => ':domain - Konfiguracja DNS',
'domain_status_valid' => 'Zweryfikowana',
'domain_status_invalid' => 'Niezweryfikowana',
'domain_no_redirects' => 'Brak przypisanych linków',
'domain_mapped_links' => 'Przypisano :count krótki link|Przypisano :count krótkie linki|Przypisano :count krótkich linków',
'action_delete_domain' => 'Usuń domenę',
'delete_domain_confirmation_desc' => 'Czy na pewno chcesz usunąć tę własną domenę? Ta akcja nie może zostać cofnięta postępuj ostrożnie.',
'short_link_label' => 'Krótki link',
// API Scopes & Rate Limits
'api_key_scope' => 'Uprawnienia (Scope)',
'api_key_scope_helper' => 'Wybierz poziom dostępu dla tego klucza API.',
'api_key_scope_read_write' => 'Odczyt i Zapis (Pełny dostęp)',
'api_key_scope_read_only' => 'Tylko Odczyt (Read-Only)',
'api_key_rate_limit' => 'Limit zapytań',
'api_key_rate_limit_helper' => 'Zdefiniuj maksymalną liczbę zapytań na minutę dopuszczalną dla tego klucza.',
'api_key_rate_limit_unlimited' => 'Bez limitu',
'api_key_rate_limit_rpm' => ':count zapytań / min',
// Folders
'folders_navigation_label' => 'Foldery',
'folder_resource_title' => 'Folder',
'folder_name' => 'Nazwa folderu',
'folder_slug' => 'Uproszczona nazwa (Slug)',
'folder_color' => 'Kolor',
'empty_state_folder_action' => 'Utwórz folder',
'empty_state_folders_heading' => 'Brak folderów',
'empty_state_folders_description' => 'Uporządkuj swoje krótkie linki w folderach.',
'action_edit_folder' => 'Edytuj folder',
'action_delete_folder' => 'Usuń folder',
'link_count_one' => '1 link',
'link_count_many' => ':count linków',
// Tagi
'tags_navigation_label' => 'Tagi',
'tag_resource_title' => 'Tag',
'tag_name' => 'Nazwa tagu',
'tag_slug' => 'Uproszczona nazwa (Slug)',
'tag_color' => 'Kolor',
'empty_state_tag_action' => 'Utwórz tag',
'empty_state_tags_heading' => 'Brak tagów',
'empty_state_tags_description' => 'Oznaczaj swoje krótkie linki tagami, by łatwo je filtrować.',
'action_edit_tag' => 'Edytuj tag',
'action_delete_tag' => 'Usuń tag',
// Archiving
'is_archived' => 'Zarchiwizowany',
'is_archived_helper' => 'Zarchiwizowane linki są ukrywane z głównej listy, ale nadal przekierowują użytkowników.',
'action_archive' => 'Archiwizuj',
'action_restore' => 'Przywróć',
'action_archive_selected' => 'Zarchiwizuj zaznaczone',
'action_restore_selected' => 'Przywróć zaznaczone',
'tab_active_links' => 'Aktywne',
'tab_archived_links' => 'Zarchiwizowane',
// Live Feed
'stats_tab_live_feed' => 'Strumień aktywności',
'stats_live_feed_empty' => 'Brak wizyt. Nowe wizyty pojawią się tutaj w czasie rzeczywistym.',
'stats_live_feed_poll_interval' => 'Aktualizacja co 5s',
'stats_col_visitor' => 'Odwiedzający / Lokalizacja',
// Puste stany - makiety
'empty_state_tags_mock_redirecting' => 'Przekierowywanie...',
'empty_state_tags_mock_promo' => 'promocja',
'empty_state_tags_mock_social' => 'social-media',
'empty_state_tags_mock_dev' => 'programisci',
// Liczba linków w folderze
'folder_one_link' => '1 link',
'folder_many_links' => ':count linków',
// Colors
'color_gray' => 'Szary',
'color_red' => 'Czerwony',
'color_blue' => 'Niebieski',
'color_green' => 'Zielony',
'color_yellow' => 'Żółty',
'color_indigo' => 'Indygo',
'color_purple' => 'Fioletowy',
'color_pink' => 'Różowy',
];

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ __('filament-short-url::default.app_redirect_title') ?? 'Opening App...' }}</title>
<title>{{ __('filament-short-url::default.app_redirect_title') }}</title>
<!-- Premium Google Fonts: Bricolage Grotesque -->
<link rel="preconnect" href="https://fonts.googleapis.com">
@@ -154,10 +154,10 @@
</div>
<p class="text-xl font-bold text-neutral-900 dark:text-white mt-2">
{{ __('filament-short-url::default.app_redirect_opening_in', ['app' => $appName]) ?? "Opening in {$appName}" }}
{{ __('filament-short-url::default.app_redirect_opening_in', ['app' => $appName]) }}
</p>
<p class="text-sm text-neutral-400 dark:text-neutral-500 mt-2 max-w-[280px]">
{{ __('filament-short-url::default.app_redirect_waiting_text') ?? 'We are redirecting you to the native application. Please wait a moment...' }}
{{ __('filament-short-url::default.app_redirect_waiting_text') }}
</p>
</div>
@@ -171,7 +171,7 @@
onmouseover="this.style.filter='brightness(1.05)';"
onmouseout="this.style.filter='brightness(0.95)';"
id="deep-link-btn">
<span>{{ __('filament-short-url::default.app_redirect_btn_open') ?? 'Open Native App' }}</span>
<span>{{ __('filament-short-url::default.app_redirect_btn_open') }}</span>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
@@ -181,7 +181,7 @@
<a href="{{ $destination }}"
class="w-full py-3 rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 hover:bg-neutral-50 dark:hover:bg-neutral-800 text-neutral-500 dark:text-neutral-400 font-semibold text-sm transition duration-200 flex justify-center items-center gap-2 shadow-sm"
id="fallback-btn">
<span>{{ __('filament-short-url::default.app_redirect_btn_browser') ?? 'Open in Browser' }}</span>
<span>{{ __('filament-short-url::default.app_redirect_btn_browser') }}</span>
</a>
</div>
@@ -191,10 +191,10 @@
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span>{{ __('filament-short-url::default.app_redirect_in_app_warning_title') ?? 'In-App Browser Detected' }}</span>
<span>{{ __('filament-short-url::default.app_redirect_in_app_warning_title') }}</span>
</p>
<p class="text-[11px] leading-relaxed opacity-95">
{{ __('filament-short-url::default.app_redirect_in_app_warning_desc') ?? 'Redirections can be restricted inside Instagram or Facebook. Tap the three dots (•••) in the top-right corner and select "Open in Safari" or "Open in Chrome" to launch the app.' }}
{{ __('filament-short-url::default.app_redirect_in_app_warning_desc') }}
</p>
</div>
</div>

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ __('filament-short-url::default.expired_title') ?? 'Link Inactive or Expired' }}</title>
<title>{{ __('filament-short-url::default.expired_title') }}</title>
<!-- Premium Google Fonts: Bricolage Grotesque -->
<link rel="preconnect" href="https://fonts.googleapis.com">
@@ -50,10 +50,10 @@
</div>
<p class="text-xl font-medium text-neutral-900 dark:text-white mt-2">
{{ __('filament-short-url::default.expired_title') ?? 'Link Inactive or Expired' }}
{{ __('filament-short-url::default.expired_title') }}
</p>
<p class="text-sm text-neutral-400 dark:text-neutral-500 mt-1">
{{ __('filament-short-url::default.expired_description') ?? 'This link is no longer active. It may have reached its click limit, expiration date, or has been deactivated.' }}
{{ __('filament-short-url::default.expired_description') }}
</p>
</div>
@@ -74,7 +74,7 @@
{{-- Action Button --}}
<a href="{{ url('/') }}" class="w-full mt-2 py-3.5 rounded-xl bg-neutral-900 hover:bg-neutral-800 dark:bg-white dark:hover:bg-neutral-200 dark:text-neutral-900 text-white font-semibold text-sm transition duration-200 flex justify-center items-center gap-2 text-center">
<span>{{ __('filament-short-url::default.expired_btn_home') ?? 'Go to Homepage' }}</span>
<span>{{ __('filament-short-url::default.expired_btn_home') }}</span>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>

View File

@@ -0,0 +1,159 @@
<x-dynamic-component
:component="$getFieldWrapperView()"
:field="$field"
>
<div
x-data="{
state: $wire.{{ $applyStateBindingModifiers("\$entangle('{$getTargetStatePath()}')") }},
get items() {
if (!this.state) return [];
if (Array.isArray(this.state)) {
return this.state;
}
return Object.entries(this.state).map(([key, value]) => ({
key,
...value
}));
},
get totalSum() {
return this.items.reduce((sum, item) => sum + parseInt(item.weight || 0), 0);
},
init() {
this.$watch('state', () => {
this.checkAndNormalize();
});
this.checkAndNormalize();
},
checkAndNormalize() {
const items = this.items;
if (items.length < 2) return;
const sum = items.reduce((s, i) => s + parseInt(i.weight || 0), 0);
if (sum !== 100) {
const m = items.length;
const base = Math.floor(100 / m);
const remainder = 100 % m;
// Re-balance equally using 1% resolution
items.forEach((item, index) => {
const newWeight = base + (index < remainder ? 1 : 0);
this.updateWeight(item, newWeight);
});
}
},
updateWeight(item, newWeight) {
if (Array.isArray(this.state)) {
const index = this.state.findIndex(i => i === item || (i.key && i.key === item.key));
if (index !== -1) {
this.state[index].weight = newWeight;
}
} else if (this.state && typeof this.state === 'object') {
if (this.state[item.key]) {
this.state[item.key].weight = newWeight;
}
}
},
startDrag(index, event) {
event.preventDefault();
const isTouch = event.type.startsWith('touch');
const startX = isTouch ? event.touches[0].clientX : event.clientX;
const rect = this.$refs.container.getBoundingClientRect();
const containerWidth = rect.width;
const containerLeft = rect.left;
if (!isTouch) {
document.body.style.cursor = 'col-resize';
}
// Get cumulative weights up to index
let sumBefore = 0;
const items = this.items;
for (let j = 0; j < index; j++) {
sumBefore += parseInt(items[j].weight || 0);
}
// Total sum of current and next segment
const segmentSum = parseInt(items[index].weight || 0) + parseInt(items[index+1].weight || 0);
const onMove = (e) => {
const currentX = e.type.startsWith('touch') ? e.touches[0].clientX : e.clientX;
const offsetPercent = ((currentX - containerLeft) / containerWidth) * 100;
const minWeight = 1;
const minPercent = sumBefore + minWeight;
const maxPercent = sumBefore + segmentSum - minWeight;
let targetPercent = Math.round(offsetPercent);
targetPercent = Math.max(minPercent, Math.min(maxPercent, targetPercent));
const newWeightCurrent = targetPercent - sumBefore;
const newWeightNext = segmentSum - newWeightCurrent;
this.updateWeight(items[index], newWeightCurrent);
this.updateWeight(items[index+1], newWeightNext);
};
const onEnd = () => {
if (!isTouch) {
document.body.style.cursor = '';
}
if (isTouch) {
window.removeEventListener('touchmove', onMove);
window.removeEventListener('touchend', onEnd);
} else {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onEnd);
}
};
if (isTouch) {
window.addEventListener('touchmove', onMove, { passive: true });
window.addEventListener('touchend', onEnd);
} else {
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onEnd);
}
}
}"
class="traffic-splitter-component mt-2"
>
<div
x-ref="container"
class="relative h-10 w-full select-none"
x-show="items.length >= 2"
>
<div class="absolute inset-0 flex h-full">
<template x-for="(item, index) in items" :key="item.key || index">
<!-- Segment container -->
<div
class="@container pointer-events-none relative flex h-full"
:style="'width: ' + item.weight + '%;'"
>
<!-- Left Spacer (only if not first item) -->
<div class="w-1.5" x-show="index > 0"></div>
<!-- Segment Box -->
<div class="flex h-full grow items-center justify-center gap-2 rounded-md border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 text-xs shadow-sm select-none">
<span class="text-xs font-semibold text-neutral-900 dark:text-neutral-100" x-text="index + 1"></span>
<span class="font-medium text-neutral-600 dark:text-neutral-400" x-show="item.weight >= 12" x-text="item.weight + '%'"></span>
</div>
<!-- Right Spacer (only if not last item) -->
<div class="w-1.5" x-show="index < items.length - 1"></div>
<!-- Drag Handle (visible only if not the last item) -->
<div
x-show="index < items.length - 1"
class="group pointer-events-auto absolute -right-1.5 flex h-full w-3 cursor-col-resize items-center px-1 z-30"
@mousedown.stop="startDrag(index, $event)"
@touchstart.stop="startDrag(index, $event)"
>
<div class="h-2/3 w-1 rounded-full bg-neutral-300 dark:bg-neutral-600 group-hover:bg-neutral-400 dark:group-hover:bg-neutral-400 group-active:bg-neutral-500 transition-colors duration-150"></div>
</div>
</div>
</template>
</div>
</div>
</div>
</x-dynamic-component>

View File

@@ -0,0 +1,35 @@
<x-filament-panels::page>
<x-filament::tabs class="mb-6">
<x-filament::tabs.item
tag="a"
href="{{ \Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource::getUrl('stats', ['record' => $record]) }}"
icon="heroicon-m-presentation-chart-line"
>
{{ __('filament-short-url::default.stats_tab_statistics') }}
</x-filament::tabs.item>
<x-filament::tabs.item
:active="true"
icon="heroicon-m-bolt"
>
{{ __('filament-short-url::default.stats_tab_live_feed') }}
</x-filament::tabs.item>
<x-filament::tabs.item
tag="a"
href="{{ \Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource::getUrl('stats.logs', ['record' => $record]) }}"
icon="heroicon-m-list-bullet"
>
{{ __('filament-short-url::default.stats_tab_visit_logs') }}
</x-filament::tabs.item>
</x-filament::tabs>
<div class="mt-2">
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlLiveFeedWidget::class, [
'record' => $record,
'dateFrom' => null,
'dateTo' => null,
'filters' => [],
], key('live-feed-page-' . $record->id))
</div>
</x-filament-panels::page>

View File

@@ -8,6 +8,14 @@
{{ __('filament-short-url::default.stats_tab_statistics') }}
</x-filament::tabs.item>
<x-filament::tabs.item
tag="a"
href="{{ \Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource::getUrl('stats.live', ['record' => $record]) }}"
icon="heroicon-m-bolt"
>
{{ __('filament-short-url::default.stats_tab_live_feed') }}
</x-filament::tabs.item>
<x-filament::tabs.item
:active="true"
icon="heroicon-m-list-bullet"

View File

@@ -3,7 +3,7 @@
<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>
<title>{{ __('filament-short-url::default.password_title') }}</title>
<!-- Premium Google Fonts: Bricolage Grotesque -->
<link rel="preconnect" href="https://fonts.googleapis.com">
@@ -49,10 +49,10 @@
<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' }}
{{ __('filament-short-url::default.password_title') }}
</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.' }}
{{ __('filament-short-url::default.password_description') }}
</p>
</div>
@@ -63,7 +63,7 @@
<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' }}
{{ __('filament-short-url::default.password_placeholder') }}
</label>
<input
type="password"
@@ -85,7 +85,7 @@
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>
<span>{{ __('filament-short-url::default.password_btn_unlock') }}</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>

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ __('filament-short-url::default.pixel_loading_title') ?? 'Connecting...' }}</title>
<title>{{ __('filament-short-url::default.pixel_loading_title') }}</title>
<!-- Premium Google Fonts: Bricolage Grotesque -->
<link rel="preconnect" href="https://fonts.googleapis.com">
@@ -148,10 +148,10 @@
</div>
<p class="text-xl font-medium text-neutral-900 dark:text-white mt-2">
{{ __('filament-short-url::default.pixel_loading_title') ?? 'Connecting...' }}
{{ __('filament-short-url::default.pixel_loading_title') }}
</p>
<p class="text-sm text-neutral-400 dark:text-neutral-500 mt-1">
{{ __('filament-short-url::default.pixel_loading_description') ?? 'Preparing your connection and forwarding you now.' }}
{{ __('filament-short-url::default.pixel_loading_description') }}
</p>
</div>

View File

@@ -9,7 +9,6 @@
if (isset($component) && method_exists($component, 'getStatePath')) {
$statePath = $component->getStatePath();
\Illuminate\Support\Facades\Log::info('QR DESIGNER STATE PATH: ' . $statePath);
if ($statePath) {
$parts = explode('.', $statePath);
if (end($parts) === 'qr_designer') {

View File

@@ -1,12 +1,21 @@
<x-filament-panels::page>
<x-filament::tabs class="mb-6">
<x-filament::tabs.item
:active="true"
wire:click="$set('activeTab', 'statistics')"
:active="$activeTab === 'statistics'"
icon="heroicon-m-presentation-chart-line"
>
{{ __('filament-short-url::default.stats_tab_statistics') }}
</x-filament::tabs.item>
<x-filament::tabs.item
tag="a"
href="{{ \Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource::getUrl('stats.live', ['record' => $record]) }}"
icon="heroicon-m-bolt"
>
{{ __('filament-short-url::default.stats_tab_live_feed') }}
</x-filament::tabs.item>
<x-filament::tabs.item
tag="a"
href="{{ \Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource::getUrl('stats.logs', ['record' => $record]) }}"
@@ -20,6 +29,8 @@
$dateFrom = $this->filterData['date_from'] ?? null;
$dateTo = $this->filterData['date_to'] ?? null;
$isCustom = ($this->filterData['preset'] ?? '') === 'custom';
$activeFilters = $this->activeFilters ?? [];
$filtersHash = md5(json_encode($activeFilters));
@endphp
<div class="space-y-6">
@@ -31,34 +42,98 @@
</div>
</div>
@if (! empty($activeFilters))
<div class="flex flex-wrap items-center gap-2 p-3 bg-gray-50/50 dark:bg-white/5 border border-gray-100 dark:border-white/10 rounded-xl">
<span class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mr-1">Active Filters:</span>
@foreach ($activeFilters as $key => $value)
@php
$displayValue = $value;
if ($key === 'country_code') {
$translatedCountry = __('filament-short-url::countries.' . strtoupper($value));
if ($translatedCountry && $translatedCountry !== 'filament-short-url::countries.' . strtoupper($value)) {
$displayValue = $translatedCountry;
}
}
@endphp
<div class="flex items-center gap-1.5 px-3 py-1 bg-indigo-50 dark:bg-indigo-950/40 border border-indigo-100 dark:border-indigo-900/60 rounded-full text-xs font-semibold text-indigo-700 dark:text-indigo-300">
<span class="opacity-75 uppercase text-[10px] tracking-wider">{{ $key === 'country_code' ? 'country' : str_replace('_', ' ', $key) }}:</span>
<span>{{ $displayValue }}</span>
<button type="button"
wire:click="clearStatsFilter('{{ $key }}')"
class="hover:text-indigo-900 dark:hover:text-indigo-100 transition-colors focus:outline-none ml-1">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
@endforeach
<button type="button"
wire:click="clearAllStatsFilters"
class="text-xs font-medium text-gray-500 hover:text-indigo-600 dark:text-gray-400 dark:hover:text-indigo-400 transition-colors focus:outline-none ml-auto px-2.5 py-1 rounded-md hover:bg-gray-100 dark:hover:bg-white/5">
Clear all filters
</button>
</div>
@endif
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlStatsOverview::class, [
'record' => $record,
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
], key('stats-overview-' . $dateFrom . '-' . $dateTo))
'filters' => $activeFilters,
], key('stats-overview-' . $dateFrom . '-' . $dateTo . '-' . $filtersHash))
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsChart::class, [
'record' => $record,
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
], key('stats-chart-' . $dateFrom . '-' . $dateTo))
'filters' => $activeFilters,
], key('stats-chart-' . $dateFrom . '-' . $dateTo . '-' . $filtersHash))
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsRightBreakdown::class, [
'record' => $record,
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
], key('stats-right-breakdown-' . $dateFrom . '-' . $dateTo))
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlGeoBreakdownWidget::class, [
'record' => $record,
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
'filters' => $activeFilters,
], key('stats-geo-breakdown-' . $dateFrom . '-' . $dateTo . '-' . $filtersHash))
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlDeviceBreakdownWidget::class, [
'record' => $record,
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
'filters' => $activeFilters,
], key('stats-device-breakdown-' . $dateFrom . '-' . $dateTo . '-' . $filtersHash))
</div>
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlWorldMapWidget::class, [
'record' => $record,
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
], key('stats-world-map-' . $dateFrom . '-' . $dateTo))
'filters' => $activeFilters,
], key('stats-world-map-' . $dateFrom . '-' . $dateTo . '-' . $filtersHash))
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsBottomBreakdown::class, [
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlSourcesBreakdownWidget::class, [
'record' => $record,
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
'filters' => $activeFilters,
], key('stats-sources-breakdown-' . $dateFrom . '-' . $dateTo . '-' . $filtersHash))
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlSecurityBreakdownWidget::class, [
'record' => $record,
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
'filters' => $activeFilters,
], key('stats-security-breakdown-' . $dateFrom . '-' . $dateTo . '-' . $filtersHash))
</div>
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVariantsWidget::class, [
'record' => $record,
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
], key('stats-bottom-breakdown-' . $dateFrom . '-' . $dateTo))
'filters' => $activeFilters,
], key('stats-variants-' . $dateFrom . '-' . $dateTo . '-' . $filtersHash))
</div>
</x-filament-panels::page>

View File

@@ -0,0 +1,247 @@
@php
$record = $getRecord();
$appHost = parse_url(config('app.url'), PHP_URL_HOST) ?: request()->getHost();
// Dynamically resolve target IP for A Record
$serverIp = null;
try {
$serverIp = gethostbyname($appHost);
if ($serverIp === $appHost) {
$serverIp = $_SERVER['SERVER_ADDR'] ?? null;
}
} catch (\Throwable $e) {
$serverIp = $_SERVER['SERVER_ADDR'] ?? null;
}
if (!$serverIp || $serverIp === '127.0.0.1') {
$serverIp = '76.76.21.21'; // Dummy IP for demonstration if local env has loopback
}
// Extract subdomain name
$domainParts = explode('.', $record->domain);
$isSubdomain = count($domainParts) > 2;
$subdomainName = $isSubdomain ? $domainParts[0] : '@';
$shortUrlsCount = $record->short_urls_count ?? 0;
$clicksCount = $record->total_clicks ?? 0;
@endphp
<div x-data="{ open: false, activeTab: 'A' }" class="w-full text-left">
<!-- Header / Collapsed Row -->
<div class="px-5 py-4 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<!-- Left Section: Icon & Info -->
<div class="flex items-center gap-3.5 min-w-0">
<!-- Globe Icon -->
<div class="flex items-center justify-center w-10 h-10 rounded-full border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-950/40 flex-shrink-0">
<svg class="w-5 h-5 text-neutral-500 dark:text-neutral-450" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-.778.099-1.533.284-2.253" />
</svg>
</div>
<div class="min-w-0">
<!-- Domain and Primary Badge -->
<div class="flex items-center gap-2">
<span class="text-[15px] font-bold text-neutral-800 dark:text-neutral-200 truncate leading-tight">
{{ $record->domain }}
</span>
@if($record->is_active)
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-blue-50/80 dark:bg-blue-950/30 text-blue-650 dark:text-blue-400 border border-blue-100/60 dark:border-blue-900/40">
Primary
</span>
@endif
</div>
<!-- Subtitle -->
<div class="text-[12px] text-neutral-450 dark:text-neutral-500 mt-1 flex items-center gap-1.5">
<span></span>
@if($shortUrlsCount === 0)
<span>No redirect configured</span>
@else
<span>Mapped to {{ $shortUrlsCount }} short {{ Str::plural('link', $shortUrlsCount) }}</span>
@endif
</div>
</div>
</div>
<!-- Middle Section: Clicks & Verification Status -->
<div class="flex items-center gap-3 sm:ml-auto">
<!-- Clicks Badge -->
<span class="inline-flex items-center gap-1.5 px-3 py-1 rounded-xl border border-neutral-200/60 dark:border-neutral-800 bg-neutral-50/50 dark:bg-neutral-900/50 text-[12px] font-semibold text-neutral-700 dark:text-neutral-300">
<svg class="w-3.5 h-3.5 text-neutral-450 dark:text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.042 21.672 13.684 16.6m0 0-2.51 2.225.569-9.47 5.227 7.917-3.286-.672ZM12 2.25V4.5m5.303.197-1.593 1.593M21.75 12H19.5m-.197 5.303-1.593-1.593M12 21.75V19.5m-5.303-.197 1.593-1.593M2.25 12H4.5m.197-5.303 1.593 1.593" />
</svg>
{{ $clicksCount }} clicks
</span>
<!-- Status Badge -->
@if($record->is_verified)
<span class="inline-flex items-center gap-1.5 px-3 py-1 rounded-xl bg-emerald-50 dark:bg-emerald-950/20 border border-emerald-150 dark:border-emerald-900/40 text-[12px] font-semibold text-emerald-650 dark:text-emerald-450">
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12c0 1.268-.63 2.39-1.593 3.068a3.745 3.745 0 0 1-1.043 3.296 3.745 3.745 0 0 1-3.296 1.043A3.745 3.745 0 0 1 12 21c-1.268 0-2.39-.63-3.068-1.593a3.746 3.746 0 0 1-3.296-1.043 3.745 3.745 0 0 1-1.043-3.296A3.745 3.745 0 0 1 3 12c0-1.268.63-2.39 1.593-3.068a3.745 3.745 0 0 1 1.043-3.296 3.746 3.746 0 0 1 3.296-1.043A3.746 3.746 0 0 1 12 3c1.268 0 2.39.63 3.068 1.593a3.746 3.746 0 0 1 3.296 1.043 3.746 3.746 0 0 1 1.043 3.296A3.745 3.745 0 0 1 21 12Z" />
</svg>
Valid
</span>
@else
<span class="inline-flex items-center gap-1.5 px-3 py-1 rounded-xl bg-red-50 dark:bg-red-950/20 border border-red-150 dark:border-red-900/40 text-[12px] font-semibold text-red-650 dark:text-red-400">
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z" />
</svg>
Invalid
</span>
@endif
</div>
<!-- Right Section: Actions Buttons -->
<div class="flex items-center gap-2">
<!-- Settings Toggle Button with red indicator when invalid -->
<button @click="open = !open" type="button" class="relative inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold rounded-lg border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-50 dark:hover:bg-neutral-950 transition-all shadow-sm focus:outline-none cursor-pointer">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.43l-1.003.828c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.43l1.004-.827c.292-.24.437-.613.43-.991a6.936 6.936 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.33-.183.58-.495.643-.869L9.594 3.94Z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
<!-- Live Red Dot Indicator -->
@if(!$record->is_verified)
<span class="absolute top-0.5 right-0.5 flex h-2 w-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-red-500"></span>
</span>
@endif
<!-- Chevron -->
<svg class="w-3 h-3 text-neutral-400 transition-transform duration-200" :class="open ? 'rotate-180' : ''" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</button>
<!-- Actions Dropdown -->
<div x-data="{ dropdownOpen: false }" class="relative">
<button @click="dropdownOpen = !dropdownOpen" type="button" class="inline-flex items-center justify-center w-8 h-8 rounded-lg border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-950 transition-all shadow-sm focus:outline-none cursor-pointer">
<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="M12 6.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5ZM12 12.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5ZM12 18.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5Z" />
</svg>
</button>
<div x-show="dropdownOpen" @click.away="dropdownOpen = false" x-cloak class="absolute right-0 mt-2 w-48 rounded-lg shadow-lg bg-white dark:bg-neutral-850 border border-neutral-200/80 dark:border-neutral-800 z-50 py-1.5 text-left transition-all">
<!-- Verify DNS / Refresh -->
<button @click="$wire.callTableAction('verify', {{ $record->id }}); dropdownOpen = false" type="button" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-900 focus:outline-none cursor-pointer">
<svg class="w-4 h-4 text-neutral-450 dark:text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
Verify / Refresh DNS
</button>
<!-- Edit Action -->
<button @click="$wire.mountTableAction('edit', {{ $record->id }}); dropdownOpen = false" type="button" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-900 focus:outline-none cursor-pointer">
<svg class="w-4 h-4 text-neutral-450 dark:text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" />
</svg>
Edit Domain
</button>
<hr class="border-neutral-100 dark:border-neutral-800 my-1">
<!-- Delete Action -->
<button @click="$wire.mountTableAction('delete', {{ $record->id }}); dropdownOpen = false" type="button" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-red-655 dark:text-red-400 hover:bg-neutral-50 dark:hover:bg-neutral-900 focus:outline-none cursor-pointer">
<svg class="w-4 h-4 text-red-450 dark:text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
</svg>
Delete Domain
</button>
</div>
</div>
</div>
</div>
<!-- Expanded DNS Panel -->
<div x-show="open" x-collapse x-cloak class="border-t border-neutral-100 dark:border-neutral-800/80 px-6 py-5 bg-neutral-50/20 dark:bg-neutral-950/5">
<div class="pt-2">
<!-- Tabs Menu -->
<div class="-ml-1.5 border-b border-neutral-200 dark:border-neutral-800">
<div class="flex text-sm">
<!-- Tab A -->
<div class="relative">
<button @click="activeTab = 'A'" type="button" class="p-4 transition-colors duration-75 text-neutral-400 hover:text-neutral-700 dark:text-neutral-500 dark:hover:text-neutral-300 font-medium focus:outline-none cursor-pointer" :class="activeTab === 'A' ? 'text-neutral-900 dark:text-white font-semibold' : ''">
A Record (recommended)
</button>
<div x-show="activeTab === 'A'" class="absolute bottom-0 w-full px-1.5 text-neutral-900 dark:text-white">
<div class="h-0.5 rounded-t-full bg-current"></div>
</div>
</div>
<!-- Tab CNAME -->
<div class="relative">
<button @click="activeTab = 'CNAME'" type="button" class="p-4 transition-colors duration-75 text-neutral-400 hover:text-neutral-700 dark:text-neutral-500 dark:hover:text-neutral-300 font-medium focus:outline-none cursor-pointer" :class="activeTab === 'CNAME' ? 'text-neutral-900 dark:text-white font-semibold' : ''">
CNAME Record
</button>
<div x-show="activeTab === 'CNAME'" x-cloak class="absolute bottom-0 w-full px-1.5 text-neutral-900 dark:text-white">
<div class="h-0.5 rounded-t-full bg-current"></div>
</div>
</div>
</div>
</div>
<!-- Tab Content -->
<div class="mt-3 text-left text-neutral-600">
<!-- Instruction Paragraph -->
<div class="my-5">
<p class="prose-sm prose-code:rounded-md prose-code:bg-neutral-100 dark:prose-code:bg-neutral-800 prose-code:p-1 prose-code:text-[.8125rem] prose-code:font-medium prose-code:font-mono prose-code:text-neutral-900 dark:prose-code:text-neutral-150 max-w-none">
To configure your <span x-text="activeTab === 'A' ? 'apex domain' : 'subdomain'"></span> <code>{{ $record->domain }}</code>, set the following <span x-text="activeTab === 'A' ? 'A record' : 'CNAME record'"></span> on your DNS provider:
</p>
</div>
<!-- Parameters Table (CSS Grid format) -->
<div class="scrollbar-hide grid items-end gap-x-10 gap-y-1 overflow-x-auto rounded-lg bg-neutral-100/80 dark:bg-neutral-950/40 p-4 text-sm grid-cols-[repeat(4,max-content)]">
<!-- Table Headers -->
<p class="font-medium text-neutral-950 dark:text-white">Type</p>
<p class="font-medium text-neutral-950 dark:text-white">Name</p>
<p class="font-medium text-neutral-950 dark:text-white">Value</p>
<p class="font-medium text-neutral-950 dark:text-white">TTL</p>
<!-- Tab A content -->
<div x-show="activeTab === 'A'" class="contents">
<p class="font-mono text-neutral-600 dark:text-neutral-400">A</p>
<p class="font-mono text-neutral-600 dark:text-neutral-400">@</p>
<p class="flex items-center gap-1 font-mono text-neutral-900 dark:text-white" x-data="{ copied: false }">
<span>{{ $serverIp }}</span>
<button @click="navigator.clipboard.writeText('{{ $serverIp }}'); copied = true; setTimeout(() => copied = false, 2000)" class="relative group rounded-full p-1.5 transition-all duration-75 bg-transparent hover:bg-neutral-200 dark:hover:bg-neutral-800 active:bg-neutral-300 dark:active:bg-neutral-700 -mb-0.5 cursor-pointer focus:outline-none" type="button">
<span class="sr-only">Copy</span>
<svg x-show="!copied" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" width="14" height="14" class="h-3.5 w-3.5 text-neutral-450 dark:text-neutral-500">
<path d="M8 17.929H6c-1.105 0-2-.912-2-2.036V5.036C4 3.91 4.895 3 6 3h8c1.105 0 2 .911 2 2.036v1.866m-6 .17h8c1.105 0 2 .91 2 2.035v10.857C20 21.09 19.105 22 18 22h-8c-1.105 0-2-.911-2-2.036V9.107c0-1.124.895-2.036 2-2.036z"></path>
</svg>
<svg x-show="copied" x-cloak class="h-3.5 w-3.5 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
</button>
</p>
<p class="font-mono text-neutral-600 dark:text-neutral-400">86400</p>
</div>
<!-- Tab CNAME content -->
<div x-show="activeTab === 'CNAME'" x-cloak class="contents">
<p class="font-mono text-neutral-600 dark:text-neutral-400">CNAME</p>
<p class="font-mono text-neutral-600 dark:text-neutral-400">{{ $subdomainName }}</p>
<p class="flex items-center gap-1 font-mono text-neutral-900 dark:text-white" x-data="{ copied: false }">
<span>{{ $appHost }}</span>
<button @click="navigator.clipboard.writeText('{{ $appHost }}'); copied = true; setTimeout(() => copied = false, 2000)" class="relative group rounded-full p-1.5 transition-all duration-75 bg-transparent hover:bg-neutral-200 dark:hover:bg-neutral-800 active:bg-neutral-300 dark:active:bg-neutral-700 -mb-0.5 cursor-pointer focus:outline-none" type="button">
<span class="sr-only">Copy</span>
<svg x-show="!copied" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" width="14" height="14" class="h-3.5 w-3.5 text-neutral-450 dark:text-neutral-500">
<path d="M8 17.929H6c-1.105 0-2-.912-2-2.036V5.036C4 3.91 4.895 3 6 3h8c1.105 0 2 .911 2 2.036v1.866m-6 .17h8c1.105 0 2 .91 2 2.035v10.857C20 21.09 19.105 22 18 22h-8c-1.105 0-2-.911-2-2.036V9.107c0-1.124.895-2.036 2-2.036z"></path>
</svg>
<svg x-show="copied" x-cloak class="h-3.5 w-3.5 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
</button>
</p>
<p class="font-mono text-neutral-600 dark:text-neutral-400">86400</p>
</div>
</div>
<!-- Info Alert Block -->
<div class="mt-4 flex items-center gap-2 rounded-lg p-3 bg-indigo-50 dark:bg-indigo-950/20 text-indigo-650 dark:text-indigo-400 border border-indigo-100/30 dark:border-indigo-900/30">
<svg height="18" width="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0">
<g fill="currentColor">
<circle cx="9" cy="9" fill="none" r="7.25" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></circle>
<line fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" x1="9" x2="9" y1="12.819" y2="8.25"></line>
<path d="M9,6.75c-.552,0-1-.449-1-1s.448-1,1-1,1,.449,1,1-.448,1-1,1Z" fill="currentColor" stroke="none"></path>
</g>
</svg>
<p class="prose-sm prose-code:rounded-md prose-code:bg-neutral-100 dark:prose-code:bg-neutral-800 prose-code:p-1 prose-code:text-[.8125rem] prose-code:font-medium prose-code:font-mono prose-code:text-neutral-900 dark:prose-code:text-neutral-150 max-w-none">
If a TTL value of 86400 is not available, choose the highest available value. Domain propagation may take up to 12 hours.
</p>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,22 @@
<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>
@if(($record->destination_type ?? 'single') === 'split')
<span class="truncate max-w-[50ch] text-[#273144] dark:text-gray-300 text-[15px] leading-[16px] font-semibold italic">
{{ __('filament-short-url::default.destination_type_split') }}
</span>
@else
<span onclick="event.preventDefault(); event.stopPropagation(); window.open('{{ e($record->destination_url) }}', '_blank', 'noopener,noreferrer');"
class="truncate max-w-[50ch] hover:underline text-[#273144] dark:text-gray-350 text-[15px] leading-[16px] font-medium cursor-pointer"
title="{{ e($record->destination_url) }}">
{{ $record->destination_url }}
</span>
@endif
</div>

View File

@@ -0,0 +1,122 @@
@php
$appHost = parse_url(config('app.url'), PHP_URL_HOST) ?: request()->getHost();
// Dynamically resolve target IP for A Record
$serverIp = null;
try {
$serverIp = gethostbyname($appHost);
if ($serverIp === $appHost) {
$serverIp = $_SERVER['SERVER_ADDR'] ?? null;
}
} catch (\Throwable $e) {
$serverIp = $_SERVER['SERVER_ADDR'] ?? null;
}
if (!$serverIp || $serverIp === '127.0.0.1') {
$serverIp = '76.76.21.21'; // Dummy IP for demonstration
}
// Extract subdomain name
$domainParts = explode('.', $record->domain);
$isSubdomain = count($domainParts) > 2;
$subdomainName = $isSubdomain ? $domainParts[0] : '@';
@endphp
<div x-data="{ activeTab: 'A' }" class="p-6 bg-neutral-50/50 dark:bg-neutral-950/20 border-t border-neutral-100 dark:border-neutral-800 text-left">
<!-- Tabs Menu -->
<div class="-ml-1.5 border-b border-neutral-200 dark:border-neutral-800">
<div class="flex text-sm">
<!-- Tab A -->
<div class="relative">
<button @click="activeTab = 'A'" type="button" class="p-4 transition-colors duration-75 text-neutral-400 hover:text-neutral-700 dark:text-neutral-500 dark:hover:text-neutral-300 font-medium focus:outline-none cursor-pointer" :class="activeTab === 'A' ? 'text-neutral-900 dark:text-white font-semibold' : ''">
{{ __('filament-short-url::default.dns_tab_a') }}
</button>
<div x-show="activeTab === 'A'" class="absolute bottom-0 w-full px-1.5 text-neutral-900 dark:text-white">
<div class="h-0.5 rounded-t-full bg-current"></div>
</div>
</div>
<!-- Tab CNAME -->
<div class="relative">
<button @click="activeTab = 'CNAME'" type="button" class="p-4 transition-colors duration-75 text-neutral-400 hover:text-neutral-700 dark:text-neutral-500 dark:hover:text-neutral-300 font-medium focus:outline-none cursor-pointer" :class="activeTab === 'CNAME' ? 'text-neutral-900 dark:text-white font-semibold' : ''">
{{ __('filament-short-url::default.dns_tab_cname') }}
</button>
<div x-show="activeTab === 'CNAME'" x-cloak class="absolute bottom-0 w-full px-1.5 text-neutral-900 dark:text-white">
<div class="h-0.5 rounded-t-full bg-current"></div>
</div>
</div>
</div>
</div>
<!-- Tab Content -->
<div class="mt-3 text-left text-neutral-600">
<!-- Instruction Paragraph -->
<div class="my-5">
<p x-show="activeTab === 'A'" class="prose-sm prose-code:rounded-md prose-code:bg-neutral-100 dark:prose-code:bg-neutral-800 prose-code:p-1 prose-code:text-[.8125rem] prose-code:font-medium prose-code:font-mono prose-code:text-neutral-900 dark:prose-code:text-neutral-150 max-w-none">
{!! __('filament-short-url::default.dns_instructions_apex', ['domain' => '<code>' . e($record->domain) . '</code>']) !!}
</p>
<p x-show="activeTab === 'CNAME'" x-cloak class="prose-sm prose-code:rounded-md prose-code:bg-neutral-100 dark:prose-code:bg-neutral-800 prose-code:p-1 prose-code:text-[.8125rem] prose-code:font-medium prose-code:font-mono prose-code:text-neutral-900 dark:prose-code:text-neutral-150 max-w-none">
{!! __('filament-short-url::default.dns_instructions_subdomain', ['domain' => '<code>' . e($record->domain) . '</code>']) !!}
</p>
</div>
<!-- Parameters Table (CSS Grid format) -->
<div class="scrollbar-hide grid items-end gap-x-10 gap-y-1 overflow-x-auto rounded-lg bg-neutral-100/80 dark:bg-neutral-950/40 p-4 text-sm grid-cols-[repeat(4,max-content)]">
<!-- Table Headers -->
<p class="font-medium text-neutral-950 dark:text-white">{{ __('filament-short-url::default.dns_table_type') }}</p>
<p class="font-medium text-neutral-950 dark:text-white">{{ __('filament-short-url::default.dns_table_name') }}</p>
<p class="font-medium text-neutral-950 dark:text-white">{{ __('filament-short-url::default.dns_table_value') }}</p>
<p class="font-medium text-neutral-950 dark:text-white">{{ __('filament-short-url::default.dns_table_ttl') }}</p>
<!-- Tab A content -->
<div x-show="activeTab === 'A'" class="contents">
<p class="font-mono text-neutral-600 dark:text-neutral-400">A</p>
<p class="font-mono text-neutral-600 dark:text-neutral-400">@</p>
<p class="flex items-end gap-1 font-mono text-neutral-900 dark:text-white" x-data="{ copied: false }">
<span>{{ $serverIp }}</span>
<button @click="navigator.clipboard.writeText('{{ $serverIp }}'); copied = true; setTimeout(() => copied = false, 2000)" class="relative group rounded-full p-1.5 transition-all duration-75 bg-transparent hover:bg-neutral-100 dark:hover:bg-neutral-800 active:bg-neutral-200 dark:active:bg-neutral-700 -mb-0.5 cursor-pointer focus:outline-none" type="button">
<span class="sr-only">Copy</span>
<svg x-show="!copied" fill="none" shape-rendering="geometricPrecision" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" width="14" height="14" class="h-3.5 w-3.5 text-neutral-400 dark:text-neutral-500">
<path d="M8 17.929H6c-1.105 0-2-.912-2-2.036V5.036C4 3.91 4.895 3 6 3h8c1.105 0 2 .911 2 2.036v1.866m-6 .17h8c1.105 0 2 .91 2 2.035v10.857C20 21.09 19.105 22 18 22h-8c-1.105 0-2-.911-2-2.036V9.107c0-1.124.895-2.036 2-2.036z"></path>
</svg>
<svg x-show="copied" x-cloak class="h-3.5 w-3.5 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
</button>
</p>
<p class="font-mono text-neutral-600 dark:text-neutral-400">86400</p>
</div>
<!-- Tab CNAME content -->
<div x-show="activeTab === 'CNAME'" x-cloak class="contents">
<p class="font-mono text-neutral-600 dark:text-neutral-400">CNAME</p>
<p class="font-mono text-neutral-600 dark:text-neutral-400">{{ $subdomainName }}</p>
<p class="flex items-end gap-1 font-mono text-neutral-900 dark:text-white" x-data="{ copied: false }">
<span>{{ $appHost }}</span>
<button @click="navigator.clipboard.writeText('{{ $appHost }}'); copied = true; setTimeout(() => copied = false, 2000)" class="relative group rounded-full p-1.5 transition-all duration-75 bg-transparent hover:bg-neutral-100 dark:hover:bg-neutral-800 active:bg-neutral-200 dark:active:bg-neutral-700 -mb-0.5 cursor-pointer focus:outline-none" type="button">
<span class="sr-only">Copy</span>
<svg x-show="!copied" fill="none" shape-rendering="geometricPrecision" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" width="14" height="14" class="h-3.5 w-3.5 text-neutral-400 dark:text-neutral-500">
<path d="M8 17.929H6c-1.105 0-2-.912-2-2.036V5.036C4 3.91 4.895 3 6 3h8c1.105 0 2 .911 2 2.036v1.866m-6 .17h8c1.105 0 2 .91 2 2.035v10.857C20 21.09 19.105 22 18 22h-8c-1.105 0-2-.911-2-2.036V9.107c0-1.124.895-2.036 2-2.036z"></path>
</svg>
<svg x-show="copied" x-cloak class="h-3.5 w-3.5 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
</button>
</p>
<p class="font-mono text-neutral-600 dark:text-neutral-400">86400</p>
</div>
</div>
<!-- Warning Alert Block -->
<div class="mt-4 flex items-center gap-2 rounded-lg p-3 bg-orange-50 dark:bg-orange-950/20 text-orange-600 dark:text-orange-400 border border-orange-100/30 dark:border-orange-900/30">
<svg height="18" width="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0">
<g fill="currentColor">
<circle cx="9" cy="9" fill="none" r="7.25" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></circle>
<line fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" x1="9" x2="9" y1="12.819" y2="8.25"></line>
<path d="M9,6.75c-.552,0-1-.449-1-1s.448-1,1-1,1,.449,1,1-.448,1-1,1Z" fill="currentColor" stroke="none"></path>
</g>
</svg>
<p class="prose-sm prose-code:rounded-md prose-code:bg-neutral-100 dark:prose-code:bg-neutral-800 prose-code:p-1 prose-code:text-[.8125rem] prose-code:font-medium prose-code:font-mono prose-code:text-neutral-900 dark:prose-code:text-neutral-150 max-w-none">
{{ __('filament-short-url::default.dns_warning_ttl') }}
</p>
</div>
</div>
</div>

View File

@@ -0,0 +1,38 @@
@php
$shortUrlsCount = $record->short_urls_count ?? $record->shortUrls()->count();
@endphp
<div class="flex items-center gap-3.5 min-w-0 py-2">
<!-- Globe Icon -->
<div class="flex items-center justify-center w-10 h-10 rounded-full border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-950/40 flex-shrink-0">
<svg class="w-5 h-5 text-neutral-500 dark:text-neutral-450" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-.778.099-1.533.284-2.253" />
</svg>
</div>
<div class="min-w-0">
<!-- Domain -->
<div class="flex items-center gap-2">
<span class="text-[15px] font-bold text-neutral-800 dark:text-neutral-200 truncate leading-tight">
{{ $record->domain }}
</span>
</div>
<!-- Subtitle -->
<div class="text-[12px] text-neutral-450 dark:text-neutral-500 mt-1 flex items-center gap-1.5">
<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>
@if($shortUrlsCount === 0)
<span>{{ __('filament-short-url::default.domain_no_redirects') }}</span>
@else
<span>{{ trans_choice('filament-short-url::default.domain_mapped_links', $shortUrlsCount, ['count' => $shortUrlsCount]) }}</span>
@endif
</div>
</div>
</div>

View File

@@ -0,0 +1,67 @@
<div class="flex flex-col items-center justify-center gap-6 px-4 py-10 md:min-h-[550px]">
<!-- Custom Domain Visual / Logo Grid -->
<div class="relative flex items-center justify-center h-28 w-full max-w-xs md:max-w-md overflow-hidden rounded-2xl border border-neutral-200/60 dark:border-neutral-800 bg-neutral-50/50 dark:bg-neutral-900/30 p-6 shadow-inner">
<div class="absolute inset-0 bg-[linear-gradient(to_right,#8080800a_1px,transparent_1px),linear-gradient(to_bottom,#8080800a_1px,transparent_1px)] bg-[size:14px_24px] [mask-image:radial-gradient(ellipse_60%_50%_at_50%_0%,#000_70%,transparent_100%)]"></div>
<div class="relative flex items-center gap-4 z-10">
<!-- Icon 1: Domain Globe -->
<div class="relative flex items-center justify-center w-12 h-12 rounded-xl bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 shadow-sm transition-transform duration-300 hover:-translate-y-1">
<!-- Ping green dot in the corner to show activity -->
<span class="absolute -top-1 -right-1 flex h-2.5 w-2.5">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500"></span>
</span>
<svg class="w-6 h-6 text-neutral-600 dark:text-neutral-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-.778.099-1.533.284-2.253" />
</svg>
</div>
<!-- Connection Line with flowing green dash animation -->
<div class="flex items-center justify-center w-12">
<svg class="w-full h-2" viewBox="0 0 48 8" fill="none">
<!-- Base grey dashes -->
<line x1="0" y1="4" x2="48" y2="4" stroke="#e5e5e5" class="dark:stroke-neutral-800" stroke-width="2" stroke-linecap="round" stroke-dasharray="4 4" />
<!-- Green flowing dashes on top -->
<line x1="0" y1="4" x2="48" y2="4" stroke="#10b981" stroke-width="2.5" stroke-linecap="round" stroke-dasharray="4 8" style="animation: fsu-flow-green 1.4s linear infinite;" />
</svg>
<style>
@keyframes fsu-flow-green {
to {
stroke-dashoffset: -12;
}
}
</style>
</div>
<!-- Icon 2: Verification Badge -->
<div class="relative flex items-center justify-center w-12 h-12 rounded-xl bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 shadow-sm transition-transform duration-300 hover:-translate-y-1">
<svg class="w-6 h-6 text-emerald-500 dark:text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12c0 1.268-.63 2.39-1.593 3.068a3.745 3.745 0 0 1-1.043 3.296 3.745 3.745 0 0 1-3.296 1.043A3.745 3.745 0 0 1 12 21c-1.268 0-2.39-.63-3.068-1.593a3.746 3.746 0 0 1-3.296-1.043 3.745 3.745 0 0 1-1.043-3.296A3.745 3.745 0 0 1 3 12c0-1.268.63-2.39 1.593-3.068a3.745 3.745 0 0 1 1.043-3.296 3.746 3.746 0 0 1 3.296-1.043A3.746 3.746 0 0 1 12 3c1.268 0 2.39.63 3.068 1.593a3.746 3.746 0 0 1 3.296 1.043 3.746 3.746 0 0 1 1.043 3.296A3.745 3.745 0 0 1 21 12Z" />
</svg>
</div>
</div>
</div>
<!-- Title and Description -->
<div class="max-w-md text-pretty text-center px-4">
<span class="text-base font-semibold text-neutral-900 dark:text-neutral-100">
{{ __('filament-short-url::default.empty_state_domain_heading') }}
</span>
<div class="mt-2 text-pretty text-sm text-neutral-500 dark:text-neutral-400">
{{ __('filament-short-url::default.empty_state_domain_description') }}
</div>
</div>
<!-- Create Button -->
@if(!isset($hideCreateButton) || !$hideCreateButton)
<div class="flex items-center gap-2">
<button type="button"
x-on:click="$wire.mountAction('create')"
class="group flex h-10 items-center justify-center gap-2 whitespace-nowrap rounded-lg border border-black bg-black text-white hover:bg-neutral-800 dark:border-white dark:bg-white dark:text-black dark:hover:bg-neutral-100 hover:ring-4 hover:ring-neutral-200 dark:hover:ring-neutral-800/50 px-4 text-sm font-semibold transition-all cursor-pointer">
<svg class="size-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
<span>{{ __('filament-short-url::default.empty_state_domain_action') }}</span>
</button>
</div>
@endif
</div>

View File

@@ -0,0 +1,133 @@
<div class="flex flex-col items-center justify-center gap-6 px-4 py-10 md:min-h-[550px]">
<!-- Folders Visual Grid -->
<div class="relative flex items-center justify-center h-40 w-full max-w-xs md:max-w-md overflow-hidden rounded-2xl border border-neutral-200/60 dark:border-neutral-800 bg-neutral-50/50 dark:bg-neutral-900/30 p-6 shadow-inner">
<div class="absolute inset-0 bg-[linear-gradient(to_right,#8080800a_1px,transparent_1px),linear-gradient(to_bottom,#8080800a_1px,transparent_1px)] bg-[size:14px_24px] [mask-image:radial-gradient(ellipse_60%_50%_at_50%_0%,#000_70%,transparent_100%)]"></div>
<!-- 3D Folder Container -->
<div class="relative w-48 h-32 flex items-center justify-center z-10" style="perspective: 1000px;">
<!-- Folder Back -->
<div class="absolute bottom-2 w-28 h-18 bg-neutral-300 dark:bg-neutral-800 rounded-lg shadow-sm border border-neutral-400/30 dark:border-neutral-700/50" style="transform: translateZ(-30px);">
<!-- Folder Tab -->
<div class="absolute -top-2 left-3 w-10 h-3 bg-neutral-300 dark:bg-neutral-800 rounded-t-md border-t border-x border-neutral-400/30 dark:border-neutral-700/50"></div>
</div>
<!-- Floating Document/Link Cards (Middle Layer) -->
<!-- Card 1 -->
<div class="absolute w-24 h-12 rounded-lg bg-gradient-to-br from-blue-500/90 to-indigo-600/90 text-white shadow-md border border-white/20 flex flex-col justify-between p-2 select-none"
style="animation: fsu-folder-card-1 4s infinite cubic-bezier(0.25, 1, 0.5, 1); transform-style: preserve-3d;">
<div class="flex items-center gap-1.5">
<div class="w-3.5 h-3.5 rounded-full bg-white/25 flex items-center justify-center flex-shrink-0">
<svg class="w-2.5 h-2.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
</svg>
</div>
<div class="h-1.5 w-8 bg-white/30 rounded"></div>
</div>
<div class="h-1 w-12 bg-white/20 rounded"></div>
</div>
<!-- Card 2 -->
<div class="absolute w-24 h-12 rounded-lg bg-gradient-to-br from-violet-500/90 to-purple-600/90 text-white shadow-md border border-white/20 flex flex-col justify-between p-2 select-none"
style="animation: fsu-folder-card-2 4s infinite cubic-bezier(0.25, 1, 0.5, 1); animation-delay: 2s; transform-style: preserve-3d;">
<div class="flex items-center gap-1.5">
<div class="w-3.5 h-3.5 rounded-full bg-white/25 flex items-center justify-center flex-shrink-0">
<svg class="w-2.5 h-2.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
</svg>
</div>
<div class="h-1.5 w-8 bg-white/30 rounded"></div>
</div>
<div class="h-1 w-12 bg-white/20 rounded"></div>
</div>
<!-- Folder Front (Glassmorphic & Tilted) -->
<div class="absolute bottom-2 w-28 h-16 bg-neutral-100/50 dark:bg-neutral-900/50 border border-white/30 dark:border-neutral-800/80 rounded-lg shadow-lg origin-bottom transition-all"
style="backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); transform: rotateX(-16deg); transform-style: preserve-3d; animation: fsu-folder-front 4s infinite ease-in-out; box-shadow: 0 12px 20px -8px rgba(0,0,0,0.15), 0 4px 6px -2px rgba(0,0,0,0.05);">
<!-- Folder lock / branding line -->
<div class="absolute top-2 left-3 w-6 h-1 bg-neutral-300 dark:bg-neutral-700 rounded-full"></div>
<div class="absolute bottom-2 right-3 w-3 h-3 rounded-full border border-neutral-300 dark:border-neutral-750 flex items-center justify-center">
<div class="w-1 h-1 rounded-full bg-neutral-400 dark:bg-neutral-650"></div>
</div>
</div>
</div>
</div>
<style>
@keyframes fsu-folder-front {
0%, 100% {
transform: rotateX(-15deg);
}
50% {
transform: rotateX(-22deg);
}
}
@keyframes fsu-folder-card-1 {
0% {
transform: translate3d(35px, -60px, 10px) rotate(12deg) scale(0.8);
opacity: 0;
}
15% {
transform: translate3d(20px, -45px, 20px) rotate(6deg) scale(0.95);
opacity: 1;
}
35% {
transform: translate3d(0px, -20px, 0px) rotate(0deg) scale(1);
opacity: 1;
}
55% {
transform: translate3d(0px, 10px, -15px) rotate(-2deg) scale(0.9);
opacity: 0.8;
}
75%, 100% {
transform: translate3d(0px, 25px, -25px) rotate(-4deg) scale(0.75);
opacity: 0;
}
}
@keyframes fsu-folder-card-2 {
0% {
transform: translate3d(-35px, -60px, 10px) rotate(-12deg) scale(0.8);
opacity: 0;
}
15% {
transform: translate3d(-20px, -45px, 20px) rotate(-6deg) scale(0.95);
opacity: 1;
}
35% {
transform: translate3d(0px, -20px, 0px) rotate(0deg) scale(1);
opacity: 1;
}
55% {
transform: translate3d(0px, 10px, -15px) rotate(2deg) scale(0.9);
opacity: 0.8;
}
75%, 100% {
transform: translate3d(0px, 25px, -25px) rotate(4deg) scale(0.75);
opacity: 0;
}
}
</style>
<!-- Title and Description -->
<div class="max-w-md text-pretty text-center px-4">
<span class="text-base font-semibold text-neutral-900 dark:text-neutral-100">
{{ __('filament-short-url::default.empty_state_folders_heading') }}
</span>
<div class="mt-2 text-pretty text-sm text-neutral-500 dark:text-neutral-400">
{{ __('filament-short-url::default.empty_state_folders_description') }}
</div>
</div>
<!-- Create Button -->
@if(!isset($hideCreateButton) || !$hideCreateButton)
<div class="flex items-center gap-2">
<button type="button"
x-on:click="$wire.mountAction('create')"
class="group flex h-10 items-center justify-center gap-2 whitespace-nowrap rounded-lg border border-black bg-black text-white hover:bg-neutral-800 dark:border-white dark:bg-white dark:text-black dark:hover:bg-neutral-100 hover:ring-4 hover:ring-neutral-200 dark:hover:ring-neutral-800/50 px-4 text-sm font-semibold transition-all cursor-pointer">
<svg class="size-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
<span>{{ __('filament-short-url::default.empty_state_folder_action') }}</span>
</button>
</div>
@endif
</div>

View File

@@ -0,0 +1,173 @@
<div class="flex flex-col items-center justify-center gap-6 px-4 py-10 md:min-h-[500px]">
<style>
@keyframes infinite-scroll-y {
0% {
transform: translateY(0);
}
100% {
transform: translateY(-50%);
}
}
.animate-infinite-scroll-y {
animation: infinite-scroll-y 22s linear infinite;
}
.animate-infinite-scroll-y:hover {
animation-play-state: paused;
}
</style>
<!-- Scrolling Mock Pixels Container -->
<div class="animate-fade-in h-40 w-full max-w-xs md:max-w-sm overflow-hidden px-4 [mask-image:linear-gradient(transparent,black_15%,black_85%,transparent)] select-none pointer-events-auto">
<div class="animate-infinite-scroll-y flex flex-col">
<!-- Set 1 -->
<!-- Card 1: Meta -->
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
<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/facebook.com.ico"
class="w-full h-full object-contain"
onerror="this.src='https://heroicons.com/24/outline/funnel.svg'" />
</div>
<div class="min-w-0 flex-1 text-left">
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">Meta Ads Pixel</div>
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-mono truncate whitespace-nowrap mt-0.5">ID: 9876543210</div>
</div>
<span class="text-[10px] font-bold bg-blue-50 dark:bg-blue-950/30 text-blue-600 dark:text-blue-400 px-2.5 py-0.5 rounded-full ml-auto flex-shrink-0">
Meta
</span>
</div>
<!-- Card 2: Google -->
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
<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/google.com.ico"
class="w-full h-full object-contain"
onerror="this.src='https://heroicons.com/24/outline/funnel.svg'" />
</div>
<div class="min-w-0 flex-1 text-left">
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">Google Analytics 4</div>
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-mono truncate whitespace-nowrap mt-0.5">ID: G-9X8Y7Z6W5V</div>
</div>
<span class="text-[10px] font-bold bg-amber-50 dark:bg-amber-950/30 text-amber-600 dark:text-amber-400 px-2.5 py-0.5 rounded-full ml-auto flex-shrink-0">
Google
</span>
</div>
<!-- Card 3: LinkedIn -->
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
<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/linkedin.com.ico"
class="w-full h-full object-contain"
onerror="this.src='https://heroicons.com/24/outline/funnel.svg'" />
</div>
<div class="min-w-0 flex-1 text-left">
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">LinkedIn Insight Tag</div>
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-mono truncate whitespace-nowrap mt-0.5">ID: 1029384756</div>
</div>
<span class="text-[10px] font-bold bg-indigo-50 dark:bg-indigo-950/30 text-indigo-600 dark:text-indigo-400 px-2.5 py-0.5 rounded-full ml-auto flex-shrink-0">
LinkedIn
</span>
</div>
<!-- Card 4: TikTok -->
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
<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/tiktok.com.ico"
class="w-full h-full object-contain"
onerror="this.src='https://heroicons.com/24/outline/funnel.svg'" />
</div>
<div class="min-w-0 flex-1 text-left">
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">TikTok Pixel</div>
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-mono truncate whitespace-nowrap mt-0.5">ID: C1234567890</div>
</div>
<span class="text-[10px] font-bold bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 px-2.5 py-0.5 rounded-full ml-auto flex-shrink-0">
TikTok
</span>
</div>
<!-- Set 2 (Duplicated for infinite looping) -->
<!-- Card 1: Meta -->
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
<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/facebook.com.ico"
class="w-full h-full object-contain"
onerror="this.src='https://heroicons.com/24/outline/funnel.svg'" />
</div>
<div class="min-w-0 flex-1 text-left">
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">Meta Ads Pixel</div>
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-mono truncate whitespace-nowrap mt-0.5">ID: 9876543210</div>
</div>
<span class="text-[10px] font-bold bg-blue-50 dark:bg-blue-950/30 text-blue-600 dark:text-blue-400 px-2.5 py-0.5 rounded-full ml-auto flex-shrink-0">
Meta
</span>
</div>
<!-- Card 2: Google -->
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
<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/google.com.ico"
class="w-full h-full object-contain"
onerror="this.src='https://heroicons.com/24/outline/funnel.svg'" />
</div>
<div class="min-w-0 flex-1 text-left">
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">Google Analytics 4</div>
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-mono truncate whitespace-nowrap mt-0.5">ID: G-9X8Y7Z6W5V</div>
</div>
<span class="text-[10px] font-bold bg-amber-50 dark:bg-amber-950/30 text-amber-600 dark:text-amber-400 px-2.5 py-0.5 rounded-full ml-auto flex-shrink-0">
Google
</span>
</div>
<!-- Card 3: LinkedIn -->
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
<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/linkedin.com.ico"
class="w-full h-full object-contain"
onerror="this.src='https://heroicons.com/24/outline/funnel.svg'" />
</div>
<div class="min-w-0 flex-1 text-left">
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">LinkedIn Insight Tag</div>
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-mono truncate whitespace-nowrap mt-0.5">ID: 1029384756</div>
</div>
<span class="text-[10px] font-bold bg-indigo-50 dark:bg-indigo-950/30 text-indigo-600 dark:text-indigo-400 px-2.5 py-0.5 rounded-full ml-auto flex-shrink-0">
LinkedIn
</span>
</div>
<!-- Card 4: TikTok -->
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
<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/tiktok.com.ico"
class="w-full h-full object-contain"
onerror="this.src='https://heroicons.com/24/outline/funnel.svg'" />
</div>
<div class="min-w-0 flex-1 text-left">
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">TikTok Pixel</div>
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-mono truncate whitespace-nowrap mt-0.5">ID: C1234567890</div>
</div>
<span class="text-[10px] font-bold bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 px-2.5 py-0.5 rounded-full ml-auto flex-shrink-0">
TikTok
</span>
</div>
</div>
</div>
<!-- Title and Description -->
<div class="max-w-sm text-pretty text-center px-4">
<span class="text-base font-semibold text-neutral-900 dark:text-neutral-100">{{ __('filament-short-url::default.empty_state_pixel_heading') }}</span>
<div class="mt-2 text-pretty text-sm text-neutral-500 dark:text-neutral-400">
{{ __('filament-short-url::default.empty_state_pixel_description') }}
</div>
</div>
<!-- Create Button -->
<div class="flex items-center gap-2">
<button type="button"
x-on:click="$wire.mountAction('create')"
class="group flex h-10 items-center justify-center gap-2 whitespace-nowrap rounded-lg border border-black bg-black text-white hover:bg-neutral-800 dark:border-white dark:bg-white dark:text-black dark:hover:bg-neutral-100 hover:ring-4 hover:ring-neutral-200 dark:hover:ring-neutral-800/50 px-4 text-sm font-semibold transition-all cursor-pointer">
<svg class="size-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
<span>{{ __('filament-short-url::default.empty_state_pixel_action') }}</span>
</button>
</div>
</div>

View File

@@ -0,0 +1,128 @@
<div class="flex flex-col items-center justify-center gap-6 px-4 py-10 md:min-h-[550px]">
<!-- Tags Visual Grid -->
<div class="relative flex items-center justify-center h-40 w-full max-w-xs md:max-w-md overflow-hidden rounded-2xl border border-neutral-200/60 dark:border-neutral-800 bg-neutral-50/50 dark:bg-neutral-900/30 p-6 shadow-inner">
<div class="absolute inset-0 bg-[linear-gradient(to_right,#8080800a_1px,transparent_1px),linear-gradient(to_bottom,#8080800a_1px,transparent_1px)] bg-[size:14px_24px] [mask-image:radial-gradient(ellipse_60%_50%_at_50%_0%,#000_70%,transparent_100%)]"></div>
<!-- Orbiting Tags Container -->
<div class="relative w-64 h-32 flex items-center justify-center z-10">
<!-- Orbit Path Visual -->
<svg class="absolute w-56 h-20 opacity-20 dark:opacity-30 text-neutral-300 dark:text-neutral-700" viewBox="0 0 220 80" fill="none">
<ellipse cx="110" cy="40" rx="100" ry="32" stroke="currentColor" stroke-width="1.5" stroke-dasharray="4 4" />
</svg>
<!-- Central URL Card -->
<div class="relative w-36 bg-white dark:bg-neutral-900 border border-neutral-200/80 dark:border-neutral-800 rounded-xl px-2.5 py-2 shadow-md flex items-center gap-2 select-none"
style="animation: fsu-tag-center-card-float 4s infinite ease-in-out; z-index: 10;">
<div class="w-5 h-5 rounded-lg bg-purple-500/10 dark:bg-purple-500/20 flex items-center justify-center text-purple-600 dark:text-purple-400 flex-shrink-0">
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
</svg>
</div>
<div class="flex flex-col overflow-hidden">
<span class="text-[10px] font-bold text-neutral-800 dark:text-neutral-200 font-mono leading-none truncate max-w-[85px] block" title="{{ parse_url(config('app.url'), PHP_URL_HOST) ?? request()->getHost() }}/{{ __('filament-short-url::default.empty_state_tags_mock_promo') }}">
{{ parse_url(config('app.url'), PHP_URL_HOST) ?? request()->getHost() }}/{{ __('filament-short-url::default.empty_state_tags_mock_promo') }}
</span>
<span class="text-[7.5px] text-neutral-400 dark:text-neutral-500 font-sans mt-0.5">{{ __('filament-short-url::default.empty_state_tags_mock_redirecting') }}</span>
</div>
</div>
<!-- Orbiting Badges -->
<!-- Tag 1: promo (Purple) -->
<div class="absolute px-2 py-0.5 rounded-full text-[9px] font-semibold bg-purple-100 dark:bg-purple-950/60 text-purple-700 dark:text-purple-300 border border-purple-200 dark:border-purple-800/50 shadow-sm flex items-center gap-1 select-none"
style="animation: fsu-tag-orbit 8s infinite linear; animation-delay: 0s;">
<span class="text-purple-400 font-bold">#</span>
<span>{{ __('filament-short-url::default.empty_state_tags_mock_promo') }}</span>
</div>
<!-- Tag 2: social (Teal) -->
<div class="absolute px-2 py-0.5 rounded-full text-[9px] font-semibold bg-teal-100 dark:bg-teal-950/60 text-teal-700 dark:text-teal-300 border border-teal-200 dark:border-teal-800/50 shadow-sm flex items-center gap-1 select-none"
style="animation: fsu-tag-orbit 8s infinite linear; animation-delay: -2.66s;">
<span class="text-teal-400 font-bold">#</span>
<span>{{ __('filament-short-url::default.empty_state_tags_mock_social') }}</span>
</div>
<!-- Tag 3: dev (Amber) -->
<div class="absolute px-2 py-0.5 rounded-full text-[9px] font-semibold bg-amber-100 dark:bg-amber-950/60 text-amber-700 dark:text-amber-300 border border-amber-200 dark:border-amber-800/50 shadow-sm flex items-center gap-1 select-none"
style="animation: fsu-tag-orbit 8s infinite linear; animation-delay: -5.33s;">
<span class="text-amber-400 font-bold">#</span>
<span>{{ __('filament-short-url::default.empty_state_tags_mock_dev') }}</span>
</div>
</div>
</div>
<style>
@keyframes fsu-tag-center-card-float {
0%, 100% {
transform: translateY(0) rotate(-1deg);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
}
50% {
transform: translateY(-6px) rotate(1deg);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -2px rgba(0, 0, 0, 0.04);
}
}
@keyframes fsu-tag-orbit {
0% {
transform: translate3d(-100px, 0px, 0px) scale(0.9);
z-index: 2;
opacity: 0.8;
}
4% {
z-index: 20;
}
25% {
transform: translate3d(0px, 32px, 0px) scale(1.05);
z-index: 20;
opacity: 1;
}
46% {
z-index: 20;
}
50% {
transform: translate3d(100px, 0px, 0px) scale(0.9);
z-index: 2;
opacity: 0.8;
}
54% {
z-index: 2;
}
75% {
transform: translate3d(0px, -32px, 0px) scale(0.8);
z-index: 2;
opacity: 0.5;
}
96% {
z-index: 2;
}
100% {
transform: translate3d(-100px, 0px, 0px) scale(0.9);
z-index: 2;
opacity: 0.8;
}
}
</style>
<!-- Title and Description -->
<div class="max-w-md text-pretty text-center px-4">
<span class="text-base font-semibold text-neutral-900 dark:text-neutral-100">
{{ __('filament-short-url::default.empty_state_tags_heading') }}
</span>
<div class="mt-2 text-pretty text-sm text-neutral-500 dark:text-neutral-400">
{{ __('filament-short-url::default.empty_state_tags_description') }}
</div>
</div>
<!-- Create Button -->
@if(!isset($hideCreateButton) || !$hideCreateButton)
<div class="flex items-center gap-2">
<button type="button"
x-on:click="$wire.mountAction('create')"
class="group flex h-10 items-center justify-center gap-2 whitespace-nowrap rounded-lg border border-black bg-black text-white hover:bg-neutral-800 dark:border-white dark:bg-white dark:text-black dark:hover:bg-neutral-100 hover:ring-4 hover:ring-neutral-200 dark:hover:ring-neutral-800/50 px-4 text-sm font-semibold transition-all cursor-pointer">
<svg class="size-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
<span>{{ __('filament-short-url::default.empty_state_tag_action') }}</span>
</button>
</div>
@endif
</div>

View File

@@ -0,0 +1,245 @@
<div class="flex flex-col items-center justify-center gap-6 px-4 py-10 md:min-h-[500px]">
<style>
@keyframes infinite-scroll-y {
0% {
transform: translateY(0);
}
100% {
transform: translateY(-50%);
}
}
.animate-infinite-scroll-y {
animation: infinite-scroll-y 25s linear infinite;
}
.animate-infinite-scroll-y:hover {
animation-play-state: paused;
}
</style>
<!-- Scrolling Mock Cards Container -->
<div class="animate-fade-in h-40 w-full max-w-xs md:max-w-sm overflow-hidden px-4 [mask-image:linear-gradient(transparent,black_15%,black_85%,transparent)] select-none pointer-events-auto">
<div class="animate-infinite-scroll-y flex flex-col">
<!-- Set 1 -->
<!-- Card 1: YouTube -->
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
<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/youtube.com.ico"
class="w-full h-full object-contain"
onerror="this.src='https://heroicons.com/24/outline/link.svg'" />
</div>
<div class="min-w-0 flex-1 text-left">
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">short.io/youtube-tour</div>
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-medium truncate whitespace-nowrap max-w-[130px] md:max-w-[180px] mt-0.5">youtube.com/watch...</div>
</div>
<span class="text-[10px] font-bold bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 px-2 py-0.5 rounded-full flex items-center gap-1 ml-auto flex-shrink-0">
<svg class="size-3 text-neutral-400 dark:text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<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>
1.4k
</span>
</div>
<!-- Card 2: Maps -->
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
<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/google.com.ico"
class="w-full h-full object-contain"
onerror="this.src='https://heroicons.com/24/outline/link.svg'" />
</div>
<div class="min-w-0 flex-1 text-left">
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">demo.to/monaco-map</div>
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-medium truncate whitespace-nowrap max-w-[130px] md:max-w-[180px] mt-0.5">maps.google.com/place...</div>
</div>
<span class="text-[10px] font-bold bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 px-2 py-0.5 rounded-full flex items-center gap-1 ml-auto flex-shrink-0">
<svg class="size-3 text-neutral-400 dark:text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<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>
890
</span>
</div>
<!-- Card 3: WhatsApp -->
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
<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/whatsapp.com.ico"
class="w-full h-full object-contain"
onerror="this.src='https://heroicons.com/24/outline/link.svg'" />
</div>
<div class="min-w-0 flex-1 text-left">
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">link.xyz/whatsapp-chat</div>
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-medium truncate whitespace-nowrap max-w-[130px] md:max-w-[180px] mt-0.5">wa.me/385912345678</div>
</div>
<span class="text-[10px] font-bold bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 px-2 py-0.5 rounded-full flex items-center gap-1 ml-auto flex-shrink-0">
<svg class="size-3 text-neutral-400 dark:text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<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>
340
</span>
</div>
<!-- Card 4: Brochure PDF -->
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
<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/dropbox.com.ico"
class="w-full h-full object-contain"
onerror="this.src='https://heroicons.com/24/outline/link.svg'" />
</div>
<div class="min-w-0 flex-1 text-left">
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">example.com/brochure-pdf</div>
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-medium truncate whitespace-nowrap max-w-[130px] md:max-w-[180px] mt-0.5">dropbox.com/s/spec-brochure...</div>
</div>
<span class="text-[10px] font-bold bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 px-2 py-0.5 rounded-full flex items-center gap-1 ml-auto flex-shrink-0">
<svg class="size-3 text-neutral-400 dark:text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<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>
620
</span>
</div>
<!-- Card 5: A/B Split Test -->
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
<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/facebook.com.ico"
class="w-full h-full object-contain"
onerror="this.src='https://heroicons.com/24/outline/link.svg'" />
</div>
<div class="min-w-0 flex-1 text-left">
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">short.io/promo-campaign</div>
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-medium italic truncate whitespace-nowrap max-w-[130px] md:max-w-[180px] mt-0.5">A/B Split Traffic</div>
</div>
<span class="text-[10px] font-bold bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 px-2 py-0.5 rounded-full flex items-center gap-1 ml-auto flex-shrink-0">
<svg class="size-3 text-neutral-400 dark:text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<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>
2.1k
</span>
</div>
<!-- Set 2 (Duplicated for infinite looping) -->
<!-- Card 1: YouTube -->
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
<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/youtube.com.ico"
class="w-full h-full object-contain"
onerror="this.src='https://heroicons.com/24/outline/link.svg'" />
</div>
<div class="min-w-0 flex-1 text-left">
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">short.io/youtube-tour</div>
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-medium truncate whitespace-nowrap max-w-[130px] md:max-w-[180px] mt-0.5">youtube.com/watch...</div>
</div>
<span class="text-[10px] font-bold bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 px-2 py-0.5 rounded-full flex items-center gap-1 ml-auto flex-shrink-0">
<svg class="size-3 text-neutral-400 dark:text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<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>
1.4k
</span>
</div>
<!-- Card 2: Maps -->
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
<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/google.com.ico"
class="w-full h-full object-contain"
onerror="this.src='https://heroicons.com/24/outline/link.svg'" />
</div>
<div class="min-w-0 flex-1 text-left">
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">demo.to/monaco-map</div>
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-medium truncate whitespace-nowrap max-w-[130px] md:max-w-[180px] mt-0.5">maps.google.com/place...</div>
</div>
<span class="text-[10px] font-bold bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 px-2 py-0.5 rounded-full flex items-center gap-1 ml-auto flex-shrink-0">
<svg class="size-3 text-neutral-400 dark:text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<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>
890
</span>
</div>
<!-- Card 3: WhatsApp -->
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
<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/whatsapp.com.ico"
class="w-full h-full object-contain"
onerror="this.src='https://heroicons.com/24/outline/link.svg'" />
</div>
<div class="min-w-0 flex-1 text-left">
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">link.xyz/whatsapp-chat</div>
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-medium truncate whitespace-nowrap max-w-[130px] md:max-w-[180px] mt-0.5">wa.me/385912345678</div>
</div>
<span class="text-[10px] font-bold bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 px-2 py-0.5 rounded-full flex items-center gap-1 ml-auto flex-shrink-0">
<svg class="size-3 text-neutral-400 dark:text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<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>
340
</span>
</div>
<!-- Card 4: Brochure PDF -->
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
<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/dropbox.com.ico"
class="w-full h-full object-contain"
onerror="this.src='https://heroicons.com/24/outline/link.svg'" />
</div>
<div class="min-w-0 flex-1 text-left">
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">example.com/brochure-pdf</div>
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-medium truncate whitespace-nowrap max-w-[130px] md:max-w-[180px] mt-0.5">dropbox.com/s/spec-brochure...</div>
</div>
<span class="text-[10px] font-bold bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 px-2 py-0.5 rounded-full flex items-center gap-1 ml-auto flex-shrink-0">
<svg class="size-3 text-neutral-400 dark:text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<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>
620
</span>
</div>
<!-- Card 5: A/B Split Test -->
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
<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/facebook.com.ico"
class="w-full h-full object-contain"
onerror="this.src='https://heroicons.com/24/outline/link.svg'" />
</div>
<div class="min-w-0 flex-1 text-left">
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">short.io/promo-campaign</div>
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-medium italic truncate whitespace-nowrap max-w-[130px] md:max-w-[180px] mt-0.5">A/B Split Traffic</div>
</div>
<span class="text-[10px] font-bold bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 px-2 py-0.5 rounded-full flex items-center gap-1 ml-auto flex-shrink-0">
<svg class="size-3 text-neutral-400 dark:text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<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>
2.1k
</span>
</div>
</div>
</div>
<!-- Title and Description -->
<div class="max-w-sm text-pretty text-center px-4">
<span class="text-base font-semibold text-neutral-900 dark:text-neutral-100">{{ __('filament-short-url::default.empty_state_heading') }}</span>
<div class="mt-2 text-pretty text-sm text-neutral-500 dark:text-neutral-400">
{{ __('filament-short-url::default.empty_state_description') }}
</div>
</div>
<!-- Create Button -->
<div class="flex items-center gap-2">
<button type="button"
x-on:click="$wire.mountAction('create')"
class="group flex h-10 items-center justify-center gap-2 whitespace-nowrap rounded-lg border border-black bg-black text-white hover:bg-neutral-800 dark:border-white dark:bg-white dark:text-black dark:hover:bg-neutral-100 hover:ring-4 hover:ring-neutral-200 dark:hover:ring-neutral-800/50 px-4 text-sm font-semibold transition-all cursor-pointer">
<svg class="size-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
<span>{{ __('filament-short-url::default.empty_state_action') }}</span>
</button>
</div>
</div>

View File

@@ -0,0 +1,107 @@
@php
$record = $getRecord();
$color = $record->color ?? 'gray';
// Map folder color to valid Tailwind classes matching the premium demo design
$colorClasses = match ($color) {
'gray' => [
'border_outer' => 'border-neutral-200 dark:border-neutral-700',
'bg_inner' => 'bg-neutral-100 dark:bg-neutral-800',
'text' => 'text-neutral-600 dark:text-neutral-400',
'badge_bg' => 'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-350',
],
'red' => [
'border_outer' => 'border-red-200 dark:border-red-900/30',
'bg_inner' => 'bg-red-100 dark:bg-red-950/40',
'text' => 'text-red-800 dark:text-red-400',
'badge_bg' => 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-350',
],
'blue' => [
'border_outer' => 'border-blue-200 dark:border-blue-900/30',
'bg_inner' => 'bg-blue-100 dark:bg-blue-950/40',
'text' => 'text-blue-800 dark:text-blue-400',
'badge_bg' => 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-350',
],
'green' => [
'border_outer' => 'border-emerald-200 dark:border-emerald-900/30',
'bg_inner' => 'bg-emerald-100 dark:bg-emerald-950/40',
'text' => 'text-emerald-800 dark:text-emerald-400',
'badge_bg' => 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-350',
],
'yellow' => [
'border_outer' => 'border-amber-200 dark:border-amber-900/30',
'bg_inner' => 'bg-amber-100 dark:bg-amber-950/40',
'text' => 'text-amber-800 dark:text-amber-400',
'badge_bg' => 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-350',
],
'indigo' => [
'border_outer' => 'border-indigo-200 dark:border-indigo-900/30',
'bg_inner' => 'bg-indigo-100 dark:bg-indigo-950/40',
'text' => 'text-indigo-800 dark:text-indigo-400',
'badge_bg' => 'bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-350',
],
'purple' => [
'border_outer' => 'border-purple-200 dark:border-purple-900/30',
'bg_inner' => 'bg-purple-100 dark:bg-purple-950/40',
'text' => 'text-purple-800 dark:text-purple-400',
'badge_bg' => 'bg-purple-100 dark:bg-purple-900/30 text-purple-750 dark:text-purple-350',
],
'pink' => [
'border_outer' => 'border-pink-200 dark:border-pink-900/30',
'bg_inner' => 'bg-pink-100 dark:bg-pink-950/40',
'text' => 'text-pink-800 dark:text-pink-400',
'badge_bg' => 'bg-pink-100 dark:bg-pink-900/30 text-pink-700 dark:text-pink-350',
],
default => [
'border_outer' => 'border-neutral-200 dark:border-neutral-700',
'bg_inner' => 'bg-neutral-100 dark:bg-neutral-800',
'text' => 'text-neutral-600 dark:text-neutral-400',
'badge_bg' => 'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-350',
],
};
$linkCount = $record->short_urls_count ?? 0;
$linkString = $linkCount === 1 ? __('filament-short-url::default.link_count_one') : __('filament-short-url::default.link_count_many', ['count' => $linkCount]);
if (str_contains($linkString, 'filament-short-url::')) {
$linkString = $linkCount === 1 ? '1 link' : "{$linkCount} links";
}
@endphp
<div class="flex flex-col justify-between h-full w-full select-none">
<!-- Top Row: Icon Container -->
<div class="flex items-center justify-between z-10 pointer-events-none">
<div class="border rounded-full bg-white dark:bg-neutral-850 p-0.5 {{ $colorClasses['border_outer'] }}">
<div class="rounded-full p-2 {{ $colorClasses['bg_inner'] }} {{ $colorClasses['text'] }}">
<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="M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-8.69-6.44-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v12a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9a2.25 2.25 0 0 0-2.25-2.25h-5.379a1.5 1.5 0 0 1-1.06-.44Z" />
</svg>
</div>
</div>
</div>
<!-- Bottom Row: Title, Badges, Link count -->
<div class="z-10 pointer-events-none">
<span class="flex items-center justify-start gap-1.5 truncate text-sm font-semibold text-neutral-900 dark:text-neutral-100">
<span class="truncate">{{ $record->name }}</span>
<span class="text-[10px] font-medium px-1.5 py-0.5 rounded {{ $colorClasses['badge_bg'] }} font-mono">
/{{ $record->slug }}
</span>
@if($record->slug === 'default' || $record->slug === 'links')
<span class="text-[10px] font-medium px-1.5 py-0.5 rounded bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300">
Default
</span>
@endif
</span>
<div class="mt-1.5 flex items-center gap-1 text-neutral-500 dark:text-neutral-400">
<svg height="18" width="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5">
<g fill="currentColor">
<ellipse cx="9" cy="9" fill="none" rx="7.25" ry="3" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></ellipse>
<ellipse cx="9" cy="9" fill="none" rx="3" ry="7.25" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></ellipse>
<circle cx="9" cy="9" fill="none" r="7.25" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></circle>
</g>
</svg>
<span class="text-sm font-normal">{{ $linkString }}</span>
</div>
</div>
</div>

View File

@@ -0,0 +1,229 @@
@php
// Get the user relationship config
$userModel = config('filament-short-url.user.model', \App\Models\User::class);
$nameColumn = config('filament-short-url.user.name_column', 'name');
$emailColumn = config('filament-short-url.user.email_column', 'email');
$avatarColumn = config('filament-short-url.user.avatar_column', 'avatar_url');
$user = $record->user;
$userName = null;
$userEmail = null;
$avatarUrl = null;
if ($user) {
$userName = $user->{$nameColumn} ?? null;
$userEmail = $user->{$emailColumn} ?? null;
// Try to get avatar URL
if ($avatarColumn && method_exists($user, $avatarColumn)) {
$avatarUrl = $user->{$avatarColumn}();
} elseif ($avatarColumn && isset($user->{$avatarColumn})) {
$avatarUrl = $user->{$avatarColumn};
} elseif ($user instanceof \Filament\Models\Contracts\HasAvatar) {
$avatarUrl = $user->getFilamentAvatarUrl();
} elseif (method_exists($user, 'getFilamentAvatarUrl')) {
$avatarUrl = $user->getFilamentAvatarUrl();
}
// Fallback to Gravatar if no avatar URL found
if (empty($avatarUrl) && !empty($userEmail)) {
$avatarUrl = 'https://www.gravatar.com/avatar/' . md5(strtolower(trim($userEmail))) . '?d=mp&s=80';
}
}
// Relative Date Calculations for "1h", "2d" etc.
$diff = $record->created_at->diff(now());
if ($diff->y > 0) {
$shortTime = $diff->y . 'y';
} elseif ($diff->m > 0) {
$shortTime = $diff->m . 'mo';
} elseif ($diff->d > 0) {
$shortTime = $diff->d . 'd';
} elseif ($diff->h > 0) {
$shortTime = $diff->h . 'h';
} elseif ($diff->i > 0) {
$shortTime = $diff->i . 'm';
} else {
$shortTime = max(1, $diff->s) . 's';
}
// Detailed relative format for hover: "1 hour, 52 minutes, 16 seconds ago"
$parts = [];
if ($diff->y > 0) {
$parts[] = $diff->y . ' ' . str('year')->plural($diff->y);
}
if ($diff->m > 0) {
$parts[] = $diff->m . ' ' . str('month')->plural($diff->m);
}
if ($diff->d > 0) {
$parts[] = $diff->d . ' ' . str('day')->plural($diff->d);
}
if ($diff->h > 0) {
$parts[] = $diff->h . ' ' . str('hour')->plural($diff->h);
}
if ($diff->i > 0) {
$parts[] = $diff->i . ' ' . str('minute')->plural($diff->i);
}
if ($diff->s > 0) {
$parts[] = $diff->s . ' ' . str('second')->plural($diff->s);
}
// Slice to top 3 units for a readable detailed string
$detailedParts = array_slice($parts, 0, 3);
$detailedRelative = !empty($detailedParts)
? implode(', ', $detailedParts) . ' ago'
: 'just now';
// Timezone offset badge (e.g. GMT+2)
$offsetHours = $record->created_at->offsetHours;
$timezoneBadge = 'GMT' . ($offsetHours >= 0 ? '+' : '') . $offsetHours;
// Absolute date (e.g. Jun 5, 2026, 1:09:29 AM)
$absoluteDate = $record->created_at->format('M j, Y, g:i:s A');
@endphp
<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>
{{ number_format($record->total_visits) }} {{ __('filament-short-url::default.badge_clicks') }}
</span>
<!-- 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>
{{ number_format($record->unique_visits) }} {{ __('filament-short-url::default.badge_unique') }}
</span>
<!-- QR Scans Badge -->
<span class="inline-flex items-center gap-1 bg-[#f4f4f5] dark:bg-gray-800 px-2 py-1 rounded text-[11px] font-medium text-gray-600 dark:text-gray-300">
<svg class="w-3.5 h-3.5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0 1 3.75 9.375v-4.5ZM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 0 1-1.125-1.125v-4.5ZM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0 1 13.5 9.375v-4.5Z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12h.008v.008H15V12Zm0 3h.008v.008H15V15Zm0 3h.008v.008H15V18Zm3-3h.008v.008H18V15Zm0 3h.008v.008H18V18Zm3-3h.008v.008H21V15Zm0 3h.008v.008H21V18Zm0-6h.008v.008H21V12Zm-3 0h.008v.008H18V12Z" />
</svg>
{{ number_format($record->qr_scans) }} {{ __('filament-short-url::default.badge_qr_scans') }}
</span>
<!-- Date Added Badge (with Hover Card) -->
<div class="relative inline-block" x-data="{ open: false }">
<div @mouseenter="open = true" @mouseleave="open = false"
class="cursor-pointer 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-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
{{ $shortTime }}
</div>
<!-- Popover Card for Date -->
<div x-show="open"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 translate-y-1 scale-95"
x-transition:enter-end="opacity-100 translate-y-0 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 translate-y-0 scale-100"
x-transition:leave-end="opacity-0 translate-y-1 scale-95"
class="absolute z-[100] bottom-full left-1/2 -translate-x-1/2 mb-2 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 pointer-events-none overflow-hidden rounded-xl shadow-sm min-w-max"
x-cloak>
<div class="flex max-w-[360px] flex-col gap-2 px-2.5 py-2 text-left text-xs">
<span class="text-neutral-500 dark:text-neutral-400 cursor-default">{{ $detailedRelative }}</span>
<table>
<tbody>
<tr class="before:bg-bg-emphasis relative select-none before:absolute before:-inset-x-1 before:inset-y-0 before:rounded before:opacity-0 before:content-[''] hover:cursor-copy hover:before:opacity-60 active:before:opacity-100">
<td class="relative py-0.5">
<span class="text-neutral-500 dark:text-neutral-400 truncate bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-300 rounded px-1 font-mono text-[11px]" title="{{ config('app.timezone') }}">{{ $timezoneBadge }}</span>
</td>
<td class="text-neutral-800 dark:text-neutral-200 relative whitespace-nowrap py-0.5 pl-2">{{ $absoluteDate }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
@if ($user)
<!-- User Avatar with Hover Card -->
<div class="relative inline-block" x-data="{ open: false }">
<div @mouseenter="open = true" @mouseleave="open = false" class="cursor-pointer flex items-center justify-center">
<img src="{{ $avatarUrl }}"
alt="{{ $userName }}"
class="w-6 h-6 rounded-full border border-neutral-200 dark:border-neutral-700 bg-neutral-100 object-cover" />
</div>
<!-- Popover Card for User -->
<div x-show="open"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 translate-y-1 scale-95"
x-transition:enter-end="opacity-100 translate-y-0 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 translate-y-0 scale-100"
x-transition:leave-end="opacity-0 translate-y-1 scale-95"
class="absolute z-[100] bottom-full left-1/2 -translate-x-1/2 mb-2 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 pointer-events-none overflow-hidden rounded-xl shadow-sm min-w-max"
x-cloak>
<div class="w-full p-3 text-left">
<img alt="Avatar for {{ $userName }}"
referrerpolicy="no-referrer"
class="rounded-full border border-neutral-300 h-8 w-8 object-cover"
draggable="false"
src="{{ $avatarUrl }}">
<div class="mt-2 flex items-center gap-1.5">
<p class="text-sm font-semibold text-neutral-700 dark:text-neutral-200">{{ $userName }}</p>
</div>
<div class="flex flex-col gap-1 text-xs text-neutral-500 dark:text-neutral-400">
<p>{{ $userEmail }}</p>
</div>
</div>
</div>
</div>
@endif
<!-- Expiry / Single Use Badge -->
@if ($record->expires_at)
<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-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>
{{ __('filament-short-url::default.badge_expires', ['date' => $record->expires_at->translatedFormat('M d, Y')]) }}
</span>
@endif
{{-- Keyboard Shortcuts Handler (Alpine.js) --}}
<div x-data="{
hovered: false,
init() {
const card = this.$el.closest('.short-url-card');
if (card) {
this.hovered = card.matches(':hover');
card.addEventListener('mouseenter', () => this.hovered = true);
card.addEventListener('mouseleave', () => this.hovered = false);
}
},
handleKeyup(event) {
if (!this.hovered) return;
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement.tagName) || document.activeElement.isContentEditable) return;
if (document.activeElement.closest('.fi-modal-window') || document.activeElement.closest('.fi-modal')) return;
const key = event.key.toLowerCase();
if (event.ctrlKey || event.metaKey || event.altKey) return;
if (key === 'e') {
this.$wire.mountTableAction('edit', '{{ $record->id }}');
} else if (key === 'q') {
this.$wire.mountTableAction('qrCode', '{{ $record->id }}');
} else if (key === 'i') {
this.$wire.mountTableAction('share', '{{ $record->id }}');
} else if (key === 's') {
window.location.href = '{{ \Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource::getUrl('stats', ['record' => $record]) }}';
} else if (key === 'x') {
this.$wire.mountTableAction('delete', '{{ $record->id }}');
}
}
}"
@keyup.window="handleKeyup($event)"
class="hidden">
</div>
</div>

View File

@@ -0,0 +1,261 @@
@php
$shortUrl = $record->getShortUrl();
$qrTargetUrl = $shortUrl . '?source=qr';
$destHost = parse_url($record->destination_url, PHP_URL_HOST) ?? '';
$urlKey = $record->url_key;
$eid = 'fsu_' . substr(md5($shortUrl), 0, 8);
$qrHelperText = __('filament-short-url::default.qr_modal_helper');
$downloadSvgText = __('filament-short-url::default.qr_download_svg');
$downloadPngText = __('filament-short-url::default.qr_download_png');
$closeButtonText = __('filament-short-url::default.close_button');
$copyLinkText = __('filament-short-url::default.action_copy');
$openLinkText = __('filament-short-url::default.open_link');
$qrDefaults = $record->getQrOptions();
$isGrad = ($qrDefaults['gradient_enabled'] ?? false) || (($qrDefaults['color_mode'] ?? '') === 'gradient');
$dotStyle = $qrDefaults['dot_style'] ?? 'square';
$fgColor = $qrDefaults['foreground_color'] ?? '#000000';
$bgColor = ($qrDefaults['bg_transparent'] ?? false) ? 'rgba(0,0,0,0)' : ($qrDefaults['background_color'] ?? '#ffffff');
$dotsOptions = $isGrad ? [
'type' => $dotStyle,
'gradient' => [
'type' => $qrDefaults['gradient_type'] ?? 'linear',
'colorStops' => [
['offset' => 0, 'color' => $qrDefaults['gradient_from'] ?? '#4f46e5'],
['offset' => 1, 'color' => $qrDefaults['gradient_to'] ?? '#06b6d4'],
],
],
] : [
'type' => $dotStyle,
'color' => $fgColor,
];
$mainColor = $isGrad ? ($qrDefaults['gradient_from'] ?? '#4f46e5') : $fgColor;
$eyeConfigEnabled = $qrDefaults['eye_config_enabled'] ?? false;
$eyeSquareStyle = $qrDefaults['eye_square_style'] ?? ($dotStyle === 'dots' ? 'dot' : 'square');
$eyeDotStyle = $qrDefaults['eye_dot_style'] ?? ($dotStyle === 'dots' ? 'dot' : 'square');
$eyeColor = $qrDefaults['eye_color'] ?? $mainColor;
$cornersSquareOptions = $eyeConfigEnabled ? [
'type' => $eyeSquareStyle,
'color' => $eyeColor,
] : [
'type' => $dotStyle === 'dots' ? 'dot' : 'square',
'color' => $mainColor,
];
$cornersDotOptions = $eyeConfigEnabled ? [
'type' => $eyeDotStyle,
'color' => $eyeColor,
] : [
'type' => $dotStyle === 'dots' ? 'dot' : 'square',
'color' => $mainColor,
];
$logo = $qrDefaults['logo'] ?? null;
$logoSize = $qrDefaults['logo_size'] ?? 0.3;
$logoMargin = $qrDefaults['logo_margin'] ?? 9;
$logoHideBackground = $qrDefaults['logo_hide_background'] ?? true;
$logoShape = $qrDefaults['logo_shape'] ?? 'square';
$qrOptionsJson = json_encode([
'type' => 'svg',
'width' => 200,
'height' => 200,
'margin' => $qrDefaults['margin'] ?? 1,
'dotsOptions' => $dotsOptions,
'backgroundOptions' => ['color' => $bgColor],
'cornersSquareOptions' => $cornersSquareOptions,
'cornersDotOptions' => $cornersDotOptions,
'image' => $logo ?: null,
'imageOptions' => [
'crossOrigin' => 'anonymous',
'hideBackgroundDots' => $logoHideBackground,
'imageSize' => $logoSize,
'margin' => $logoMargin,
'logoShape' => $logoShape,
],
'qrOptions' => ['errorCorrectionLevel' => $logo ? 'H' : 'M'],
]);
@endphp
<div data-qr-options="{{ $qrOptionsJson }}" x-data="{
init() {
const el = this.$el;
const eid = '{{ $eid }}';
const qrTargetUrl = '{{ $qrTargetUrl }}';
this.$nextTick(() => {
this.loadScript().then(() => {
const canvas = document.getElementById(eid + '_qr_canvas');
if (canvas && !canvas.innerHTML) {
const opts = JSON.parse(el.getAttribute('data-qr-options'));
opts.data = qrTargetUrl;
const fixSvg = () => {
const svg = canvas.querySelector('svg');
if (svg) {
const w = svg.getAttribute('width') || opts.width || 200;
const h = svg.getAttribute('height') || opts.height || 200;
svg.setAttribute('viewBox', '0 0 ' + w + ' ' + h);
svg.style.width = '100%';
svg.style.height = '100%';
}
};
if (opts.image && opts.imageOptions) {
const img = new Image();
img.onload = () => {
const cv = document.createElement('canvas');
cv.width = 1200;
cv.height = 1200;
const cx = cv.getContext('2d');
cx.imageSmoothingEnabled = true;
cx.imageSmoothingQuality = 'high';
const isCircle = opts.imageOptions.logoShape === 'circle';
const targetDim = 1200 - (parseFloat(opts.imageOptions.margin || 0) * 20);
cx.save();
if (isCircle) {
cx.beginPath();
cx.arc(600, 600, targetDim / 2, 0, 2 * Math.PI);
cx.clip();
const scale = Math.max(targetDim / img.width, targetDim / img.height);
const w = img.width * scale;
const h = img.height * scale;
const x = (1200 - w) / 2;
const y = (1200 - h) / 2;
cx.drawImage(img, x, y, w, h);
} else {
cx.beginPath();
const offset = (1200 - targetDim) / 2;
const radius = 144 * (targetDim / 1200);
if (typeof cx.roundRect === 'function') {
cx.roundRect(offset, offset, targetDim, targetDim, radius);
} else {
cx.moveTo(offset + radius, offset);
cx.lineTo(offset + targetDim - radius, offset);
cx.quadraticCurveTo(offset + targetDim, offset, offset + targetDim, offset + radius);
cx.lineTo(offset + targetDim, offset + targetDim - radius);
cx.quadraticCurveTo(offset + targetDim, offset + targetDim, offset + targetDim - radius, offset + targetDim);
cx.lineTo(offset + radius, offset + targetDim);
cx.quadraticCurveTo(offset, offset + targetDim, offset, offset + targetDim - radius);
cx.lineTo(offset, offset + radius);
cx.quadraticCurveTo(offset, offset, offset + radius, offset);
cx.closePath();
}
cx.clip();
const scale = Math.max(targetDim / img.width, targetDim / img.height);
const w = img.width * scale;
const h = img.height * scale;
const x = (1200 - w) / 2;
const y = (1200 - h) / 2;
cx.drawImage(img, x, y, w, h);
}
cx.restore();
opts.image = cv.toDataURL('image/png');
opts.imageOptions.margin = 0;
window['qr_' + eid] = new window.QRCodeStyling(opts);
window['qr_' + eid].append(canvas);
fixSvg();
};
img.onerror = () => {
opts.image = null;
window['qr_' + eid] = new window.QRCodeStyling(opts);
window['qr_' + eid].append(canvas);
fixSvg();
};
img.src = opts.image;
} else {
window['qr_' + eid] = new window.QRCodeStyling(opts);
window['qr_' + eid].append(canvas);
fixSvg();
}
}
});
});
},
loadScript() {
return new Promise((resolve) => {
if (window.QRCodeStyling) return resolve();
const s = document.createElement('script');
s.src = 'https://unpkg.com/qr-code-styling@1.6.0-rc.1/lib/qr-code-styling.js';
s.onload = () => resolve();
s.onerror = () => {
const s2 = document.createElement('script');
s2.src = 'https://cdn.jsdelivr.net/npm/qr-code-styling@1.6.0-rc.1/lib/qr-code-styling.js';
s2.onload = () => resolve();
document.head.appendChild(s2);
};
document.head.appendChild(s);
});
}
}">
<!-- Close Button (x) -->
<button type="button" x-on:click="event.preventDefault(); event.stopPropagation(); const f=$el.closest('.fi-modal-window'); const m=f?(f.getAttribute('x-on:keydown.window.escape')||'').match(/'(fi-[^']*)'/):null; if(m){$dispatch('close-modal',{id:m[1]})}else{$wire.unmountAction()}"
style="position:absolute;top:16px;right:16px;z-index:50;width:28px;height:28px;border-radius:50%;border:none;background:#f3f4f6;cursor:pointer;display:flex;align-items:center;justify-content:center;color:#6b7280;transition:color .15s,background-color .15s"
onmouseover="this.style.color='#374151';this.style.backgroundColor='#e5e7eb'"
onmouseout="this.style.color='#6b7280';this.style.backgroundColor='#f3f4f6'"
title="{{ e($closeButtonText) }}">
<svg style="width:16px;height:16px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<div style="text-align:center;padding:4px 0 8px">
<p style="font-size:13px;color:#9ca3af;margin:0">{{ $qrHelperText }}</p>
</div>
<!-- URL pill -->
<div style="display:flex;align-items:center;gap:10px;background:#EFF6FF;border-radius:999px;padding:10px 14px;margin:18px 0 0">
<div style="width:30px;height:30px;border-radius:50%;background:#fff;display:flex;align-items:center;justify-content:center;flex-shrink:0;border:1px solid #e5e7eb">
<img src="https://icons.duckduckgo.com/ip2/{{ e($destHost) }}.ico" style="width:16px;height:16px;object-fit:contain" onerror="this.style.display='none'">
</div>
<span style="flex:1;font-size:14px;font-weight:600;color:#1d4ed8;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ $shortUrl }}</span>
<div style="display:flex;align-items:center;gap:4px;flex-shrink:0">
<button id="{{ e($eid) }}_copy" type="button"
onclick="
event.preventDefault();
event.stopPropagation();
const u='{{ e($shortUrl) }}';
if(navigator.clipboard&&window.isSecureContext){navigator.clipboard.writeText(u);}
else{const t=document.createElement('textarea');t.value=u;t.style.cssText='position:fixed;left:-9999px';document.body.appendChild(t);t.select();document.execCommand('copy');t.remove();}
const b=document.getElementById('{{ e($eid) }}_copy');
const prev=b.innerHTML;
b.innerHTML='<svg style=\'width:16px;height:16px;color:#16a34a\' fill=\'none\' viewBox=\'0 0 24 24\' stroke=\'currentColor\' stroke-width=\'2\'><path stroke-linecap=\'round\' stroke-linejoin=\'round\' d=\'M5 13l4 4L19 7\'/></svg>';
setTimeout(()=>b.innerHTML=prev,1800);
"
style="width:30px;height:30px;border-radius:7px;border:1px solid #e5e7eb;background:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .15s"
onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'" title="{{ e($copyLinkText) }}">
<svg style="width:16px;height:16px;color:#6b7280" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path stroke-linecap="round" stroke-linejoin="round" d="M5 15H4a2 2 0 01-2-2V6a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</button>
<a href="{{ $shortUrl }}" target="_blank" rel="noopener noreferrer"
style="width:30px;height:30px;border-radius:7px;border:1px solid #e5e7eb;background:#fff;display:flex;align-items:center;justify-content:center;text-decoration:none;transition:background .15s"
onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'" title="{{ e($openLinkText) }}">
<svg style="width:15px;height:15px;color:#6b7280" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
</a>
</div>
</div>
<!-- QR Code Container (Visible immediately) -->
<div id="{{ e($eid) }}_qr_container" style="display:block;margin:16px 0 0;text-align:center;background:#f9fafb;border:1px solid #e5e7eb;border-radius:12px;padding:16px">
<div style="display:flex;justify-content:center;margin-bottom:12px">
<div id="{{ e($eid) }}_qr_canvas" style="background:#fff;padding:8px;border-radius:8px;box-shadow:0 1px 3px rgba(0,0,0,0.05)"></div>
</div>
<div style="display:flex;justify-content:center;gap:8px">
<button type="button" onclick="event.preventDefault();event.stopPropagation();window['qr_{{ e($eid) }}']?.download({ name: '{{ e($urlKey) }}-qr', extension: 'svg' })" style="font-size:12px;font-weight:600;color:#374151;border:1.5px solid #e5e7eb;background:#fff;padding:6px 12px;border-radius:8px;cursor:pointer;transition:background .15s" onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'">{{ $downloadSvgText }}</button>
<button type="button" onclick="event.preventDefault();event.stopPropagation();window['qr_{{ e($eid) }}']?.download({ name: '{{ e($urlKey) }}-qr', extension: 'png' })" style="font-size:12px;font-weight:600;color:#374151;border:1.5px solid #e5e7eb;background:#fff;padding:6px 12px;border-radius:8px;cursor:pointer;transition:background .15s" onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'">{{ $downloadPngText }}</button>
</div>
</div>

View File

@@ -0,0 +1,36 @@
@php
$shortUrl = $record->getShortUrl();
$shareCopiedMsg = __('filament-short-url::default.share_copied');
$shareCopyBtnText = __('filament-short-url::default.share_copy');
@endphp
<div class="flex items-center gap-2 relative mt-2">
<input type="text"
readonly
value="{{ e($shortUrl) }}"
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($shareCopiedMsg) }}')
.success()
.send();
} else if (typeof Alpine !== 'undefined') {
Alpine.store('filament-notifications')?.send({
status: 'success',
title: '{{ e($shareCopiedMsg) }}'
});
}
"
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>{{ $shareCopyBtnText }}</span>
</button>
</div>

View File

@@ -0,0 +1,61 @@
@php
$shortUrl = $record->getShortUrl();
$encodedUrl = urlencode($shortUrl);
@endphp
<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={{ $encodedUrl }}" 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={{ $encodedUrl }}" 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={{ $encodedUrl }}" 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-550 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={{ $encodedUrl }}" 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.5 h-5.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={{ $encodedUrl }}" 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={{ $encodedUrl }}" 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>

View File

@@ -0,0 +1,53 @@
@php
$record = $getRecord();
$color = $record->color ?? 'gray';
// Map tag color to icon + badge colors — same hex values as the Select options
$colorMap = match ($color) {
'red' => ['icon_bg' => '#fee2e2', 'icon_color' => '#ef4444'],
'blue' => ['icon_bg' => '#dbeafe', 'icon_color' => '#3b82f6'],
'green' => ['icon_bg' => '#d1fae5', 'icon_color' => '#10b981'],
'yellow' => ['icon_bg' => '#fef3c7', 'icon_color' => '#f59e0b'],
'indigo' => ['icon_bg' => '#e0e7ff', 'icon_color' => '#6366f1'],
'purple' => ['icon_bg' => '#f3e8ff', 'icon_color' => '#a855f7'],
'pink' => ['icon_bg' => '#fce7f3', 'icon_color' => '#ec4899'],
default => ['icon_bg' => '#f3f4f6', 'icon_color' => '#737373'],
};
$linkCount = $record->short_urls_count ?? 0;
$linkString = $linkCount === 1 ? __('filament-short-url::default.link_count_one') : __('filament-short-url::default.link_count_many', ['count' => $linkCount]);
if (str_contains($linkString, 'filament-short-url::')) {
$linkString = $linkCount === 1 ? '1 link' : "{$linkCount} links";
}
@endphp
<div class="flex items-center justify-between w-full gap-3 select-none py-0.5">
{{-- Left: icon + name --}}
<div class="flex items-center gap-3 min-w-0">
{{-- Colored tag icon --}}
<div class="flex-shrink-0 flex items-center justify-center w-8 h-8 rounded-full"
style="background-color: {{ $colorMap['icon_bg'] }};">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="{{ $colorMap['icon_color'] }}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2H7a2 2 0 0 0-2 2v5.586a1 1 0 0 0 .293.707l9.414 9.414a2 2 0 0 0 2.828 0l4.172-4.172a2 2 0 0 0 0-2.828L12.707 2.293A1 1 0 0 0 12 2z"/>
<circle cx="7.5" cy="7.5" r="1" fill="{{ $colorMap['icon_color'] }}" stroke="none"/>
</svg>
</div>
{{-- Name --}}
<span class="text-sm font-semibold text-neutral-900 dark:text-neutral-100 truncate">
{{ $record->name }}
</span>
</div>
{{-- Right: link count badge --}}
<div class="flex items-center gap-1.5 flex-shrink-0 mr-2 border border-neutral-200 dark:border-neutral-800 rounded-full px-2.5 py-1 text-neutral-500 dark:text-neutral-400 text-xs font-medium bg-white dark:bg-neutral-900">
<svg height="14" width="14" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5 flex-shrink-0">
<g fill="currentColor">
<ellipse cx="9" cy="9" fill="none" rx="7.25" ry="3" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></ellipse>
<ellipse cx="9" cy="9" fill="none" rx="3" ry="7.25" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></ellipse>
<circle cx="9" cy="9" fill="none" r="7.25" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></circle>
</g>
</svg>
<span>{{ $linkString }}</span>
</div>
</div>

View File

@@ -0,0 +1,53 @@
@php
$shortUrl = $record->getShortUrl();
$copiedMsg = __('filament-short-url::default.qr_copied');
$tooltipCopy = __('filament-short-url::default.action_copy');
$destHost = parse_url($record->destination_url, PHP_URL_HOST);
@endphp
<div onclick="
event.preventDefault();
event.stopPropagation();
const text = '{{ e($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('{{ e($copiedMsg) }}')
.success()
.send();
} else if (window.Alpine) {
window.Alpine.store('filament-notifications')?.send({
status: 'success',
title: '{{ e($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/{{ e($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="{{ e($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>

View File

@@ -3,7 +3,7 @@
<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>
<title>{{ __('filament-short-url::default.warning_title') }}</title>
<!-- Premium Google Fonts: Bricolage Grotesque -->
<link rel="preconnect" href="https://fonts.googleapis.com">
@@ -49,10 +49,10 @@
<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' }}
{{ __('filament-short-url::default.warning_title') }}
</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:' }}
{{ __('filament-short-url::default.warning_description') }}
</p>
</div>
@@ -90,7 +90,7 @@
<!-- 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>
<span>{{ __('filament-short-url::default.warning_btn_continue') }}</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>
@@ -99,7 +99,7 @@
<!-- 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' }}
{{ __('filament-short-url::default.warning_btn_back') }}
</button>
</div>
</div>

View File

@@ -36,7 +36,7 @@
<div class="space-y-2 mt-4">
<label class="text-sm font-medium leading-6 text-gray-950 dark:text-white">
{{ __('filament-short-url::default.webhook_show_payload') ?? 'Show example JSON payload' }}
{{ __('filament-short-url::default.webhook_show_payload') }}
</label>
<div

View File

@@ -0,0 +1,16 @@
<x-filament-widgets::widget>
<x-filament::section :icon="$icon">
<x-slot name="heading">
{{ $heading }}
</x-slot>
<div class="flex flex-col items-center justify-center py-12 text-center">
<div class="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-gray-50 text-gray-400 dark:bg-gray-800/30 dark:text-gray-500">
<x-filament::icon icon="heroicon-o-document-magnifying-glass" class="h-5 w-5" />
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ $message }}
</p>
</div>
</x-filament::section>
</x-filament-widgets::widget>

View File

@@ -0,0 +1,164 @@
<x-filament-widgets::widget>
<div class="fi-section rounded-xl border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-900 overflow-hidden">
<!-- Tabs Header -->
<div class="flex items-center justify-between border-b border-gray-200 dark:border-white/10 bg-gray-50/50 dark:bg-white/5 px-6">
<nav class="-mb-px flex space-x-6" aria-label="Tabs">
<button type="button"
wire:click="setActiveTab('devices')"
class="whitespace-nowrap border-b-2 py-4 px-1 text-sm font-semibold focus:outline-none transition-colors {{ $activeTab === 'devices' ? 'border-gray-900 text-gray-900 dark:border-white dark:text-white' : 'border-transparent text-gray-400 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300' }}">
{{ __('filament-short-url::default.stats_breakdown_devices') }}
</button>
<button type="button"
wire:click="setActiveTab('browsers')"
class="whitespace-nowrap border-b-2 py-4 px-1 text-sm font-semibold focus:outline-none transition-colors {{ $activeTab === 'browsers' ? 'border-gray-900 text-gray-900 dark:border-white dark:text-white' : 'border-transparent text-gray-400 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300' }}">
{{ __('filament-short-url::default.stats_breakdown_browsers') }}
</button>
<button type="button"
wire:click="setActiveTab('os')"
class="whitespace-nowrap border-b-2 py-4 px-1 text-sm font-semibold focus:outline-none transition-colors {{ $activeTab === 'os' ? 'border-gray-900 text-gray-900 dark:border-white dark:text-white' : 'border-transparent text-gray-400 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300' }}">
{{ __('filament-short-url::default.stats_breakdown_os') }}
</button>
</nav>
</div>
<!-- Body -->
<div class="relative p-6 min-h-[280px]">
<!-- Spinner -->
<div wire:loading class="absolute inset-0 bg-white/50 dark:bg-gray-900/50 backdrop-blur-[1px] z-10 transition-opacity">
<div class="flex items-center justify-center h-full w-full">
<x-filament::loading-indicator class="h-7 w-7 text-gray-900 dark:text-white" />
</div>
</div>
<!-- Tab Contents -->
<div wire:loading.class="opacity-40 pointer-events-none transition-opacity">
<!-- Devices List -->
@if ($activeTab === 'devices')
<div class="space-y-3.5">
@forelse ($visitsByDevice as $device => $count)
@php
$pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0;
$deviceLabel = match (strtolower($device)) {
'desktop' => __('filament-short-url::default.stats_device_desktop'),
'mobile' => __('filament-short-url::default.stats_device_mobile'),
'tablet' => __('filament-short-url::default.stats_device_tablet'),
default => ucfirst($device),
};
@endphp
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors" x-on:click="$wire.dispatch('set-stats-filter', { key: 'device_type', value: '{{ addslashes($device) }}' })">
<div class="flex items-center justify-between text-sm">
<span class="flex items-center gap-2 font-medium text-gray-700 dark:text-gray-300">
@if (strtolower($device) === 'desktop')
<x-filament::icon icon="heroicon-m-computer-desktop" class="h-4 w-4 text-gray-400 dark:text-gray-500" />
@elseif (strtolower($device) === 'mobile')
<x-filament::icon icon="heroicon-m-device-phone-mobile" class="h-4 w-4 text-gray-400 dark:text-gray-500" />
@elseif (strtolower($device) === 'tablet')
<x-filament::icon icon="heroicon-m-device-tablet" class="h-4 w-4 text-gray-400 dark:text-gray-500" />
@else
<x-filament::icon icon="heroicon-m-question-mark-circle" class="h-4 w-4 text-gray-400 dark:text-gray-500" />
@endif
<span>{{ $deviceLabel }}</span>
</span>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span></span>
</div>
<div class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div class="h-full rounded-full bg-indigo-500 transition-all duration-500" style="width: {{ $pct }}%"></div>
</div>
</div>
@empty
<div class="flex flex-col items-center justify-center py-6 text-center">
<div class="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-gray-50 text-gray-400 dark:bg-gray-800/30 dark:text-gray-500">
<x-filament::icon icon="heroicon-o-document-magnifying-glass" class="h-5 w-5" />
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ __('filament-short-url::default.stats_no_device_data') }}
</p>
</div>
@endforelse
</div>
@endif
<!-- Browsers List -->
@if ($activeTab === 'browsers')
<div class="space-y-3.5">
@forelse ($visitsByBrowser as $browser => $count)
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors flex items-center justify-between text-sm" x-on:click="$wire.dispatch('set-stats-filter', { key: 'browser', value: '{{ addslashes($browser) }}' })">
<div class="flex flex-col min-w-0">
<div class="flex items-center gap-2">
<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>
@if (!empty($visitsByBrowserVersion[$browser]))
@php
$versionsList = [];
foreach ($visitsByBrowserVersion[$browser] as $ver => $vCount) {
$versionsList[] = "v{$ver} ({$vCount})";
}
@endphp
<span class="text-[10px] text-gray-400 dark:text-gray-500 ml-6 truncate">
{{ implode(', ', $versionsList) }}
</span>
@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
<div class="flex flex-col items-center justify-center py-6 text-center">
<div class="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-gray-50 text-gray-400 dark:bg-gray-800/30 dark:text-gray-500">
<x-filament::icon icon="heroicon-o-document-magnifying-glass" class="h-5 w-5" />
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ __('filament-short-url::default.stats_no_browser_data') }}
</p>
</div>
@endforelse
</div>
@endif
<!-- OS List -->
@if ($activeTab === 'os')
<div class="space-y-3.5">
@forelse ($visitsByOs as $os => $count)
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors flex items-center justify-between text-sm" x-on:click="$wire.dispatch('set-stats-filter', { key: 'operating_system', value: '{{ addslashes($os) }}' })">
<div class="flex flex-col min-w-0">
<div class="flex items-center gap-2">
<x-filament::icon :icon="$osIcons[$os] ?? 'heroicon-m-cpu-chip'" 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>
@if (!empty($visitsByOsVersion[$os]))
@php
$versionsList = [];
foreach ($visitsByOsVersion[$os] as $ver => $vCount) {
$versionsList[] = "v{$ver} ({$vCount})";
}
@endphp
<span class="text-[10px] text-gray-400 dark:text-gray-500 ml-6 truncate">
{{ implode(', ', $versionsList) }}
</span>
@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
<div class="flex flex-col items-center justify-center py-6 text-center">
<div class="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-gray-50 text-gray-400 dark:bg-gray-800/30 dark:text-gray-500">
<x-filament::icon icon="heroicon-o-document-magnifying-glass" class="h-5 w-5" />
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ __('filament-short-url::default.stats_no_os_data') }}
</p>
</div>
@endforelse
</div>
@endif
</div>
</div>
</div>
</x-filament-widgets::widget>

View File

@@ -0,0 +1 @@
<div></div>

View File

@@ -0,0 +1,133 @@
<x-filament-widgets::widget>
<div class="fi-section rounded-xl border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-900 overflow-hidden">
<!-- Tabs Header -->
<div class="flex items-center justify-between border-b border-gray-200 dark:border-white/10 bg-gray-50/50 dark:bg-white/5 px-6">
<nav class="-mb-px flex space-x-6" aria-label="Tabs">
<button type="button"
wire:click="setActiveTab('countries')"
class="whitespace-nowrap border-b-2 py-4 px-1 text-sm font-semibold focus:outline-none transition-colors {{ $activeTab === 'countries' ? 'border-gray-900 text-gray-900 dark:border-white dark:text-white' : 'border-transparent text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300' }}">
{{ __('filament-short-url::default.stats_breakdown_countries') }}
</button>
<button type="button"
wire:click="setActiveTab('cities')"
class="whitespace-nowrap border-b-2 py-4 px-1 text-sm font-semibold focus:outline-none transition-colors {{ $activeTab === 'cities' ? 'border-gray-900 text-gray-900 dark:border-white dark:text-white' : 'border-transparent text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300' }}">
{{ __('filament-short-url::default.stats_breakdown_cities') }}
</button>
<button type="button"
wire:click="setActiveTab('languages')"
class="whitespace-nowrap border-b-2 py-4 px-1 text-sm font-semibold focus:outline-none transition-colors {{ $activeTab === 'languages' ? 'border-gray-900 text-gray-900 dark:border-white dark:text-white' : 'border-transparent text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300' }}">
{{ __('filament-short-url::default.stats_breakdown_languages') }}
</button>
</nav>
</div>
<!-- Body -->
<div class="relative p-6 min-h-[280px]">
<!-- Spinner -->
<div wire:loading class="absolute inset-0 bg-white/50 dark:bg-gray-900/50 backdrop-blur-[1px] z-10 transition-opacity">
<div class="flex items-center justify-center h-full w-full">
<x-filament::loading-indicator class="h-7 w-7 text-gray-900 dark:text-white" />
</div>
</div>
<!-- Tab Contents -->
<div wire:loading.class="opacity-40 pointer-events-none transition-opacity">
<!-- Countries List -->
@if ($activeTab === 'countries')
<div class="space-y-3">
@forelse ($visitsByCountry as $code => $count)
@php
$pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0;
$translatedCountry = __('filament-short-url::countries.' . strtoupper($code));
if (!$translatedCountry || $translatedCountry === 'filament-short-url::countries.' . strtoupper($code)) {
$translatedCountry = strtoupper($code);
}
@endphp
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors" x-on:click="$wire.dispatch('set-stats-filter', { key: 'country_code', value: '{{ addslashes($code) }}' })">
<div class="flex items-center justify-between text-sm">
<span class="flex items-center gap-2 font-medium text-gray-700 dark:text-gray-300">
@if ($code)
<img src="https://flagcdn.com/h20/{{ strtolower($code) }}.webp" class="w-5 h-auto rounded-sm inline-block" alt="{{ $translatedCountry }}" />
@endif
<span>{{ $translatedCountry }}</span>
</span>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span></span>
</div>
<div class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div class="h-full rounded-full bg-indigo-500 transition-all duration-500" style="width: {{ $pct }}%"></div>
</div>
</div>
@empty
<div class="flex flex-col items-center justify-center py-6 text-center">
<div class="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-gray-50 text-gray-400 dark:bg-gray-800/30 dark:text-gray-500">
<x-filament::icon icon="heroicon-o-document-magnifying-glass" class="h-5 w-5" />
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ __('filament-short-url::default.stats_no_country_data') }}
</p>
</div>
@endforelse
</div>
@endif
<!-- Cities List -->
@if ($activeTab === 'cities')
<div class="space-y-3.5">
@forelse ($visitsByCity as $city => $count)
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors" x-on:click="$wire.dispatch('set-stats-filter', { key: 'city', value: '{{ addslashes($city) }}' })">
<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>
@empty
<div class="flex flex-col items-center justify-center py-6 text-center">
<div class="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-gray-50 text-gray-400 dark:bg-gray-800/30 dark:text-gray-500">
<x-filament::icon icon="heroicon-o-document-magnifying-glass" class="h-5 w-5" />
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ __('filament-short-url::default.stats_no_city_data') }}
</p>
</div>
@endforelse
</div>
@endif
<!-- Languages List -->
@if ($activeTab === 'languages')
<div class="space-y-3">
@forelse ($visitsByLanguage as $langCode => $count)
@php
$pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0;
$langName = \Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlLanguagesWidget::getLanguageTranslation($langCode);
@endphp
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors" x-on:click="$wire.dispatch('set-stats-filter', { key: 'browser_language', value: '{{ addslashes($langCode) }}' })">
<div class="flex items-center justify-between text-sm">
<span class="font-medium text-gray-700 dark:text-gray-300">{{ $langName }} ({{ strtoupper($langCode) }})</span>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span></span>
</div>
<div class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div class="h-full rounded-full bg-indigo-500 transition-all duration-500" style="width: {{ $pct }}%"></div>
</div>
</div>
@empty
<div class="flex flex-col items-center justify-center py-6 text-center">
<div class="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-gray-50 text-gray-400 dark:bg-gray-800/30 dark:text-gray-500">
<x-filament::icon icon="heroicon-o-document-magnifying-glass" class="h-5 w-5" />
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ __('filament-short-url::default.stats_no_language_data') }}
</p>
</div>
@endforelse
</div>
@endif
</div>
</div>
</div>
</x-filament-widgets::widget>

View File

@@ -0,0 +1,153 @@
<x-filament-widgets::widget>
{{--
wire:poll calls checkForUpdates() instead of blindly re-rendering.
checkForUpdates() does a single MAX(id) query; if nothing changed it
calls skipRender() and Livewire returns a ~100-byte "no diff" response.
Only when a new visit is detected does the full render happen.
.visible stops polling entirely when the widget is scrolled out of view.
--}}
<div wire:poll.5s.visible="checkForUpdates"
class="fi-section rounded-xl border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-900 overflow-hidden">
<!-- Header -->
<div class="flex items-center justify-between border-b border-gray-200 dark:border-white/10 bg-gray-50/50 dark:bg-white/5 px-6 py-4">
<div class="flex items-center gap-2">
<span class="flex h-2 w-2 relative">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
</span>
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{{ __('filament-short-url::default.stats_tab_live_feed') }}
</h3>
</div>
<span class="text-xs text-gray-400 dark:text-gray-500 font-mono">
{{ __('filament-short-url::default.stats_live_feed_poll_interval') }}
</span>
</div>
<!-- Body -->
<div class="p-6 relative min-h-[300px]">
<!-- Spinner shown only during re-renders that actually happen -->
<div wire:loading class="absolute inset-0 bg-white/50 dark:bg-gray-900/50 backdrop-blur-[1px] z-10 transition-opacity">
<div class="flex items-center justify-center h-full w-full">
<x-filament::loading-indicator class="h-7 w-7 text-gray-900 dark:text-white" />
</div>
</div>
<div class="space-y-4" wire:loading.class="opacity-40 pointer-events-none transition-opacity">
@forelse ($visits as $visit)
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4 p-4 border border-gray-100 dark:border-white/5 rounded-xl hover:bg-gray-50/50 dark:hover:bg-white/5 transition-all">
<!-- Visitor & Location Info -->
<div class="flex items-start gap-3 min-w-0 flex-1">
{{-- Flag from flagcdn.com. Alpine handles broken image gracefully without
inline onerror JS (which can violate CSP nonce policies). --}}
<div class="mt-0.5 shrink-0 w-6 flex items-center" title="{{ $visit['country'] }}">
@if ($visit['flag_url'])
<span x-data="{ error: false }">
<img x-show="!error"
x-on:error="error = true"
src="{{ $visit['flag_url'] }}"
width="20"
height="15"
alt="{{ $visit['country_code'] }}"
class="rounded-[2px] object-cover shadow-sm">
<span x-show="error" class="text-base leading-none">🌐</span>
</span>
@else
<span class="text-base leading-none">🌐</span>
@endif
</div>
<div class="min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<button type="button"
x-on:click="$wire.dispatch('set-stats-filter', { key: 'country_code', value: '{{ $visit['country_code'] }}' })"
class="text-sm font-semibold text-gray-950 dark:text-white hover:underline truncate">
{{ $visit['city'] ?: 'Unknown City' }}, {{ $visit['country'] ?: 'Unknown Country' }}
</button>
@if ($visit['is_qr_scan'])
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium bg-purple-50 text-purple-700 dark:bg-purple-950/40 dark:text-purple-300 border border-purple-100 dark:border-purple-900/40">
<x-filament::icon icon="heroicon-m-qr-code" class="w-3.5 h-3.5" />
QR Scan
</span>
@endif
@if ($visit['selected_variant'])
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium bg-amber-50 text-amber-700 dark:bg-amber-950/40 dark:text-amber-300 border border-amber-100 dark:border-amber-900/40">
Variant: {{ $visit['selected_variant'] }}
</span>
@endif
</div>
<div class="text-xs text-gray-400 dark:text-gray-500 mt-1 flex items-center gap-2">
{{-- time_ago precomputed in PHP no Carbon::parse() per-row in Blade --}}
<span>{{ $visit['time_ago'] }}</span>
<span></span>
<span class="font-mono text-[11px]">{{ $visit['ip_address'] ?: '—' }}</span>
</div>
</div>
</div>
<!-- Referrer -->
<div class="flex items-center gap-2 min-w-[150px] max-w-[220px]">
<x-filament::icon icon="heroicon-m-link" class="w-4 h-4 text-gray-400 dark:text-gray-500 shrink-0" />
<span class="text-xs font-medium text-gray-600 dark:text-gray-400 truncate"
title="{{ $visit['referer_url'] }}">
{{ $visit['referer_host'] ?: __('filament-short-url::default.stats_referer_direct') }}
</span>
</div>
<!-- Device / Browser / OS filters -->
<div class="flex items-center gap-2 shrink-0">
<!-- Device -->
<button type="button"
x-on:click="$wire.dispatch('set-stats-filter', { key: 'device_type', value: '{{ $visit['device_type'] }}' })"
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg border border-gray-100 dark:border-white/5 bg-gray-50/50 dark:bg-white/5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/10 transition-colors"
title="Filter by device: {{ $visit['device_type'] }}">
@if (strtolower($visit['device_type'] ?? '') === 'desktop')
<x-filament::icon icon="heroicon-m-computer-desktop" class="w-3.5 h-3.5 text-gray-400 dark:text-gray-500" />
@elseif (strtolower($visit['device_type'] ?? '') === 'mobile')
<x-filament::icon icon="heroicon-m-device-phone-mobile" class="w-3.5 h-3.5 text-gray-400 dark:text-gray-500" />
@elseif (strtolower($visit['device_type'] ?? '') === 'tablet')
<x-filament::icon icon="heroicon-m-device-tablet" class="w-3.5 h-3.5 text-gray-400 dark:text-gray-500" />
@else
<x-filament::icon icon="heroicon-m-question-mark-circle" class="w-3.5 h-3.5 text-gray-400 dark:text-gray-500" />
@endif
<span class="capitalize">{{ $visit['device_type'] ?: 'Unknown' }}</span>
</button>
<!-- Browser -->
<button type="button"
x-on:click="$wire.dispatch('set-stats-filter', { key: 'browser', value: '{{ $visit['browser'] }}' })"
class="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg border border-gray-100 dark:border-white/5 bg-gray-50/50 dark:bg-white/5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/10 transition-colors"
title="Filter by browser: {{ $visit['browser'] }}">
<x-filament::icon icon="heroicon-m-globe-alt" class="w-3.5 h-3.5 text-gray-400 dark:text-gray-500" />
<span>{{ $visit['browser'] ?: 'Browser' }}</span>
</button>
<!-- OS -->
<button type="button"
x-on:click="$wire.dispatch('set-stats-filter', { key: 'operating_system', value: '{{ $visit['operating_system'] }}' })"
class="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg border border-gray-100 dark:border-white/5 bg-gray-50/50 dark:bg-white/5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/10 transition-colors"
title="Filter by OS: {{ $visit['operating_system'] }}">
<x-filament::icon icon="heroicon-m-cpu-chip" class="w-3.5 h-3.5 text-gray-400 dark:text-gray-500" />
<span>{{ $visit['operating_system'] ?: 'OS' }}</span>
</button>
</div>
</div>
@empty
<div class="flex flex-col items-center justify-center py-12 text-center">
<div class="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-gray-50 text-gray-400 dark:bg-gray-800/30 dark:text-gray-500">
<x-filament::icon icon="heroicon-o-chat-bubble-left-right" class="h-6 w-6" />
</div>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ __('filament-short-url::default.stats_live_feed_empty') }}
</p>
</div>
@endforelse
</div>
</div>
</div>
</x-filament-widgets::widget>

View File

@@ -0,0 +1,107 @@
<x-filament-widgets::widget>
<div class="fi-section rounded-xl border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-900 overflow-hidden">
<!-- Tabs Header -->
<div class="flex items-center justify-between border-b border-gray-200 dark:border-white/10 bg-gray-50/50 dark:bg-white/5 px-6">
<nav class="-mb-px flex space-x-6" aria-label="Tabs">
<button type="button"
wire:click="setActiveTab('bots')"
class="whitespace-nowrap border-b-2 py-4 px-1 text-sm font-semibold focus:outline-none transition-colors {{ $activeTab === 'bots' ? 'border-gray-900 text-gray-900 dark:border-white dark:text-white' : 'border-transparent text-gray-400 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300' }}">
{{ __('filament-short-url::default.stats_security_bot_ratio') }}
</button>
<button type="button"
wire:click="setActiveTab('vpn')"
class="whitespace-nowrap border-b-2 py-4 px-1 text-sm font-semibold focus:outline-none transition-colors {{ $activeTab === 'vpn' ? 'border-gray-900 text-gray-900 dark:border-white dark:text-white' : 'border-transparent text-gray-400 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300' }}">
{{ __('filament-short-url::default.stats_security_vpn_blocked') }}
</button>
</nav>
</div>
<!-- Body -->
<div class="relative p-6 min-h-[280px]">
<!-- Spinner -->
<div wire:loading class="absolute inset-0 bg-white/50 dark:bg-gray-900/50 backdrop-blur-[1px] z-10 transition-opacity">
<div class="flex items-center justify-center h-full w-full">
<x-filament::loading-indicator class="h-7 w-7 text-gray-900 dark:text-white" />
</div>
</div>
<!-- Tab Contents -->
<div wire:loading.class="opacity-40 pointer-events-none transition-opacity">
<!-- Bot Clicks Tab -->
@if ($activeTab === 'bots')
<div class="space-y-4">
{{-- Human Clicks --}}
<div>
<div class="flex items-center justify-between text-sm">
<span class="flex items-center gap-2 font-medium text-gray-700 dark:text-gray-300">
<span class="inline-block h-2 w-2 rounded-full bg-emerald-500"></span>
<span>{{ __('filament-short-url::default.stats_security_real_users') }}</span>
</span>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">
{{ number_format($humanClicks) }}
<span class="text-gray-400 dark:text-gray-500">({{ $humanPercentage }}%)</span>
</span>
</div>
<div class="mt-2 h-2.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: {{ $humanPercentage }}%"></div>
</div>
</div>
{{-- Bot Clicks --}}
<div>
<div class="flex items-center justify-between text-sm">
<span class="flex items-center gap-2 font-medium text-gray-700 dark:text-gray-300">
<span class="inline-block h-2 w-2 rounded-full bg-purple-500"></span>
<span>{{ __('filament-short-url::default.stats_security_bots') }}</span>
</span>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">
{{ number_format($botClicks) }}
<span class="text-gray-400 dark:text-gray-500">({{ $botPercentage }}%)</span>
</span>
</div>
<div class="mt-2 h-2.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div class="h-full rounded-full bg-purple-500 transition-all duration-500" style="width: {{ $botPercentage }}%"></div>
</div>
</div>
</div>
@endif
<!-- VPN / Proxy Blocked Tab -->
@if ($activeTab === 'vpn')
<div class="mt-2">
@if ($proxyClicks === 0)
<div class="flex flex-col items-center justify-center py-6 text-center">
<div class="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-emerald-50 text-emerald-500 dark:bg-emerald-950/30 dark:text-emerald-400">
<x-filament::icon icon="heroicon-o-check-circle" class="h-6 w-6" />
</div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-300">
{{ __('filament-short-url::default.stats_security_no_vpn_blocks') }}
</p>
</div>
@else
<div class="flex items-center justify-between py-2">
<div>
<p class="text-2xl font-bold text-rose-600 dark:text-rose-400 font-mono">
{{ number_format($proxyClicks) }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ __('filament-short-url::default.stats_security_proxy_clicks') }}
</p>
</div>
<div class="rounded-lg bg-rose-50 px-3 py-2 text-rose-700 dark:bg-rose-950/30 dark:text-rose-400">
<div class="flex items-center gap-1.5 text-xs font-semibold">
<x-filament::icon icon="heroicon-o-exclamation-triangle" class="h-4 w-4" />
<span>VPN / Proxy Traffic</span>
</div>
</div>
</div>
<div class="mt-4 h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div class="h-full rounded-full bg-rose-500 transition-all duration-500" style="width: {{ $proxyPercentage }}%"></div>
</div>
@endif
</div>
@endif
</div>
</div>
</div>
</x-filament-widgets::widget>

View File

@@ -0,0 +1,205 @@
<x-filament-widgets::widget>
<div class="fi-section rounded-xl border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-900 overflow-hidden">
<!-- Tabs Header -->
<div class="flex items-center justify-between border-b border-gray-200 dark:border-white/10 bg-gray-50/50 dark:bg-white/5 px-6">
<nav class="-mb-px flex space-x-6" aria-label="Tabs">
<button type="button"
wire:click="setActiveTab('referrers')"
class="whitespace-nowrap border-b-2 py-4 px-1 text-sm font-semibold focus:outline-none transition-colors {{ $activeTab === 'referrers' ? 'border-gray-900 text-gray-900 dark:border-white dark:text-white' : 'border-transparent text-gray-400 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300' }}">
{{ __('filament-short-url::default.stats_breakdown_referers') }}
</button>
<button type="button"
wire:click="setActiveTab('utm')"
class="whitespace-nowrap border-b-2 py-4 px-1 text-sm font-semibold focus:outline-none transition-colors {{ $activeTab === 'utm' ? 'border-gray-900 text-gray-900 dark:border-white dark:text-white' : 'border-transparent text-gray-400 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300' }}">
UTM Parameters
</button>
</nav>
</div>
<!-- Submenu Header (only for UTM) -->
@if ($activeTab === 'utm')
<div class="flex items-center gap-1.5 border-b border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 px-6 py-2.5">
<button type="button"
wire:click="setActiveSubTab('sources')"
class="px-2.5 py-1 text-xs font-semibold rounded-lg transition-all focus:outline-none {{ $activeSubTab === 'sources' ? 'bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-white' : 'bg-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300' }}">
{{ __('filament-short-url::default.stats_breakdown_utm_source') }}
</button>
<button type="button"
wire:click="setActiveSubTab('mediums')"
class="px-2.5 py-1 text-xs font-semibold rounded-lg transition-all focus:outline-none {{ $activeSubTab === 'mediums' ? 'bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-white' : 'bg-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300' }}">
{{ __('filament-short-url::default.stats_breakdown_utm_medium') }}
</button>
<button type="button"
wire:click="setActiveSubTab('campaigns')"
class="px-2.5 py-1 text-xs font-semibold rounded-lg transition-all focus:outline-none {{ $activeSubTab === 'campaigns' ? 'bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-white' : 'bg-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300' }}">
{{ __('filament-short-url::default.stats_breakdown_utm_campaign') }}
</button>
<button type="button"
wire:click="setActiveSubTab('terms')"
class="px-2.5 py-1 text-xs font-semibold rounded-lg transition-all focus:outline-none {{ $activeSubTab === 'terms' ? 'bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-white' : 'bg-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300' }}">
{{ __('filament-short-url::default.stats_breakdown_utm_term') }}
</button>
<button type="button"
wire:click="setActiveSubTab('contents')"
class="px-2.5 py-1 text-xs font-semibold rounded-lg transition-all focus:outline-none {{ $activeSubTab === 'contents' ? 'bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-white' : 'bg-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300' }}">
{{ __('filament-short-url::default.stats_breakdown_utm_content') }}
</button>
</div>
@endif
<!-- Body -->
<div class="relative p-6 min-h-[280px]">
<!-- Spinner -->
<div wire:loading class="absolute inset-0 bg-white/50 dark:bg-gray-900/50 backdrop-blur-[1px] z-10 transition-opacity">
<div class="flex items-center justify-center h-full w-full">
<x-filament::loading-indicator class="h-7 w-7 text-gray-900 dark:text-white" />
</div>
</div>
<!-- Tab Contents -->
<div wire:loading.class="opacity-40 pointer-events-none transition-opacity">
<!-- Referrers Tab -->
@if ($activeTab === 'referrers')
<div class="space-y-3.5">
@forelse ($visitsByReferer as $referer => $count)
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors" x-on:click="$wire.dispatch('set-stats-filter', { key: 'referer_host', value: '{{ addslashes($referer) }}' })">
<div class="flex items-center justify-between text-sm">
<span class="font-medium text-gray-700 dark:text-gray-300">{{ $referer }}</span>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span></span>
</div>
<div class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div class="h-full rounded-full bg-indigo-500 transition-all duration-500" style="width: {{ $pct }}%"></div>
</div>
</div>
@empty
<div class="flex flex-col items-center justify-center py-6 text-center">
<div class="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-gray-50 text-gray-400 dark:bg-gray-800/30 dark:text-gray-500">
<x-filament::icon icon="heroicon-o-document-magnifying-glass" class="h-5 w-5" />
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ __('filament-short-url::default.stats_no_referer_data') }}
</p>
</div>
@endforelse
</div>
@endif
<!-- UTM Parameters Tab -->
@if ($activeTab === 'utm')
<div class="space-y-4">
<!-- Sub-tab Content -->
<div>
@if ($activeSubTab === 'sources')
<div class="space-y-3.5 mt-2">
@forelse ($utmSources as $source => $count)
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1 rounded-lg transition-colors flex items-center justify-between text-sm" x-on:click="$wire.dispatch('set-stats-filter', { key: 'utm_source', value: '{{ addslashes($source) }}' })">
<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>
@empty
<div class="flex flex-col items-center justify-center py-6 text-center">
<div class="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-gray-50 text-gray-400 dark:bg-gray-800/30 dark:text-gray-500">
<x-filament::icon icon="heroicon-o-document-magnifying-glass" class="h-5 w-5" />
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ __('filament-short-url::default.stats_no_utm_data') }}
</p>
</div>
@endforelse
</div>
@elseif ($activeSubTab === 'mediums')
<div class="space-y-3.5 mt-2">
@forelse ($utmMediums as $medium => $count)
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1 rounded-lg transition-colors flex items-center justify-between text-sm" x-on:click="$wire.dispatch('set-stats-filter', { key: 'utm_medium', value: '{{ addslashes($medium) }}' })">
<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
<div class="flex flex-col items-center justify-center py-6 text-center">
<div class="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-gray-50 text-gray-400 dark:bg-gray-800/30 dark:text-gray-500">
<x-filament::icon icon="heroicon-o-document-magnifying-glass" class="h-5 w-5" />
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ __('filament-short-url::default.stats_no_utm_data') }}
</p>
</div>
@endforelse
</div>
@elseif ($activeSubTab === 'campaigns')
<div class="space-y-3.5 mt-2">
@forelse ($utmCampaigns as $campaign => $count)
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1 rounded-lg transition-colors flex items-center justify-between text-sm" x-on:click="$wire.dispatch('set-stats-filter', { key: 'utm_campaign', value: '{{ addslashes($campaign) }}' })">
<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
<div class="flex flex-col items-center justify-center py-6 text-center">
<div class="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-gray-50 text-gray-400 dark:bg-gray-800/30 dark:text-gray-500">
<x-filament::icon icon="heroicon-o-document-magnifying-glass" class="h-5 w-5" />
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ __('filament-short-url::default.stats_no_utm_data') }}
</p>
</div>
@endforelse
</div>
@elseif ($activeSubTab === 'terms')
<div class="space-y-3.5 mt-2">
@forelse ($utmTerms as $term => $count)
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1 rounded-lg transition-colors flex items-center justify-between text-sm" x-on:click="$wire.dispatch('set-stats-filter', { key: 'utm_term', value: '{{ addslashes($term) }}' })">
<span class="font-medium text-gray-700 dark:text-gray-300 truncate mr-2">{{ $term }}</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
<div class="flex flex-col items-center justify-center py-6 text-center">
<div class="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-gray-50 text-gray-400 dark:bg-gray-800/30 dark:text-gray-500">
<x-filament::icon icon="heroicon-o-document-magnifying-glass" class="h-5 w-5" />
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ __('filament-short-url::default.stats_no_utm_data') }}
</p>
</div>
@endforelse
</div>
@elseif ($activeSubTab === 'contents')
<div class="space-y-3.5 mt-2">
@forelse ($utmContents as $content => $count)
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1 rounded-lg transition-colors flex items-center justify-between text-sm" x-on:click="$wire.dispatch('set-stats-filter', { key: 'utm_content', value: '{{ addslashes($content) }}' })">
<span class="font-medium text-gray-700 dark:text-gray-300 truncate mr-2">{{ $content }}</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
<div class="flex flex-col items-center justify-center py-6 text-center">
<div class="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-gray-50 text-gray-400 dark:bg-gray-800/30 dark:text-gray-500">
<x-filament::icon icon="heroicon-o-document-magnifying-glass" class="h-5 w-5" />
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ __('filament-short-url::default.stats_no_utm_data') }}
</p>
</div>
@endforelse
</div>
@endif
</div>
</div>
@endif
</div>
</div>
</div>
</x-filament-widgets::widget>

View File

@@ -0,0 +1,38 @@
<x-filament-widgets::widget>
<x-filament::section icon="heroicon-o-arrow-path-rounded-square">
<x-slot name="heading">
A/B Testing Variants
</x-slot>
<div class="space-y-4 mt-2">
@foreach ($visitsByVariant as $variant => $count)
@php
$pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0;
$expectedWeight = null;
if (is_array($rotationVariants)) {
foreach ($rotationVariants as $v) {
if (($v['label'] ?? '') === $variant) {
$expectedWeight = $v['weight'] ?? null;
break;
}
}
}
@endphp
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors" x-on:click="$wire.dispatch('set-stats-filter', { key: 'selected_variant', value: '{{ addslashes($variant) }}' })">
<div class="flex items-center justify-between text-sm">
<span class="font-medium text-gray-700 dark:text-gray-300">{{ $variant }}</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 }}%{{ $expectedWeight !== null ? ' vs expected ' . $expectedWeight . '%' : '' }})
</span>
</span>
</div>
<div class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div class="h-full rounded-full bg-indigo-500 transition-all duration-500" style="width: {{ $pct }}%"></div>
</div>
</div>
@endforeach
</div>
</x-filament::section>
</x-filament-widgets::widget>

View File

@@ -1,257 +0,0 @@
@php
$deviceIcons = [
'desktop' => 'heroicon-m-computer-desktop',
'mobile' => 'heroicon-m-device-phone-mobile',
'tablet' => 'heroicon-m-device-tablet',
'robot' => 'heroicon-m-cpu-chip',
];
$browserIcons = [
'Chrome' => 'heroicon-m-globe-alt',
'Firefox' => 'heroicon-m-globe-americas',
'Safari' => 'heroicon-m-globe-europe-africa',
'Edge' => 'heroicon-m-globe-asia-australia',
'Opera' => 'heroicon-m-bolt',
'Internet Explorer' => 'heroicon-m-wrench',
'Samsung Browser' => 'heroicon-m-device-phone-mobile',
];
$osIcons = [
'Windows' => 'heroicon-m-computer-desktop',
'OS X' => 'heroicon-m-computer-desktop',
'macOS' => 'heroicon-m-computer-desktop',
'iOS' => 'heroicon-m-device-phone-mobile',
'Android' => 'heroicon-m-device-phone-mobile',
'Linux' => 'heroicon-m-cpu-chip',
];
@endphp
<x-filament-widgets::widget>
<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" />
</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>
<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>
@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>
@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>
</div>
</div>
{{-- 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>
<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>
@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 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

@@ -0,0 +1,102 @@
@php
use Filament\Widgets\View\Components\ChartWidgetComponent;
use Illuminate\View\ComponentAttributeBag;
$color = $this->getColor();
$heading = $this->getHeading();
$description = $this->getDescription();
$filters = $this->getFilters();
$isCollapsible = $this->isCollapsible();
$type = $this->getType();
$maxHeight = $this->getMaxHeight();
$hasMaxHeight = filled($maxHeight) && $maxHeight !== '100%';
@endphp
<x-filament-widgets::widget class="fi-wi-chart">
<x-filament::section
:description="$description"
:heading="$heading"
:collapsible="$isCollapsible"
>
<x-slot name="headerEnd">
<div class="flex items-center gap-3">
<!-- Metric Selector -->
<x-filament::input.wrapper class="w-44">
<x-filament::input.select wire:model.live="activeMetric">
<option value="total">{{ __('filament-short-url::default.stats_card_total') }}</option>
<option value="unique">{{ __('filament-short-url::default.stats_card_unique') }}</option>
<option value="qr">{{ __('filament-short-url::default.stats_card_qr_scans') }}</option>
</x-filament::input.select>
</x-filament::input.wrapper>
<!-- Granularity Selector -->
@if ($filters)
<x-filament::input.wrapper class="w-32">
<x-filament::input.select wire:model.live="filter">
@foreach ($filters as $value => $label)
<option value="{{ $value }}">
{{ $label }}
</option>
@endforeach
</x-filament::input.select>
</x-filament::input.wrapper>
@endif
</div>
</x-slot>
<div
@if ($pollingInterval = $this->getPollingInterval())
wire:poll.{{ $pollingInterval }}="updateChartData"
@endif
>
<div
x-load
x-load-src="{{ \Filament\Support\Facades\FilamentAsset::getAlpineComponentSrc('chart', 'filament/widgets') }}"
wire:ignore
data-chart-type="{{ $type }}"
x-data="chart({
cachedData: @js($this->getCachedData()),
options: @js($this->getOptions()),
type: @js($type),
})"
{{
(new ComponentAttributeBag)
->color(ChartWidgetComponent::class, $color)
->class([
'fi-wi-chart-canvas-ctn',
'fi-wi-chart-canvas-ctn-no-aspect-ratio' => $hasMaxHeight,
])
}}
>
<canvas
x-ref="canvas"
@style([
'width: 100%',
'height: 100%; max-height: 100%' => ! $hasMaxHeight,
"max-height: {$maxHeight}" => $hasMaxHeight,
])
></canvas>
<span
x-ref="backgroundColorElement"
class="fi-wi-chart-bg-color"
></span>
<span
x-ref="borderColorElement"
class="fi-wi-chart-border-color"
></span>
<span
x-ref="gridColorElement"
class="fi-wi-chart-grid-color"
></span>
<span
x-ref="textColorElement"
class="fi-wi-chart-text-color"
></span>
</div>
</div>
</x-filament::section>
</x-filament-widgets::widget>

View File

@@ -1,63 +0,0 @@
<x-filament-widgets::widget>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
{{-- Countries --}}
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-indigo-50 text-indigo-500 dark:bg-indigo-950/50 dark:text-indigo-400">
<x-filament::icon icon="heroicon-o-globe-alt" 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_countries') }}</h3>
</div>
<div class="space-y-3">
@forelse ($visitsByCountry as $country => $count)
@php
$pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0;
$translatedCountry = \Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsRightBreakdown::getCountryTranslation($country);
@endphp
<div>
<div class="flex items-center justify-between text-sm">
<span class="font-medium text-gray-700 dark:text-gray-300">{{ $translatedCountry }}</span>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span></span>
</div>
<div class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div class="h-full rounded-full bg-indigo-500 transition-all duration-500" style="width: {{ $pct }}%"></div>
</div>
</div>
@empty
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_country_data') }}</p>
@endforelse
</div>
</div>
{{-- Languages --}}
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-indigo-50 text-indigo-500 dark:bg-indigo-950/50 dark:text-indigo-400">
<x-filament::icon icon="heroicon-o-language" class="h-5 w-5" />
</div>
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_languages') }}</h3>
</div>
<div class="space-y-3">
@forelse ($visitsByLanguage as $langCode => $count)
@php
$pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0;
$langName = \Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsRightBreakdown::getLanguageTranslation($langCode);
@endphp
<div>
<div class="flex items-center justify-between text-sm">
<span class="font-medium text-gray-700 dark:text-gray-300">{{ $langName }} ({{ strtoupper($langCode) }})</span>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span></span>
</div>
<div class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div class="h-full rounded-full bg-indigo-500 transition-all duration-500" style="width: {{ $pct }}%"></div>
</div>
</div>
@empty
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_language_data') }}</p>
@endforelse
</div>
</div>
</div>
</x-filament-widgets::widget>

View File

@@ -4,8 +4,6 @@ use Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlApiController;
use Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlLogoController;
use Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlRedirectController;
use Bjanczak\FilamentShortUrl\Http\Middleware\AuthenticateShortUrlApi;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Route;
Route::get('/.well-known/apple-app-site-association', [ShortUrlRedirectController::class, 'serveAasa'])
@@ -22,12 +20,20 @@ Route::match(
)
->name('short-url.redirect')
->where('key', '[a-zA-Z0-9_-]+')
->middleware(config('filament-short-url.middleware', ['web', 'throttle:120,1']));
->middleware(config('filament-short-url.middleware', ['throttle:120,1']));
Route::match(
['GET', 'POST'],
config('filament-short-url.route_prefix', 's').'-auth/{key}',
[ShortUrlRedirectController::class, 'handlePasswordAuth']
)
->name('short-url.password-auth')
->where('key', '[a-zA-Z0-9_-]+')
->middleware(array_merge(['web'], config('filament-short-url.middleware', ['throttle:120,1'])));
Route::prefix('api/short-url')
->middleware([
AuthenticateShortUrlApi::class,
'throttle:60,1',
])
->group(function () {
Route::get('links', [ShortUrlApiController::class, 'index']);
@@ -42,11 +48,10 @@ Route::post('admin/short-url/upload-logo', [ShortUrlLogoController::class, 'uplo
->name('short-url.upload-logo')
->middleware(['web']);
Route::post('admin/short-url/log-debug', function (Request $request) {
Log::info('QR DESIGNER JS DEBUG: '.json_encode($request->all()));
return response()->json(['status' => 'ok']);
})->middleware(['web']);
Route::get('short-url/logo/{filename}', [ShortUrlLogoController::class, 'serveLogo'])
->name('short-url.logo');
if (config('filament-short-url.enable_fallback_route', true)) {
Route::fallback(ShortUrlRedirectController::class)
->middleware(config('filament-short-url.middleware', ['throttle:120,1']));
}

View File

@@ -0,0 +1,22 @@
<?php
namespace Bjanczak\FilamentShortUrl\Assets;
use Filament\Support\Assets\Css;
use Throwable;
class ShortUrlCss extends Css
{
public function getVersion(): string
{
try {
$path = $this->getPublicPath();
if (file_exists($path)) {
return (string) filemtime($path);
}
} catch (Throwable $exception) {
}
return parent::getVersion();
}
}

View File

@@ -27,12 +27,22 @@ class AggregateAndPruneVisitsCommand extends Command
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();
// 1. Find the oldest and newest visit dates before today using highly optimized min/max index scanning
$oldestVisit = ShortUrlVisit::where('visited_at', '<', $today)->min('visited_at');
if (! $oldestVisit) {
$dates = [];
} else {
$latestVisit = ShortUrlVisit::where('visited_at', '<', $today)->max('visited_at');
$startCarbon = Carbon::parse($oldestVisit)->startOfDay();
$endCarbon = Carbon::parse($latestVisit)->startOfDay();
$dates = [];
$current = $startCarbon->copy();
while ($current->lte($endCarbon)) {
$dates[] = $current->toDateString();
$current->addDay();
}
}
if (empty($dates)) {
$this->info('No historical visits to aggregate.');
@@ -46,7 +56,13 @@ class AggregateAndPruneVisitsCommand extends Command
$start = $date.' 00:00:00';
$end = $nextDate.' 00:00:00';
// 1. Get totals and uniques per short_url_id
// Driver-aware boolean count: MySQL/SQLite store booleans as TINYINT (= 1),
// PostgreSQL uses a native boolean type (cast to int for aggregation).
$driver = DB::connection()->getDriverName();
$qrExpr = $driver === 'pgsql'
? 'count(case when is_qr_scan::int = 1 then 1 end) as qr_scans'
: 'count(case when is_qr_scan = 1 then 1 end) as qr_scans';
$totals = DB::table('short_url_visits')
->where('visited_at', '>=', $start)
->where('visited_at', '<', $end)
@@ -54,7 +70,7 @@ class AggregateAndPruneVisitsCommand extends Command
'short_url_id',
DB::raw('count(*) as total'),
DB::raw('count(distinct ip_hash) as uniques'),
DB::raw("count(case when is_qr_scan = 1 or is_qr_scan = true or is_qr_scan = '1' then 1 end) as qr_scans"),
DB::raw($qrExpr),
])
->groupBy('short_url_id')
->get();
@@ -79,14 +95,21 @@ class AggregateAndPruneVisitsCommand extends Command
'utm_medium_stats' => [],
'utm_campaign_stats' => [],
'language_stats' => [],
'variant_stats' => [],
];
}
// Helper to fetch and populate category stats
// Helper to fetch and populate category stats natively in database GROUP BY
$populateStats = function (string $column, string $statsKey) use ($start, $end, &$statsByUrl): void {
$urlIds = array_keys($statsByUrl);
if (empty($urlIds)) {
return;
}
$query = DB::table('short_url_visits')
->where('visited_at', '>=', $start)
->where('visited_at', '<', $end)
->whereIn('short_url_id', $urlIds)
->whereNotNull($column)
->where($column, '<>', '');
@@ -118,17 +141,18 @@ class AggregateAndPruneVisitsCommand extends Command
}
};
// Populate all categories via 10 quick indexed database aggregations
// Populate all categories via 11 quick indexed database aggregations
$populateStats('device_type', 'device_stats');
$populateStats('browser', 'browser_stats');
$populateStats('operating_system', 'os_stats');
$populateStats('country', 'country_stats');
$populateStats('country_code', 'country_stats');
$populateStats('city', 'city_stats');
$populateStats('referer_host', 'referer_stats');
$populateStats('utm_source', 'utm_source_stats');
$populateStats('utm_medium', 'utm_medium_stats');
$populateStats('utm_campaign', 'utm_campaign_stats');
$populateStats('browser_language', 'language_stats');
$populateStats('selected_variant', 'variant_stats');
// Write aggregated stats to ShortUrlDailyStats
foreach ($statsByUrl as $urlId => $s) {
@@ -149,6 +173,7 @@ class AggregateAndPruneVisitsCommand extends Command
'utm_campaign_stats' => $s['utm_campaign_stats'],
'qr_visits_count' => $s['qr_scans'],
'language_stats' => $s['language_stats'],
'variant_stats' => $s['variant_stats'],
]);
}
});

View File

@@ -3,10 +3,10 @@
namespace Bjanczak\FilamentShortUrl\Console\Commands;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Illuminate\Cache\RedisStore;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
class SyncBufferedCountersCommand extends Command
{
@@ -21,19 +21,26 @@ class SyncBufferedCountersCommand extends Command
$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)) {
// Atomically pull the dirty-ID list so incoming increments during this command
// are written to a fresh list rather than being lost. Strategy is driver-aware:
// Redis uses RENAME + SMEMBERS (O(N)) for true atomicity; all other stores use
// Cache::pull() which is atomic on most drivers (file, database, memcached).
$store = Cache::store()->getStore();
$isRedis = $store instanceof RedisStore;
if ($isRedis) {
$tempKey = "{$dirtyKey}:temp:".time();
try {
if (Redis::exists($dirtyKey)) {
Redis::rename($dirtyKey, $tempKey);
$dirtyIds = Redis::smembers($tempKey);
Redis::del($tempKey);
$conn = $store->connection();
if ($conn->exists($dirtyKey)) {
$conn->rename($dirtyKey, $tempKey);
$rawIds = $conn->smembers($tempKey);
$conn->del($tempKey);
$dirtyIds = $rawIds ?: [];
} else {
$dirtyIds = [];
}
} catch (\Throwable) {
// If key does not exist or rename fails, fallback
$dirtyIds = [];
}
} else {
@@ -46,7 +53,7 @@ class SyncBufferedCountersCommand extends Command
return 0;
}
$dirtyIds = array_unique(array_filter($dirtyIds));
$dirtyIds = array_unique(array_filter(array_map('intval', $dirtyIds)));
$processed = 0;
$updatesToMake = [];
@@ -76,7 +83,11 @@ class SyncBufferedCountersCommand extends Command
try {
DB::transaction(function () use ($updatesToMake, &$processed) {
$shortUrls = ShortUrl::whereIn('id', array_keys($updatesToMake))->get(['id', 'url_key']);
$shortUrls = ShortUrl::whereIn('id', array_keys($updatesToMake))
->with('customDomain')
->get(['id', 'url_key', 'custom_domain_id']);
$appHost = parse_url(config('app.url'), PHP_URL_HOST);
foreach ($updatesToMake as $id => $deltas) {
ShortUrl::where('id', $id)->update([
@@ -87,12 +98,24 @@ class SyncBufferedCountersCommand extends Command
$processed++;
}
// Bust redirect cache for all host-variant keys so subsequent requests
// see fresh counters (for max_visits enforcement etc.)
foreach ($shortUrls as $url) {
Cache::forget("filament-short-url:{$url->url_key}");
$hostsToForget = array_unique(array_filter([
'default',
$appHost,
$url->customDomain?->domain,
]));
foreach ($hostsToForget as $host) {
Cache::forget("filament-short-url:{$url->url_key}:{$host}");
}
}
});
} catch (\Throwable $e) {
// Restore pulled values in cache so no clicks are lost
// DB transaction failed — restore pulled cache values so no clicks are lost.
// Increments are used (not set) to safely merge with any new clicks that
// arrived during the failed transaction window.
foreach ($updatesToMake as $id => $deltas) {
$totalKey = "{$prefix}total:{$id}";
$uniqueKey = "{$prefix}unique:{$id}";
@@ -109,9 +132,10 @@ class SyncBufferedCountersCommand extends Command
}
}
// Put the IDs back into the dirty list
if (Cache::getDefaultDriver() === 'redis' && class_exists(Redis::class)) {
Redis::sadd($dirtyKey, ...array_keys($updatesToMake));
// Restore dirty IDs using the same driver-aware strategy
if ($isRedis) {
$conn = $store->connection();
$conn->sadd($dirtyKey, ...array_keys($updatesToMake));
} else {
$lock = Cache::lock("{$prefix}dirty_ids_lock", 2);
$lock->get(function () use ($prefix, $updatesToMake) {

View File

@@ -0,0 +1,32 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Filament\Forms\Components;
use Filament\Forms\Components\Field;
class TrafficSplitter extends Field
{
protected string $view = 'filament-short-url::forms.components.traffic-splitter';
protected string $target = 'rotation_variants';
public function target(string $target): static
{
$this->target = $target;
return $this;
}
public function getTargetStatePath(): string
{
$containerPath = $this->getContainer()->getStatePath();
return $containerPath ? $containerPath.'.'.$this->target : $this->target;
}
}

View File

@@ -0,0 +1,182 @@
<?php
namespace Bjanczak\FilamentShortUrl\Filament\Resources;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlCustomDomainResource\Pages\ListShortUrlCustomDomains;
use Bjanczak\FilamentShortUrl\Models\ShortUrlCustomDomain;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Actions\DeleteAction;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\Layout\Split;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\ToggleColumn;
use Filament\Tables\Columns\ViewColumn;
use Filament\Tables\Table;
class ShortUrlCustomDomainResource extends Resource
{
protected static ?string $model = ShortUrlCustomDomain::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-globe-alt';
protected static ?int $navigationSort = 52;
protected static ?string $recordTitleAttribute = 'domain';
public static function getNavigationLabel(): string
{
return __('filament-short-url::default.domains_navigation_label');
}
public static function getModelLabel(): string
{
return __('filament-short-url::default.domain_resource_title');
}
public static function getPluralModelLabel(): string
{
return __('filament-short-url::default.domains_navigation_label');
}
public static function getNavigationGroup(): string|\UnitEnum|null
{
try {
return ShortUrlResource::getNavigationGroup();
} catch (\Throwable) {
return __('filament-short-url::default.navigation_group');
}
}
public static function getNavigationSort(): ?int
{
try {
return ShortUrlResource::getNavigationSort() + 2;
} catch (\Throwable) {
return static::$navigationSort;
}
}
public static function form(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('domain')
->label(__('filament-short-url::default.domain_label'))
->required()
->maxLength(191)
->placeholder('e.g. links.acme.com')
->rules([
'regex:/^[a-zA-Z0-9][-a-zA-Z0-9]{0,62}(\.[a-zA-Z0-9][-a-zA-Z0-9]{0,62})+$/',
])
->unique(ignoreRecord: true)
->dehydrateStateUsing(fn ($state) => strtolower(trim($state))),
Toggle::make('is_active')
->label(__('filament-short-url::default.domain_status_active'))
->default(true),
])
->columns(1);
}
public static function table(Table $table): Table
{
return $table
->columns([
Split::make([
ViewColumn::make('domain')
->label(__('filament-short-url::default.domain_label'))
->view('filament-short-url::table.domain-column')
->searchable()
->grow(),
ToggleColumn::make('is_active')
->label(__('filament-short-url::default.domain_status_active')),
TextColumn::make('is_verified')
->label(__('filament-short-url::default.domain_verification_status'))
->badge()
->color(fn ($state) => $state ? 'success' : 'danger')
->formatStateUsing(fn ($state) => $state ? __('filament-short-url::default.domain_status_valid') : __('filament-short-url::default.domain_status_invalid')),
]),
])
->modifyQueryUsing(fn ($query) => $query
->withCount('shortUrls')
->withSum('shortUrls as total_clicks', 'total_visits')
)
->filters([])
->actions([
Action::make('dns_settings')
->label('')
->icon('heroicon-o-cog-6-tooth')
->color('gray')
->iconButton()
->badge(fn (ShortUrlCustomDomain $record): ?string => $record->is_verified ? null : '!')
->badgeColor('danger')
->modalHeading(fn (ShortUrlCustomDomain $record) => __('filament-short-url::default.dns_setup_title', ['domain' => $record->domain]))
->modalWidth('2xl')
->modalContent(fn (ShortUrlCustomDomain $record) => view('filament-short-url::table.dns-guide-panel', ['record' => $record]))
->modalSubmitAction(false)
->modalCancelAction(false)
->closeModalByClickingAway(false)
->extraAttributes([
'class' => 'action-trigger-btn group flex items-center justify-center gap-2 whitespace-nowrap rounded-lg border text-sm bg-bg-default text-content-emphasis hover:bg-bg-muted focus-visible:border-border-emphasis sm:inline-flex h-8 px-1.5 outline-none transition-all duration-200 border-transparent sm:group-hover/card:border-neutral-200',
'style' => 'width: 32px !important; height: 32px !important; padding: 0px !important; border-radius: 8px !important;',
]),
ActionGroup::make([
Action::make('verify')
->label(__('filament-short-url::default.action_verify_dns'))
->icon('heroicon-o-arrow-path')
->color('warning')
->action(function (ShortUrlCustomDomain $record) {
$verified = $record->verifyDns();
if ($verified) {
Notification::make()
->title(__('filament-short-url::default.dns_verify_success'))
->success()
->send();
} else {
Notification::make()
->title(__('filament-short-url::default.dns_verify_fail'))
->danger()
->send();
}
}),
ActionGroup::make([
DeleteAction::make()
->label(__('filament-short-url::default.action_delete_domain'))
->icon('heroicon-o-trash')
->color('danger')
->modalHeading(__('filament-short-url::default.action_delete_domain'))
->modalDescription(__('filament-short-url::default.delete_domain_confirmation_desc'))
->modalSubmitActionLabel(__('filament-short-url::default.action_delete_domain'))
->closeModalByClickingAway(false),
])->dropdown(false),
])
->icon('heroicon-m-ellipsis-vertical')
->color('gray')
->iconButton()
->extraAttributes([
'class' => 'action-trigger-btn group flex items-center justify-center gap-2 whitespace-nowrap rounded-lg border text-sm bg-bg-default text-content-emphasis hover:bg-bg-muted focus-visible:border-border-emphasis data-[state=open]:ring-4 data-[state=open]:ring-border-subtle sm:inline-flex h-8 px-1.5 outline-none transition-all duration-200 border-transparent data-[state=open]:border-neutral-500 sm:group-hover/card:data-[state=closed]:border-neutral-200',
'style' => 'width: 32px !important; height: 32px !important; padding: 0px !important; border-radius: 8px !important;',
])
->label(''),
])
->bulkActions([])
->defaultSort('created_at', 'desc')
->emptyState(view('filament-short-url::table.empty-state-domains'));
}
public static function getPages(): array
{
return [
'index' => ListShortUrlCustomDomains::route('/'),
];
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlCustomDomainResource\Pages;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlCustomDomainResource;
use Bjanczak\FilamentShortUrl\Models\ShortUrlCustomDomain;
use Filament\Actions\Action;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ManageRecords;
use Livewire\Attributes\On;
class ListShortUrlCustomDomains extends ManageRecords
{
protected static string $resource = ShortUrlCustomDomainResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make()
->label(__('filament-short-url::default.empty_state_domain_action'))
->modalHeading(__('filament-short-url::default.empty_state_domain_action'))
->icon('heroicon-o-plus')
->size('sm')
->color('primary')
->modalWidth('md')
->modalAutofocus(false)
->closeModalByClickingAway(false)
->after(function ($livewire, $record) {
$livewire->dispatch('mount-dns-setup-modal', recordId: $record->id);
}),
Action::make('dns_setup_modal')
->modalHeading(function (array $arguments) {
$record = ShortUrlCustomDomain::find($arguments['record'] ?? null);
return $record ? __('filament-short-url::default.dns_setup_title', ['domain' => $record->domain]) : '';
})
->modalWidth('2xl')
->modalContent(function (array $arguments) {
$record = ShortUrlCustomDomain::find($arguments['record'] ?? null);
return $record ? view('filament-short-url::table.dns-guide-panel', ['record' => $record]) : null;
})
->modalSubmitAction(false)
->modalCancelAction(false)
->closeModalByClickingAway(false)
->extraAttributes(['class' => 'hidden']),
];
}
#[On('mount-dns-setup-modal')]
public function openDnsSetupModal(int $recordId): void
{
$this->mountAction('dns_setup_modal', ['record' => $recordId]);
}
}

View File

@@ -0,0 +1,158 @@
<?php
namespace Bjanczak\FilamentShortUrl\Filament\Resources;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlFolderResource\Pages\ListShortUrlFolders;
use Bjanczak\FilamentShortUrl\Models\ShortUrlFolder;
use Filament\Actions\ActionGroup;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\Layout\Stack;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Support\Str;
class ShortUrlFolderResource extends Resource
{
protected static ?string $model = ShortUrlFolder::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-folder';
protected static ?int $navigationSort = 53;
protected static ?string $recordTitleAttribute = 'name';
public static function getNavigationLabel(): string
{
return __('filament-short-url::default.folders_navigation_label');
}
public static function getModelLabel(): string
{
return __('filament-short-url::default.folder_resource_title');
}
public static function getPluralModelLabel(): string
{
return __('filament-short-url::default.folders_navigation_label');
}
public static function getNavigationGroup(): string|\UnitEnum|null
{
try {
return ShortUrlResource::getNavigationGroup();
} catch (\Throwable) {
return __('filament-short-url::default.navigation_group');
}
}
public static function getNavigationSort(): ?int
{
try {
return ShortUrlResource::getNavigationSort() + 3;
} catch (\Throwable) {
return static::$navigationSort;
}
}
public static function form(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('name')
->label(__('filament-short-url::default.folder_name'))
->required()
->maxLength(100)
->live(onBlur: true)
->afterStateUpdated(fn ($state, callable $set) => $set('slug', Str::slug($state))),
TextInput::make('slug')
->label(__('filament-short-url::default.folder_slug'))
->required()
->maxLength(100)
->unique('short_url_folders', 'slug', ignoreRecord: true),
Select::make('color')
->label(__('filament-short-url::default.folder_color'))
->allowHtml()
->options([
'gray' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #737373;"></span><span>'.__('filament-short-url::default.color_gray').'</span></span>',
'red' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #ef4444;"></span><span>'.__('filament-short-url::default.color_red').'</span></span>',
'blue' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #3b82f6;"></span><span>'.__('filament-short-url::default.color_blue').'</span></span>',
'green' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #10b981;"></span><span>'.__('filament-short-url::default.color_green').'</span></span>',
'yellow' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #f59e0b;"></span><span>'.__('filament-short-url::default.color_yellow').'</span></span>',
'indigo' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #6366f1;"></span><span>'.__('filament-short-url::default.color_indigo').'</span></span>',
'purple' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #a855f7;"></span><span>'.__('filament-short-url::default.color_purple').'</span></span>',
'pink' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #ec4899;"></span><span>'.__('filament-short-url::default.color_pink').'</span></span>',
])
->default('gray')
->required()
->native(false),
])
->columns(1);
}
public static function table(Table $table): Table
{
return $table
->columns([
Stack::make([
TextColumn::make('name')
->label(__('filament-short-url::default.folder_name'))
->view('filament-short-url::table.folder-card-column')
->counts('shortUrls')
->searchable()
->sortable()
->extraAttributes([
'class' => 'h-full',
]),
])->extraAttributes([
'class' => 'h-full',
]),
])
->contentGrid([
'md' => 2,
'lg' => 3,
])
->recordUrl(
fn (ShortUrlFolder $record): string => ShortUrlResource::getUrl('index', [
'filters' => ['folder' => ['values' => [$record->id]]],
])
)
->recordClasses(fn (): string => 'folder-card relative flex flex-col justify-between rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-5 py-4 transition-all duration-200 h-[120px] sm:h-[132px] group/card hover:shadow-md hover:border-neutral-350 dark:hover:border-neutral-700')
->filters([])
->actions([
ActionGroup::make([
EditAction::make()
->icon('heroicon-o-pencil-square')
->color('gray')
->label(__('filament-short-url::default.action_edit_folder'))
->modalWidth('md'),
DeleteAction::make()
->icon('heroicon-o-trash')
->label(__('filament-short-url::default.action_delete_folder')),
])
->icon('heroicon-m-ellipsis-vertical')
->color('gray')
->iconButton()
->extraAttributes([
'class' => 'action-trigger-btn group flex items-center justify-center gap-2 whitespace-nowrap rounded-lg border text-sm bg-transparent hover:bg-bg-muted data-[state=open]:ring-4 data-[state=open]:ring-border-subtle sm:inline-flex h-8 w-8 outline-none transition-all duration-200 border-transparent data-[state=open]:border-neutral-500 sm:group-hover/card:data-[state=closed]:border-neutral-200',
'style' => 'width: 32px !important; height: 32px !important; padding: 0px !important; border-radius: 8px !important;',
]),
])
->bulkActions([])
->defaultSort('created_at', 'desc')
->emptyState(view('filament-short-url::table.empty-state-folders'));
}
public static function getPages(): array
{
return [
'index' => ListShortUrlFolders::route('/'),
];
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlFolderResource\Pages;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlFolderResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ManageRecords;
class ListShortUrlFolders extends ManageRecords
{
protected static string $resource = ShortUrlFolderResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make()
->label(__('filament-short-url::default.empty_state_folder_action'))
->modalHeading(__('filament-short-url::default.empty_state_folder_action'))
->icon('heroicon-o-plus')
->size('sm')
->color('primary')
->modalWidth('md')
->modalAutofocus(false)
->createAnother(false),
];
}
}

View File

@@ -27,17 +27,17 @@ class ShortUrlPixelResource extends Resource
public static function getNavigationLabel(): string
{
return __('filament-short-url::default.pixels_navigation_label') ?? 'Retargeting Pixels';
return __('filament-short-url::default.pixels_navigation_label');
}
public static function getModelLabel(): string
{
return __('filament-short-url::default.pixel_resource_title') ?? 'Retargeting Pixel';
return __('filament-short-url::default.pixel_resource_title');
}
public static function getPluralModelLabel(): string
{
return __('filament-short-url::default.pixels_navigation_label') ?? 'Retargeting Pixels';
return __('filament-short-url::default.pixels_navigation_label');
}
public static function getNavigationGroup(): string|\UnitEnum|null
@@ -63,13 +63,13 @@ class ShortUrlPixelResource extends Resource
return $schema
->components([
TextInput::make('name')
->label(__('filament-short-url::default.pixel_name') ?? 'Pixel Name')
->label(__('filament-short-url::default.pixel_name'))
->required()
->maxLength(150)
->placeholder('e.g. Meta Ads - Yacht Promo'),
Select::make('type')
->label(__('filament-short-url::default.pixel_type') ?? 'Provider')
->label(__('filament-short-url::default.pixel_type'))
->options([
'meta' => 'Meta / Facebook Pixel',
'google' => 'Google Tag (GA4 / GTM)',
@@ -81,13 +81,13 @@ class ShortUrlPixelResource extends Resource
->native(false),
TextInput::make('pixel_id')
->label(__('filament-short-url::default.pixel_id_label') ?? 'Pixel ID / Tag ID')
->label(__('filament-short-url::default.pixel_id_label'))
->required()
->maxLength(100)
->placeholder('e.g. 1234567890 or G-XXXXXXXXXX'),
Toggle::make('is_active')
->label(__('filament-short-url::default.pixel_status_active') ?? 'Active')
->label(__('filament-short-url::default.pixel_status_active'))
->default(true),
])
->columns(1);
@@ -98,13 +98,13 @@ class ShortUrlPixelResource extends Resource
return $table
->columns([
TextColumn::make('name')
->label(__('filament-short-url::default.pixel_name') ?? 'Pixel Name')
->label(__('filament-short-url::default.pixel_name'))
->searchable()
->sortable()
->weight('bold'),
TextColumn::make('type')
->label(__('filament-short-url::default.pixel_type') ?? 'Provider')
->label(__('filament-short-url::default.pixel_type'))
->badge()
->formatStateUsing(fn (string $state): string => match ($state) {
'meta' => 'Meta',
@@ -124,14 +124,14 @@ class ShortUrlPixelResource extends Resource
}),
TextColumn::make('pixel_id')
->label(__('filament-short-url::default.pixel_id_label') ?? 'ID')
->label(__('filament-short-url::default.pixel_id_label'))
->searchable()
->copyable()
->fontFamily('mono')
->color('gray'),
ToggleColumn::make('is_active')
->label(__('filament-short-url::default.pixel_status_active') ?? 'Active')
->label(__('filament-short-url::default.pixel_status_active'))
->sortable(),
])
->filters([])
@@ -148,7 +148,9 @@ class ShortUrlPixelResource extends Resource
->iconButton()
->tooltip(__('filament-short-url::default.action_delete')),
])
->bulkActions([]);
->bulkActions([])
->defaultSort('created_at', 'desc')
->emptyState(view('filament-short-url::table.empty-state-pixels'));
}
public static function getPages(): array

View File

@@ -14,10 +14,13 @@ class ListShortUrlPixels extends ManageRecords
{
return [
CreateAction::make()
->label(__('filament-short-url::default.empty_state_pixel_action'))
->modalHeading(__('filament-short-url::default.empty_state_pixel_action'))
->icon('heroicon-o-plus')
->size('sm')
->color('primary')
->modalWidth('md'),
->modalWidth('md')
->modalAutofocus(false),
];
}
}

View File

@@ -3,6 +3,7 @@
namespace Bjanczak\FilamentShortUrl\Filament\Resources;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\ListShortUrls;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\ViewShortUrlLiveFeed;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\ViewShortUrlLogs;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\ViewShortUrlStats;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\ShortUrlForm;
@@ -111,6 +112,7 @@ class ShortUrlResource extends Resource
return [
'index' => ListShortUrls::route('/'),
'stats' => ViewShortUrlStats::route('/{record}/stats'),
'stats.live' => ViewShortUrlLiveFeed::route('/{record}/stats/live'),
'stats.logs' => ViewShortUrlLogs::route('/{record}/stats/logs'),
];
}

View File

@@ -10,6 +10,7 @@ use Filament\Actions\Action;
use Filament\Actions\CreateAction;
use Filament\Forms;
use Filament\Resources\Pages\ManageRecords;
use Filament\Schemas\Components\Tabs\Tab;
use Illuminate\Support\HtmlString;
class ListShortUrls extends ManageRecords
@@ -20,13 +21,16 @@ class ListShortUrls extends ManageRecords
{
return [
CreateAction::make()
->label(__('filament-short-url::default.empty_state_action'))
->modalHeading(__('filament-short-url::default.empty_state_action'))
->icon('heroicon-o-plus')
->size('sm')
->color('primary')
->modalWidth('4xl')
->mutateFormDataUsing(function (array $data): array {
->modalAutofocus(false)
->mutateFormDataUsing(function (array $data, ShortUrlService $service): array {
if (empty($data['url_key'])) {
$data['url_key'] = app(ShortUrlService::class)->generateKey();
$data['url_key'] = $service->generateKey();
}
return $data;
@@ -68,16 +72,16 @@ class ListShortUrls extends ManageRecords
$encoded = urlencode($shortUrl);
$eid = 'fsu_'.substr(md5($shortUrl), 0, 8);
$successTitle = __('filament-short-url::default.success_modal_title') ?? 'Your link & QR code are ready!';
$successSubtitle = __('filament-short-url::default.success_modal_subtitle') ?? 'Time to get some clicks 🎉';
$successHelper = __('filament-short-url::default.success_modal_helper') ?? 'Copy and share manually or choose a platform.';
$downloadSvgText = __('filament-short-url::default.qr_download_svg') ?? 'Download SVG';
$downloadPngText = __('filament-short-url::default.qr_download_png') ?? 'Download PNG';
$closeButtonText = __('filament-short-url::default.close_button') ?? 'Close';
$copyLinkText = __('filament-short-url::default.action_copy') ?? 'Copy link';
$qrCodeText = __('filament-short-url::default.action_qr') ?? 'QR Code';
$openLinkText = __('filament-short-url::default.open_link') ?? 'Open link';
$dontShowAgainText = __('filament-short-url::default.dont_show_again') ?? "Don't show sharing options after creating a link";
$successTitle = __('filament-short-url::default.success_modal_title');
$successSubtitle = __('filament-short-url::default.success_modal_subtitle');
$successHelper = __('filament-short-url::default.success_modal_helper');
$downloadSvgText = __('filament-short-url::default.qr_download_svg');
$downloadPngText = __('filament-short-url::default.qr_download_png');
$closeButtonText = __('filament-short-url::default.close_button');
$copyLinkText = __('filament-short-url::default.action_copy');
$qrCodeText = __('filament-short-url::default.action_qr');
$openLinkText = __('filament-short-url::default.open_link');
$dontShowAgainText = __('filament-short-url::default.dont_show_again');
$record = $recordId ? ShortUrl::find($recordId) : null;
$qrDefaults = $record ? $record->getQrOptions() : config('filament-short-url.qr_defaults', []);
@@ -392,4 +396,14 @@ HTML;
ShortUrlGlobalOverview::class,
];
}
public function getTabs(): array
{
return [
'active' => Tab::make(__('filament-short-url::default.tab_active_links'))
->modifyQueryUsing(fn ($query) => $query->where('is_archived', false)),
'archived' => Tab::make(__('filament-short-url::default.tab_archived_links'))
->modifyQueryUsing(fn ($query) => $query->where('is_archived', true)),
];
}
}

View File

@@ -0,0 +1,179 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs;
use Bjanczak\FilamentShortUrl\Services\SafeBrowsingService;
use Filament\Actions\Action;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
class AdvancedTab
{
/**
* Build the advanced & security settings form tab.
*/
public static function make(): Tab
{
return Tab::make(__('filament-short-url::default.settings_tab_advanced'))
->key('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()
->integer()
->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()
->integer()
->minValue(1)
->suffix('s')
->required()
->visible(fn (Get $get): bool => (bool) $get('rate_limiting_enabled')),
]),
Section::make(__('filament-short-url::default.settings_section_security_v2'))
->description(__('filament-short-url::default.settings_section_security_v2_desc'))
->columns(2)
->schema([
Toggle::make('vpn_detection_enabled')
->label(__('filament-short-url::default.settings_vpn_detection_enabled'))
->helperText(__('filament-short-url::default.settings_vpn_detection_enabled_helper'))
->default(false)
->inline(false)
->live()
->columnSpanFull(),
Select::make('vpn_detection_driver')
->label(__('filament-short-url::default.settings_vpn_driver'))
->helperText(__('filament-short-url::default.settings_vpn_driver_helper'))
->options([
'ip-api' => __('filament-short-url::default.settings_vpn_driver_ipapi'),
'vpnapi' => __('filament-short-url::default.settings_vpn_driver_vpnapi'),
])
->default('ip-api')
->live()
->required()
->visible(fn (Get $get): bool => (bool) $get('vpn_detection_enabled')),
TextInput::make('vpnapi_key')
->label(__('filament-short-url::default.settings_vpnapi_key'))
->helperText(__('filament-short-url::default.settings_vpnapi_key_helper'))
->password()
->revealable()
->placeholder('••••••••••••••••••••')
->visible(fn (Get $get): bool => (bool) $get('vpn_detection_enabled') && $get('vpn_detection_driver') === 'vpnapi'),
Select::make('vpn_block_action')
->label(__('filament-short-url::default.settings_vpn_block_action'))
->helperText(__('filament-short-url::default.settings_vpn_block_action_helper'))
->options([
'flag_only' => __('filament-short-url::default.settings_vpn_block_flag_only'),
'block_with_403' => __('filament-short-url::default.settings_vpn_block_block_403'),
])
->default('flag_only')
->required()
->visible(fn (Get $get): bool => (bool) $get('vpn_detection_enabled')),
Toggle::make('safe_browsing_enabled')
->label(__('filament-short-url::default.settings_safe_browsing_enabled'))
->helperText(__('filament-short-url::default.settings_safe_browsing_enabled_helper'))
->default(false)
->inline(false)
->live()
->columnSpanFull(),
TextInput::make('google_safe_browsing_api_key')
->label(__('filament-short-url::default.settings_safe_browsing_api_key'))
->helperText(__('filament-short-url::default.settings_safe_browsing_api_key_helper'))
->password()
->revealable()
->placeholder('AIza••••••••••••••••••')
->columnSpanFull()
->suffixAction(
Action::make('testSafeBrowsing')
->label(__('filament-short-url::default.settings_safe_browsing_test'))
->icon('heroicon-o-signal')
->color('gray')
->action(function (Get $get, SafeBrowsingService $svc): void {
$key = trim($get('google_safe_browsing_api_key') ?? '');
if (empty($key)) {
Notification::make()
->title(__('filament-short-url::default.settings_safe_browsing_test_empty'))
->warning()->send();
return;
}
try {
$safe = $svc->isSafeWithKey('https://google.com', $key);
Notification::make()
->title($safe
? __('filament-short-url::default.settings_safe_browsing_test_ok')
: __('filament-short-url::default.settings_safe_browsing_test_fail'))
->color($safe ? 'success' : 'danger')
->send();
} catch (\Throwable $e) {
Notification::make()
->title(__('filament-short-url::default.settings_safe_browsing_test_error'))
->body($e->getMessage())
->danger()->send();
}
})
)
->visible(fn (Get $get): bool => (bool) $get('safe_browsing_enabled')),
]),
]);
}
}

View File

@@ -0,0 +1,83 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
class DeepLinkingTab
{
/**
* Build the deep linking settings form tab.
*/
public static function make(): Tab
{
return Tab::make(__('filament-short-url::default.settings_tab_deep_linking'))
->key('deep-linking')
->icon('heroicon-o-device-phone-mobile')
->schema([
Section::make(__('filament-short-url::default.settings_section_deep_linking'))
->schema([
Toggle::make('deep_linking_enabled')
->label(__('filament-short-url::default.settings_deep_linking_enabled'))
->helperText(__('filament-short-url::default.settings_deep_linking_enabled_helper'))
->default(false)
->inline(false)
->live(),
Textarea::make('aasa_json')
->label(__('filament-short-url::default.settings_aasa_json'))
->helperText(__('filament-short-url::default.settings_aasa_json_helper'))
->nullable()
->visible(fn (Get $get): bool => (bool) $get('deep_linking_enabled'))
->columnSpanFull()
->extraInputAttributes(['style' => 'font-family: monospace;'])
->rows(8)
->rules([
function () {
return function (string $attribute, $value, \Closure $fail) {
if (empty($value)) {
return;
}
json_decode($value);
if (json_last_error() !== JSON_ERROR_NONE) {
$fail(__('filament-short-url::default.validation_invalid_json'));
}
};
},
]),
Textarea::make('assetlinks_json')
->label(__('filament-short-url::default.settings_assetlinks_json'))
->helperText(__('filament-short-url::default.settings_assetlinks_json_helper'))
->nullable()
->visible(fn (Get $get): bool => (bool) $get('deep_linking_enabled'))
->columnSpanFull()
->extraInputAttributes(['style' => 'font-family: monospace;'])
->rows(8)
->rules([
function () {
return function (string $attribute, $value, \Closure $fail) {
if (empty($value)) {
return;
}
json_decode($value);
if (json_last_error() !== JSON_ERROR_NONE) {
$fail(__('filament-short-url::default.validation_invalid_json'));
}
};
},
]),
]),
]);
}
}

View File

@@ -0,0 +1,130 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
class DeveloperTab
{
/**
* Build the developer API and webhooks settings form tab.
*/
public static function make(): Tab
{
return Tab::make(__('filament-short-url::default.settings_tab_developer'))
->key('developer')
->icon('heroicon-o-cpu-chip')
->schema([
Section::make(__('filament-short-url::default.settings_section_rest_api'))
->schema([
Toggle::make('api_enabled')
->label(__('filament-short-url::default.settings_api_enabled'))
->helperText(__('filament-short-url::default.settings_api_enabled_helper'))
->default(false)
->inline(false)
->columnSpanFull()
->live(),
]),
Section::make(__('filament-short-url::default.settings_section_api_keys'))
->description(__('filament-short-url::default.settings_api_keys_description'))
->visible(fn (Get $get): bool => (bool) $get('api_enabled'))
->schema([
Repeater::make('api_keys')
->label(__('filament-short-url::default.settings_api_keys'))
->schema([
TextInput::make('name')
->label(__('filament-short-url::default.api_key_name'))
->required(),
TextInput::make('key')
->label(__('filament-short-url::default.api_key'))
->disabled()
->dehydrated()
->default(fn () => 'sh_key_'.bin2hex(random_bytes(16))),
Select::make('scope')
->label(__('filament-short-url::default.api_key_scope'))
->options([
'links:read-write' => __('filament-short-url::default.api_key_scope_read_write'),
'links:read-only' => __('filament-short-url::default.api_key_scope_read_only'),
])
->default('links:read-write')
->required(),
Select::make('rate_limit')
->label(__('filament-short-url::default.api_key_rate_limit'))
->options([
'60' => __('filament-short-url::default.api_key_rate_limit_rpm', ['count' => 60]),
'120' => __('filament-short-url::default.api_key_rate_limit_rpm', ['count' => 120]),
'300' => __('filament-short-url::default.api_key_rate_limit_rpm', ['count' => 300]),
'0' => __('filament-short-url::default.api_key_rate_limit_unlimited'),
])
->default('60')
->required(),
Toggle::make('is_active')
->label(__('filament-short-url::default.active'))
->default(true),
])
->columns(5)
->default([]),
]),
Section::make(__('filament-short-url::default.settings_section_global_webhook'))
->schema([
Toggle::make('global_webhook_enabled')
->label(__('filament-short-url::default.settings_global_webhook_enabled'))
->helperText(__('filament-short-url::default.settings_global_webhook_enabled_helper'))
->live()
->inline(false)
->afterStateUpdated(function (bool $state, $set) {
if (! $state) {
$set('global_webhook_url', null);
$set('webhook_events', ['visited']);
$set('webhook_signing_secret', null);
}
}),
TextInput::make('global_webhook_url')
->label(__('filament-short-url::default.settings_global_webhook_url'))
->helperText(__('filament-short-url::default.settings_global_webhook_url_helper'))
->url()
->nullable()
->columnSpanFull()
->visible(fn (Get $get): bool => (bool) $get('global_webhook_enabled')),
TextInput::make('webhook_signing_secret')
->label('Webhook Signing Secret')
->helperText('If configured, outgoing webhook requests will include the HMAC signature in X-ShortUrl-Signature.')
->password()
->revealable()
->placeholder('••••••••••••••••••••')
->columnSpanFull()
->visible(fn (Get $get): bool => (bool) $get('global_webhook_enabled')),
Select::make('webhook_events')
->label(__('filament-short-url::default.settings_webhook_events'))
->helperText(__('filament-short-url::default.settings_webhook_events_helper'))
->multiple()
->options([
'visited' => __('filament-short-url::default.webhook_event_visited'),
'created' => __('filament-short-url::default.webhook_event_created'),
'expired' => __('filament-short-url::default.webhook_event_expired'),
'limit_reached' => __('filament-short-url::default.webhook_event_limit_reached'),
])
->default(['visited'])
->columnSpanFull()
->visible(fn (Get $get): bool => (bool) $get('global_webhook_enabled')),
]),
]);
}
}

View File

@@ -0,0 +1,107 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs;
use Filament\Actions\Action;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Schemas\Components\Actions;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
use Illuminate\Support\Facades\Http;
class Ga4Tab
{
/**
* Build the GA4 settings form tab.
*/
public static function make(): Tab
{
return Tab::make(__('filament-short-url::default.settings_tab_ga4'))
->key('ga4')
->icon('heroicon-o-chart-bar')
->schema([
Section::make(__('filament-short-url::default.settings_section_ga4'))
->description(__('filament-short-url::default.settings_ga4_description'))
->schema([
TextInput::make('ga4_api_secret')
->label(__('filament-short-url::default.settings_ga4_api_secret'))
->helperText(__('filament-short-url::default.settings_ga4_api_secret_helper'))
->password()
->revealable()
->placeholder('••••••••••••••••••••'),
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'),
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 {
$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'] ?? [];
$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();
}
}),
]),
]),
]);
}
}

View File

@@ -0,0 +1,160 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
use Illuminate\Support\HtmlString;
class GeneralTab
{
/**
* Build the general settings form tab.
*/
public static function make(): Tab
{
return Tab::make(__('filament-short-url::default.settings_tab_general'))
->key('general')
->icon('heroicon-o-link')
->schema([
Section::make(__('filament-short-url::default.settings_section_routing'))
->columns(2)
->schema([
TextInput::make('site_name')
->label(__('filament-short-url::default.settings_site_name'))
->helperText(__('filament-short-url::default.settings_site_name_helper'))
->nullable()
->maxLength(100)
->columnSpanFull(),
TextInput::make('route_prefix')
->label(__('filament-short-url::default.settings_route_prefix'))
->helperText(__('filament-short-url::default.settings_route_prefix_helper'))
->prefix(new HtmlString('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" style="display: inline-block; vertical-align: middle; margin-right: 6px; margin-top: -3px; width: 15px; height: 15px;" class="text-emerald-600 dark:text-emerald-500"><path fill-rule="evenodd" d="M10 1a4.5 4.5 0 00-4.5 4.5V9H5a2 2 0 00-2 2v7a2 2 0 00 2 2h10a2 2 0 00 2-2v-7a2 2 0 00-2-2h-.5V5.5A4.5 4.5 0 00 10 1zm3 8V5.5a3 3 0 10-6 0V9h6z" clip-rule="evenodd" /></svg>https://'.request()->getHost().'/'))
->nullable()
->alphaDash()
->maxLength(20),
Select::make('redirect_status_code')
->label(__('filament-short-url::default.redirect_code'))
->helperText(__('filament-short-url::default.settings_redirect_code_helper'))
->options([
302 => __('filament-short-url::default.redirect_code_302'),
301 => __('filament-short-url::default.redirect_code_301'),
])
->required(),
Toggle::make('lock_url_key')
->label(__('filament-short-url::default.settings_lock_url_key'))
->helperText(__('filament-short-url::default.settings_lock_url_key_helper'))
->inline(false),
Toggle::make('disable_default_domain')
->label(__('filament-short-url::default.settings_disable_default_domain'))
->helperText(__('filament-short-url::default.settings_disable_default_domain_helper'))
->inline(false),
TextInput::make('key_length')
->label(__('filament-short-url::default.settings_key_length'))
->helperText(__('filament-short-url::default.settings_key_length_helper'))
->rules(['required', 'integer', 'between:3,20'])
->extraInputAttributes([
'maxlength' => 2,
'oninput' => "this.value = this.value.replace(/[^0-9]/g, ''); if(this.value !== '') { let val = parseInt(this.value); if(val > 20) this.value = 20; }",
'onblur' => "if(this.value !== '') { let val = parseInt(this.value); if(val < 3) this.value = 3; if(val > 20) this.value = 20; }",
])
->required(),
TextInput::make('cache_ttl')
->label(__('filament-short-url::default.settings_cache_ttl'))
->helperText(__('filament-short-url::default.settings_cache_ttl_helper'))
->numeric()
->integer()
->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)
->live(),
Placeholder::make('trust_cdn_headers_info')
->content(function () {
$html = __('filament-short-url::default.settings_trust_cdn_headers_info_callout');
return new HtmlString($html);
})
->visible(fn (Get $get): bool => (bool) $get('trust_cdn_headers'))
->columnSpanFull(),
]),
Section::make(__('filament-short-url::default.settings_section_queue'))
->columns(2)
->schema([
Select::make('queue_connection')
->label(__('filament-short-url::default.settings_queue_connection'))
->helperText(__('filament-short-url::default.settings_queue_connection_helper'))
->options(function (): array {
$connections = array_keys(config('queue.connections', []));
return array_combine($connections, $connections) ?: [
'sync' => 'sync',
'database' => 'database',
'redis' => 'redis',
];
})
->required()
->live(),
TextInput::make('queue_name')
->label(__('filament-short-url::default.settings_queue_name'))
->helperText(__('filament-short-url::default.settings_queue_name_helper'))
->default('default')
->required(fn (Get $get): bool => $get('queue_connection') !== 'sync')
->visible(fn (Get $get): bool => $get('queue_connection') !== 'sync'),
Placeholder::make('queue_worker_info')
->content(function (Get $get) {
$queueName = $get('queue_name') ?: 'default';
$html = __('filament-short-url::default.settings_queue_worker_info', ['queue' => $queueName]);
return new HtmlString($html);
})
->visible(fn (Get $get): bool => $get('queue_connection') !== 'sync')
->columnSpanFull(),
]),
Section::make(__('filament-short-url::default.settings_section_buffering'))
->schema([
Toggle::make('counter_buffering_enabled')
->label(__('filament-short-url::default.settings_buffering_enabled'))
->helperText(__('filament-short-url::default.settings_buffering_helper'))
->inline(false)
->live(),
Placeholder::make('counter_buffering_info')
->content(function () {
$html = __('filament-short-url::default.settings_buffering_worker_info');
return new HtmlString($html);
})
->visible(fn (Get $get): bool => (bool) $get('counter_buffering_enabled'))
->columnSpanFull(),
]),
]);
}
}

View File

@@ -0,0 +1,154 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs;
use Filament\Actions\Action;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Schemas\Components\Actions;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
use Illuminate\Support\HtmlString;
class GeoIpTab
{
/**
* Build the geo-ip settings form tab.
*/
public static function make(): Tab
{
return Tab::make(__('filament-short-url::default.settings_tab_geoip'))
->key('geoip')
->icon('heroicon-o-globe-alt')
->schema([
Section::make(__('filament-short-url::default.settings_section_geoip'))
->columns(2)
->schema([
Toggle::make('geo_ip_enabled')
->label(__('filament-short-url::default.settings_geoip_enabled'))
->helperText(__('filament-short-url::default.settings_geoip_enabled_helper'))
->columnSpanFull()
->inline(false)
->live(),
Select::make('geo_ip_driver')
->label(__('filament-short-url::default.settings_geoip_driver'))
->helperText(__('filament-short-url::default.settings_geoip_driver_helper'))
->options([
'headers' => __('filament-short-url::default.settings_geoip_driver_headers'),
'maxmind' => __('filament-short-url::default.settings_geoip_driver_maxmind'),
'ip-api' => __('filament-short-url::default.settings_geoip_driver_ipapi'),
])
->required()
->live()
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled')),
Placeholder::make('geoip_headers_warning')
->content(function () {
$html = __('filament-short-url::default.settings_geoip_headers_warning');
return new HtmlString($html);
})
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled') &&
$get('geo_ip_driver') === 'headers' &&
! (bool) $get('trust_cdn_headers')
)
->columnSpanFull(),
TextInput::make('geo_ip_cache_ttl')
->label(__('filament-short-url::default.settings_geoip_cache_ttl'))
->helperText(__('filament-short-url::default.settings_geoip_cache_ttl_helper'))
->numeric()
->integer()
->minValue(0)
->maxValue(31536000)
->suffix('s')
->required()
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled')),
TextInput::make('geo_ip_stats_cache_ttl')
->label(__('filament-short-url::default.settings_geoip_stats_cache_ttl'))
->helperText(__('filament-short-url::default.settings_geoip_stats_cache_ttl_helper'))
->numeric()
->integer()
->minValue(0)
->maxValue(86400)
->suffix('s')
->required()
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled')),
TextInput::make('geo_ip_timeout')
->label(__('filament-short-url::default.settings_geoip_timeout'))
->helperText(__('filament-short-url::default.settings_geoip_timeout_helper'))
->numeric()
->integer()
->minValue(1)
->maxValue(30)
->suffix('s')
->required()
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled') && $get('geo_ip_driver') === 'ip-api'),
Placeholder::make('maxmind_info')
->content(function () {
$html = __('filament-short-url::default.settings_maxmind_info_callout');
return new HtmlString($html);
})
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled') && $get('geo_ip_driver') === 'maxmind')
->columnSpanFull(),
TextInput::make('maxmind_database_path')
->label(__('filament-short-url::default.settings_maxmind_path'))
->helperText(__('filament-short-url::default.settings_maxmind_path_helper'))
->columnSpanFull()
->placeholder('/var/www/html/database/geoip/GeoLite2-Country.mmdb')
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled') && $get('geo_ip_driver') === 'maxmind'),
Actions::make([
Action::make('verifyMaxmindPath')
->label(__('filament-short-url::default.settings_maxmind_verify'))
->icon('heroicon-o-check-circle')
->color('gray')
->action(function (Get $get): void {
$path = trim($get('maxmind_database_path') ?? '');
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'),
]),
]);
}
}

View File

@@ -0,0 +1,101 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs;
use Filament\Forms\Components\ColorPicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
class QrDefaultsTab
{
/**
* Build the QR defaults settings form tab.
*/
public static function make(): Tab
{
return Tab::make(__('filament-short-url::default.settings_tab_qr'))
->key('qr-defaults')
->icon('heroicon-o-qr-code')
->schema([
Section::make(__('filament-short-url::default.settings_section_qr_defaults'))
->description(__('filament-short-url::default.settings_section_qr_defaults_helper'))
->columns(2)
->schema([
TextInput::make('qr_size')
->label(__('filament-short-url::default.settings_qr_size'))
->numeric()
->integer()
->minValue(100)
->maxValue(2000)
->required(),
Select::make('qr_margin')
->label(__('filament-short-url::default.settings_qr_margin'))
->options(array_combine(range(0, 10), range(0, 10)))
->required(),
Select::make('qr_dot_style')
->label(__('filament-short-url::default.settings_qr_dot_style'))
->options([
'square' => __('filament-short-url::default.qr_option_square'),
'dots' => __('filament-short-url::default.qr_option_dots'),
'rounded' => __('filament-short-url::default.qr_option_rounded'),
'classy' => __('filament-short-url::default.qr_option_classy'),
'classy-rounded' => __('filament-short-url::default.qr_option_classy_rounded'),
'extra-rounded' => __('filament-short-url::default.qr_option_extra_rounded'),
])
->required(),
ColorPicker::make('qr_foreground_color')
->label(__('filament-short-url::default.settings_qr_foreground_color'))
->regex('/^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})\b$/')
->placeholder('#000000')
->required(),
ColorPicker::make('qr_background_color')
->label(__('filament-short-url::default.settings_qr_background_color'))
->regex('/^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})\b$/')
->placeholder('#ffffff')
->required(),
Toggle::make('qr_gradient_enabled')
->label(__('filament-short-url::default.settings_qr_gradient_enabled'))
->live()
->inline(false),
ColorPicker::make('qr_gradient_from')
->label(__('filament-short-url::default.settings_qr_gradient_from'))
->regex('/^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})\b$/')
->placeholder('#4f46e5')
->required(fn (Get $get): bool => (bool) $get('qr_gradient_enabled'))
->visible(fn (Get $get): bool => (bool) $get('qr_gradient_enabled')),
ColorPicker::make('qr_gradient_to')
->label(__('filament-short-url::default.settings_qr_gradient_to'))
->regex('/^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})\b$/')
->placeholder('#06b6d4')
->required(fn (Get $get): bool => (bool) $get('qr_gradient_enabled'))
->visible(fn (Get $get): bool => (bool) $get('qr_gradient_enabled')),
Select::make('qr_gradient_type')
->label(__('filament-short-url::default.settings_qr_gradient_type'))
->options([
'linear' => __('filament-short-url::default.qr_gradient_linear'),
'radial' => __('filament-short-url::default.qr_gradient_radial'),
])
->required(fn (Get $get): bool => (bool) $get('qr_gradient_enabled'))
->visible(fn (Get $get): bool => (bool) $get('qr_gradient_enabled')),
]),
]);
}
}

View File

@@ -0,0 +1,86 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
class TrackingDefaultsTab
{
/**
* Build the tracking defaults form tab.
*/
public static function make(): Tab
{
return Tab::make(__('filament-short-url::default.settings_tab_tracking_defaults'))
->key('tracking-defaults')
->icon('heroicon-o-chart-bar')
->schema([
Section::make(__('filament-short-url::default.settings_section_tracking_defaults'))
->description(__('filament-short-url::default.settings_section_tracking_defaults_helper'))
->schema([
Toggle::make('tracking_enabled')
->label(__('filament-short-url::default.settings_track_visits_default'))
->live()
->inline(false)
->columnSpanFull(),
Toggle::make('tracking_anonymize_ips')
->label(__('filament-short-url::default.settings_track_anonymize_ips'))
->helperText(__('filament-short-url::default.settings_track_anonymize_ips_helper'))
->inline(false)
->columnSpanFull()
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
Toggle::make('tracking_fields_ip_address')
->label(__('filament-short-url::default.settings_track_ip_default'))
->inline(false)
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
Toggle::make('tracking_fields_browser')
->label(__('filament-short-url::default.settings_track_browser_default'))
->inline(false)
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
Toggle::make('tracking_fields_browser_version')
->label(__('filament-short-url::default.settings_track_browser_version_default'))
->inline(false)
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
Toggle::make('tracking_fields_operating_system')
->label(__('filament-short-url::default.settings_track_os_default'))
->inline(false)
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
Toggle::make('tracking_fields_operating_system_version')
->label(__('filament-short-url::default.settings_track_os_version_default'))
->inline(false)
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
Toggle::make('tracking_fields_browser_language')
->label(__('filament-short-url::default.settings_track_browser_language_default'))
->inline(false)
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
Toggle::make('tracking_fields_referer_url')
->label(__('filament-short-url::default.settings_track_referer_default'))
->inline(false)
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
Toggle::make('tracking_fields_device_type')
->label(__('filament-short-url::default.settings_track_device_type_default'))
->inline(false)
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
])
->columns(4),
]);
}
}

View File

@@ -3,30 +3,25 @@
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs\AdvancedTab;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs\DeepLinkingTab;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs\DeveloperTab;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs\Ga4Tab;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs\GeneralTab;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs\GeoIpTab;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs\QrDefaultsTab;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs\TrackingDefaultsTab;
use Bjanczak\FilamentShortUrl\FilamentShortUrlPlugin;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Bjanczak\FilamentShortUrl\Services\SafeBrowsingService;
use Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager;
use Filament\Actions\Action;
use Filament\Forms\Components\ColorPicker;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Actions;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\HtmlString;
class ShortUrlSettingsPage extends Page implements HasForms
@@ -41,7 +36,7 @@ class ShortUrlSettingsPage extends Page implements HasForms
public static function getNavigationLabel(): string
{
return __('filament-short-url::default.settings_nav_label') ?? 'Settings';
return __('filament-short-url::default.settings_nav_label');
}
public static function getNavigationGroup(): string|\UnitEnum|null
@@ -56,9 +51,9 @@ class ShortUrlSettingsPage extends Page implements HasForms
public static function getNavigationSort(): ?int
{
try {
return ShortUrlResource::getNavigationSort() + 2;
return ShortUrlResource::getNavigationSort() + 99;
} catch (\Throwable) {
return 52;
return 99;
}
}
@@ -86,10 +81,8 @@ class ShortUrlSettingsPage extends Page implements HasForms
public ?array $data = [];
public function mount(): void
public function mount(ShortUrlSettingsManager $mgr): void
{
$mgr = app(ShortUrlSettingsManager::class);
$aasa = $mgr->get('aasa_json');
if (! empty($aasa)) {
$decoded = json_decode($aasa, true);
@@ -108,6 +101,8 @@ class ShortUrlSettingsPage extends Page implements HasForms
$this->form->fill([
'route_prefix' => $mgr->get('route_prefix', 's'),
'lock_url_key' => $mgr->get('lock_url_key', false),
'disable_default_domain' => $mgr->get('disable_default_domain', false),
'redirect_status_code' => $mgr->get('redirect_status_code', 302),
'key_length' => $mgr->get('key_length', 6),
'cache_ttl' => $mgr->get('cache_ttl', 3600),
@@ -129,6 +124,7 @@ class ShortUrlSettingsPage extends Page implements HasForms
'rate_limiting_max_attempts' => $mgr->get('rate_limiting_max_attempts', 60),
'rate_limiting_decay_seconds' => $mgr->get('rate_limiting_decay_seconds', 60),
'tracking_enabled' => $mgr->get('tracking_enabled', true),
'tracking_anonymize_ips' => $mgr->get('tracking_anonymize_ips', false),
'tracking_fields_ip_address' => $mgr->get('tracking_fields_ip_address', true),
'tracking_fields_browser' => $mgr->get('tracking_fields_browser', true),
'tracking_fields_browser_version' => $mgr->get('tracking_fields_browser_version', true),
@@ -175,781 +171,24 @@ class ShortUrlSettingsPage extends Page implements HasForms
Tabs::make('ShortUrlSettings')
->persistTabInQueryString()
->tabs([
// ── General ──────────────────────────────────────────
Tab::make(__('filament-short-url::default.settings_tab_general'))
->icon('heroicon-o-link')
->schema([
Section::make(__('filament-short-url::default.settings_section_routing'))
->columns(2)
->schema([
TextInput::make('site_name')
->label(__('filament-short-url::default.settings_site_name'))
->helperText(__('filament-short-url::default.settings_site_name_helper'))
->nullable()
->maxLength(100)
->columnSpanFull(),
TextInput::make('route_prefix')
->label(__('filament-short-url::default.settings_route_prefix'))
->helperText(__('filament-short-url::default.settings_route_prefix_helper'))
->prefix(new HtmlString('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" style="display: inline-block; vertical-align: middle; margin-right: 6px; margin-top: -3px; width: 15px; height: 15px;" class="text-emerald-600 dark:text-emerald-500"><path fill-rule="evenodd" d="M10 1a4.5 4.5 0 00-4.5 4.5V9H5a2 2 0 00-2 2v7a2 2 0 00 2 2h10a2 2 0 00 2-2v-7a2 2 0 00-2-2h-.5V5.5A4.5 4.5 0 00 10 1zm3 8V5.5a3 3 0 10-6 0V9h6z" clip-rule="evenodd" /></svg>https://'.request()->getHost().'/'))
->nullable()
->alphaDash()
->maxLength(20),
Select::make('redirect_status_code')
->label(__('filament-short-url::default.redirect_code'))
->helperText(__('filament-short-url::default.settings_redirect_code_helper'))
->options([
302 => __('filament-short-url::default.redirect_code_302'),
301 => __('filament-short-url::default.redirect_code_301'),
])
->required(),
TextInput::make('key_length')
->label(__('filament-short-url::default.settings_key_length'))
->helperText(__('filament-short-url::default.settings_key_length_helper'))
->rules(['required', 'integer', 'between:3,20'])
->extraInputAttributes([
'maxlength' => 2,
'oninput' => "this.value = this.value.replace(/[^0-9]/g, ''); if(this.value !== '') { let val = parseInt(this.value); if(val > 20) this.value = 20; }",
'onblur' => "if(this.value !== '') { let val = parseInt(this.value); if(val < 3) this.value = 3; if(val > 20) this.value = 20; }",
])
->required(),
TextInput::make('cache_ttl')
->label(__('filament-short-url::default.settings_cache_ttl'))
->helperText(__('filament-short-url::default.settings_cache_ttl_helper'))
->numeric()
->integer()
->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)
->live(),
Placeholder::make('trust_cdn_headers_info')
->content(function () {
$html = __('filament-short-url::default.settings_trust_cdn_headers_info_callout');
return new HtmlString($html);
})
->visible(fn (Get $get): bool => (bool) $get('trust_cdn_headers'))
->columnSpanFull(),
]),
Section::make(__('filament-short-url::default.settings_section_queue'))
->columns(2)
->schema([
Select::make('queue_connection')
->label(__('filament-short-url::default.settings_queue_connection'))
->helperText(__('filament-short-url::default.settings_queue_connection_helper'))
->options(function (): array {
$connections = array_keys(config('queue.connections', []));
return array_combine($connections, $connections) ?: [
'sync' => 'sync',
'database' => 'database',
'redis' => 'redis',
];
})
->required()
->live(),
TextInput::make('queue_name')
->label(__('filament-short-url::default.settings_queue_name'))
->helperText(__('filament-short-url::default.settings_queue_name_helper'))
->default('default')
->required(fn (Get $get): bool => $get('queue_connection') !== 'sync')
->visible(fn (Get $get): bool => $get('queue_connection') !== 'sync'),
Placeholder::make('queue_worker_info')
->content(function (Get $get) {
$queueName = $get('queue_name') ?: 'default';
$html = __('filament-short-url::default.settings_queue_worker_info', ['queue' => $queueName]);
return new HtmlString($html);
})
->visible(fn (Get $get): bool => $get('queue_connection') !== 'sync')
->columnSpanFull(),
]),
Section::make(__('filament-short-url::default.settings_section_buffering'))
->schema([
Toggle::make('counter_buffering_enabled')
->label(__('filament-short-url::default.settings_buffering_enabled'))
->helperText(__('filament-short-url::default.settings_buffering_helper'))
->inline(false)
->live(),
Placeholder::make('counter_buffering_info')
->content(function () {
$html = __('filament-short-url::default.settings_buffering_worker_info');
return new HtmlString($html);
})
->visible(fn (Get $get): bool => (bool) $get('counter_buffering_enabled'))
->columnSpanFull(),
]),
]),
// ── Geo-IP ───────────────────────────────────────────
Tab::make(__('filament-short-url::default.settings_tab_geoip'))
->icon('heroicon-o-globe-alt')
->schema([
Section::make(__('filament-short-url::default.settings_section_geoip'))
->columns(2)
->schema([
Toggle::make('geo_ip_enabled')
->label(__('filament-short-url::default.settings_geoip_enabled'))
->helperText(__('filament-short-url::default.settings_geoip_enabled_helper'))
->columnSpanFull()
->inline(false)
->live(),
// ── Driver (only when geo-ip is on) ──
Select::make('geo_ip_driver')
->label(__('filament-short-url::default.settings_geoip_driver'))
->helperText(__('filament-short-url::default.settings_geoip_driver_helper'))
->options([
'headers' => __('filament-short-url::default.settings_geoip_driver_headers'),
'maxmind' => __('filament-short-url::default.settings_geoip_driver_maxmind'),
'ip-api' => __('filament-short-url::default.settings_geoip_driver_ipapi'),
])
->required()
->live()
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled')),
Placeholder::make('geoip_headers_warning')
->content(function () {
$html = __('filament-short-url::default.settings_geoip_headers_warning');
return new HtmlString($html);
})
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled') &&
$get('geo_ip_driver') === 'headers' &&
! (bool) $get('trust_cdn_headers')
)
->columnSpanFull(),
// ── Cache TTL (only when geo-ip is on) ──
TextInput::make('geo_ip_cache_ttl')
->label(__('filament-short-url::default.settings_geoip_cache_ttl'))
->helperText(__('filament-short-url::default.settings_geoip_cache_ttl_helper'))
->numeric()
->integer()
->minValue(0)
->maxValue(31536000)
->suffix('s')
->required()
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled')),
// ── Stats Cache TTL (only when geo-ip is on) ──
TextInput::make('geo_ip_stats_cache_ttl')
->label(__('filament-short-url::default.settings_geoip_stats_cache_ttl'))
->helperText(__('filament-short-url::default.settings_geoip_stats_cache_ttl_helper'))
->numeric()
->integer()
->minValue(0)
->maxValue(86400)
->suffix('s')
->required()
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled')),
// ── Timeout (only for ip-api driver) ──
TextInput::make('geo_ip_timeout')
->label(__('filament-short-url::default.settings_geoip_timeout'))
->helperText(__('filament-short-url::default.settings_geoip_timeout_helper'))
->numeric()
->integer()
->minValue(1)
->maxValue(30)
->suffix('s')
->required()
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled') && $get('geo_ip_driver') === 'ip-api'),
// ── MaxMind path (only for maxmind driver) ──
Placeholder::make('maxmind_info')
->content(function () {
$html = __('filament-short-url::default.settings_maxmind_info_callout');
return new HtmlString($html);
})
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled') && $get('geo_ip_driver') === 'maxmind')
->columnSpanFull(),
TextInput::make('maxmind_database_path')
->label(__('filament-short-url::default.settings_maxmind_path'))
->helperText(__('filament-short-url::default.settings_maxmind_path_helper'))
->columnSpanFull()
->placeholder('/var/www/html/database/geoip/GeoLite2-Country.mmdb')
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled') && $get('geo_ip_driver') === 'maxmind'),
Actions::make([
Action::make('verifyMaxmindPath')
->label(__('filament-short-url::default.settings_maxmind_verify'))
->icon('heroicon-o-check-circle')
->color('gray')
->action(function (Get $get): void {
$path = trim($get('maxmind_database_path') ?? '');
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'),
]),
]),
// ── Google Analytics 4 ───────────────────────────────
Tab::make(__('filament-short-url::default.settings_tab_ga4'))
->icon('heroicon-o-chart-bar')
->schema([
Section::make(__('filament-short-url::default.settings_section_ga4'))
->description(__('filament-short-url::default.settings_ga4_description'))
->schema([
TextInput::make('ga4_api_secret')
->label(__('filament-short-url::default.settings_ga4_api_secret'))
->helperText(__('filament-short-url::default.settings_ga4_api_secret_helper'))
->password()
->revealable()
->placeholder('••••••••••••••••••••'),
// 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'),
// 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()
->integer()
->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()
->integer()
->minValue(1)
->suffix('s')
->required()
->visible(fn (Get $get): bool => (bool) $get('rate_limiting_enabled')),
]),
Section::make(__('filament-short-url::default.settings_section_security_v2'))
->description(__('filament-short-url::default.settings_section_security_v2_desc'))
->columns(2)
->schema([
Toggle::make('vpn_detection_enabled')
->label(__('filament-short-url::default.settings_vpn_detection_enabled'))
->helperText(__('filament-short-url::default.settings_vpn_detection_enabled_helper'))
->default(false)
->inline(false)
->live()
->columnSpanFull(),
Select::make('vpn_detection_driver')
->label(__('filament-short-url::default.settings_vpn_driver'))
->helperText(__('filament-short-url::default.settings_vpn_driver_helper'))
->options([
'ip-api' => __('filament-short-url::default.settings_vpn_driver_ipapi'),
'vpnapi' => __('filament-short-url::default.settings_vpn_driver_vpnapi'),
])
->default('ip-api')
->live()
->required()
->visible(fn (Get $get): bool => (bool) $get('vpn_detection_enabled')),
TextInput::make('vpnapi_key')
->label(__('filament-short-url::default.settings_vpnapi_key'))
->helperText(__('filament-short-url::default.settings_vpnapi_key_helper'))
->password()
->revealable()
->placeholder('••••••••••••••••••••')
->visible(fn (Get $get): bool => (bool) $get('vpn_detection_enabled') && $get('vpn_detection_driver') === 'vpnapi'),
Select::make('vpn_block_action')
->label(__('filament-short-url::default.settings_vpn_block_action'))
->helperText(__('filament-short-url::default.settings_vpn_block_action_helper'))
->options([
'flag_only' => __('filament-short-url::default.settings_vpn_block_flag_only'),
'block_with_403' => __('filament-short-url::default.settings_vpn_block_block_403'),
])
->default('flag_only')
->required()
->visible(fn (Get $get): bool => (bool) $get('vpn_detection_enabled')),
Toggle::make('safe_browsing_enabled')
->label(__('filament-short-url::default.settings_safe_browsing_enabled'))
->helperText(__('filament-short-url::default.settings_safe_browsing_enabled_helper'))
->default(false)
->inline(false)
->live()
->columnSpanFull(),
TextInput::make('google_safe_browsing_api_key')
->label(__('filament-short-url::default.settings_safe_browsing_api_key'))
->helperText(__('filament-short-url::default.settings_safe_browsing_api_key_helper'))
->password()
->revealable()
->placeholder('AIza••••••••••••••••••')
->columnSpanFull()
->suffixAction(
Action::make('testSafeBrowsing')
->label(__('filament-short-url::default.settings_safe_browsing_test'))
->icon('heroicon-o-signal')
->color('gray')
->action(function (Get $get): void {
$key = trim($get('google_safe_browsing_api_key') ?? '');
if (empty($key)) {
Notification::make()
->title(__('filament-short-url::default.settings_safe_browsing_test_empty'))
->warning()->send();
return;
}
try {
$svc = app(SafeBrowsingService::class);
$safe = $svc->isSafeWithKey('https://google.com', $key);
Notification::make()
->title($safe
? __('filament-short-url::default.settings_safe_browsing_test_ok')
: __('filament-short-url::default.settings_safe_browsing_test_fail'))
->color($safe ? 'success' : 'danger')
->send();
} catch (\Throwable $e) {
Notification::make()
->title(__('filament-short-url::default.settings_safe_browsing_test_error'))
->body($e->getMessage())
->danger()->send();
}
})
)
->visible(fn (Get $get): bool => (bool) $get('safe_browsing_enabled')),
]),
]),
// ── Tracking Defaults ─────────────────────────────────
Tab::make(__('filament-short-url::default.settings_tab_tracking_defaults'))
->icon('heroicon-o-chart-bar')
->schema([
Section::make(__('filament-short-url::default.settings_section_tracking_defaults'))
->description(__('filament-short-url::default.settings_section_tracking_defaults_helper'))
->schema([
Toggle::make('tracking_enabled')
->label(__('filament-short-url::default.settings_track_visits_default'))
->live()
->inline(false)
->columnSpanFull(),
Toggle::make('tracking_fields_ip_address')
->label(__('filament-short-url::default.settings_track_ip_default'))
->inline(false)
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
Toggle::make('tracking_fields_browser')
->label(__('filament-short-url::default.settings_track_browser_default'))
->inline(false)
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
Toggle::make('tracking_fields_browser_version')
->label(__('filament-short-url::default.settings_track_browser_version_default'))
->inline(false)
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
Toggle::make('tracking_fields_operating_system')
->label(__('filament-short-url::default.settings_track_os_default'))
->inline(false)
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
Toggle::make('tracking_fields_operating_system_version')
->label(__('filament-short-url::default.settings_track_os_version_default'))
->inline(false)
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
Toggle::make('tracking_fields_browser_language')
->label(__('filament-short-url::default.settings_track_browser_language_default'))
->inline(false)
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
Toggle::make('tracking_fields_referer_url')
->label(__('filament-short-url::default.settings_track_referer_default'))
->inline(false)
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
Toggle::make('tracking_fields_device_type')
->label(__('filament-short-url::default.settings_track_device_type_default'))
->inline(false)
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
])
->columns(4),
]),
// ── QR Defaults ──────────────────────────────────────
Tab::make(__('filament-short-url::default.settings_tab_qr'))
->icon('heroicon-o-qr-code')
->schema([
Section::make(__('filament-short-url::default.settings_section_qr_defaults'))
->description(__('filament-short-url::default.settings_section_qr_defaults_helper'))
->columns(2)
->schema([
TextInput::make('qr_size')
->label(__('filament-short-url::default.settings_qr_size'))
->numeric()
->integer()
->minValue(100)
->maxValue(2000)
->required(),
Select::make('qr_margin')
->label(__('filament-short-url::default.settings_qr_margin'))
->options(array_combine(range(0, 10), range(0, 10)))
->required(),
Select::make('qr_dot_style')
->label(__('filament-short-url::default.settings_qr_dot_style'))
->options([
'square' => __('filament-short-url::default.qr_option_square'),
'dots' => __('filament-short-url::default.qr_option_dots'),
'rounded' => __('filament-short-url::default.qr_option_rounded'),
'classy' => __('filament-short-url::default.qr_option_classy'),
'classy-rounded' => __('filament-short-url::default.qr_option_classy_rounded'),
'extra-rounded' => __('filament-short-url::default.qr_option_extra_rounded'),
])
->required(),
ColorPicker::make('qr_foreground_color')
->label(__('filament-short-url::default.settings_qr_foreground_color'))
->regex('/^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})\b$/')
->placeholder('#000000')
->required(),
ColorPicker::make('qr_background_color')
->label(__('filament-short-url::default.settings_qr_background_color'))
->regex('/^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})\b$/')
->placeholder('#ffffff')
->required(),
Toggle::make('qr_gradient_enabled')
->label(__('filament-short-url::default.settings_qr_gradient_enabled'))
->live()
->inline(false),
ColorPicker::make('qr_gradient_from')
->label(__('filament-short-url::default.settings_qr_gradient_from'))
->regex('/^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})\b$/')
->placeholder('#4f46e5')
->required(fn (Get $get): bool => (bool) $get('qr_gradient_enabled'))
->visible(fn (Get $get): bool => (bool) $get('qr_gradient_enabled')),
ColorPicker::make('qr_gradient_to')
->label(__('filament-short-url::default.settings_qr_gradient_to'))
->regex('/^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})\b$/')
->placeholder('#06b6d4')
->required(fn (Get $get): bool => (bool) $get('qr_gradient_enabled'))
->visible(fn (Get $get): bool => (bool) $get('qr_gradient_enabled')),
Select::make('qr_gradient_type')
->label(__('filament-short-url::default.settings_qr_gradient_type'))
->options([
'linear' => __('filament-short-url::default.qr_gradient_linear'),
'radial' => __('filament-short-url::default.qr_gradient_radial'),
])
->required(fn (Get $get): bool => (bool) $get('qr_gradient_enabled'))
->visible(fn (Get $get): bool => (bool) $get('qr_gradient_enabled')),
]),
]),
// ── Developer API & Webhooks ────────────────────────
Tab::make(__('filament-short-url::default.settings_tab_developer'))
->icon('heroicon-o-cpu-chip')
->schema([
Section::make(__('filament-short-url::default.settings_section_rest_api'))
->schema([
Toggle::make('api_enabled')
->label(__('filament-short-url::default.settings_api_enabled'))
->helperText(__('filament-short-url::default.settings_api_enabled_helper'))
->default(false)
->inline(false)
->columnSpanFull()
->live(),
]),
Section::make(__('filament-short-url::default.settings_section_api_keys'))
->description(__('filament-short-url::default.settings_api_keys_description'))
->visible(fn (Get $get): bool => (bool) $get('api_enabled'))
->schema([
Repeater::make('api_keys')
->label(__('filament-short-url::default.settings_api_keys'))
->schema([
TextInput::make('name')
->label(__('filament-short-url::default.api_key_name'))
->required(),
TextInput::make('key')
->label(__('filament-short-url::default.api_key'))
->disabled()
->dehydrated()
->default(fn () => 'sh_key_'.bin2hex(random_bytes(16))),
Toggle::make('is_active')
->label(__('filament-short-url::default.active'))
->default(true),
])
->columns(3)
->default([]),
]),
Section::make(__('filament-short-url::default.settings_section_global_webhook'))
->schema([
Toggle::make('global_webhook_enabled')
->label(__('filament-short-url::default.settings_global_webhook_enabled'))
->helperText(__('filament-short-url::default.settings_global_webhook_enabled_helper'))
->live()
->inline(false)
->afterStateUpdated(function (bool $state, $set) {
if (! $state) {
$set('global_webhook_url', null);
$set('webhook_events', ['visited']);
$set('webhook_signing_secret', null);
}
}),
TextInput::make('global_webhook_url')
->label(__('filament-short-url::default.settings_global_webhook_url'))
->helperText(__('filament-short-url::default.settings_global_webhook_url_helper'))
->url()
->nullable()
->columnSpanFull()
->visible(fn (Get $get): bool => (bool) $get('global_webhook_enabled')),
TextInput::make('webhook_signing_secret')
->label('Webhook Signing Secret')
->helperText('If configured, outgoing webhook requests will include the HMAC signature in X-ShortUrl-Signature.')
->password()
->revealable()
->placeholder('••••••••••••••••••••')
->columnSpanFull()
->visible(fn (Get $get): bool => (bool) $get('global_webhook_enabled')),
Select::make('webhook_events')
->label(__('filament-short-url::default.settings_webhook_events'))
->helperText(__('filament-short-url::default.settings_webhook_events_helper'))
->multiple()
->options([
'visited' => __('filament-short-url::default.webhook_event_visited'),
'created' => __('filament-short-url::default.webhook_event_created'),
'expired' => __('filament-short-url::default.webhook_event_expired'),
'limit_reached' => __('filament-short-url::default.webhook_event_limit_reached'),
])
->default(['visited'])
->columnSpanFull()
->visible(fn (Get $get): bool => (bool) $get('global_webhook_enabled')),
]),
]),
// ── Deep Linking ─────────────────────────────────────
Tab::make(__('filament-short-url::default.settings_tab_deep_linking'))
->icon('heroicon-o-device-phone-mobile')
->schema([
Section::make(__('filament-short-url::default.settings_section_deep_linking'))
->schema([
Toggle::make('deep_linking_enabled')
->label(__('filament-short-url::default.settings_deep_linking_enabled'))
->helperText(__('filament-short-url::default.settings_deep_linking_enabled_helper'))
->default(false)
->inline(false)
->live(),
Textarea::make('aasa_json')
->label(__('filament-short-url::default.settings_aasa_json'))
->helperText(__('filament-short-url::default.settings_aasa_json_helper'))
->nullable()
->visible(fn (Get $get): bool => (bool) $get('deep_linking_enabled'))
->columnSpanFull()
->extraInputAttributes(['style' => 'font-family: monospace;'])
->rows(8)
->rules([
function () {
return function (string $attribute, $value, \Closure $fail) {
if (empty($value)) {
return;
}
json_decode($value);
if (json_last_error() !== JSON_ERROR_NONE) {
$fail(__('filament-short-url::default.validation_invalid_json'));
}
};
},
]),
Textarea::make('assetlinks_json')
->label(__('filament-short-url::default.settings_assetlinks_json'))
->helperText(__('filament-short-url::default.settings_assetlinks_json_helper'))
->nullable()
->visible(fn (Get $get): bool => (bool) $get('deep_linking_enabled'))
->columnSpanFull()
->extraInputAttributes(['style' => 'font-family: monospace;'])
->rows(8)
->rules([
function () {
return function (string $attribute, $value, \Closure $fail) {
if (empty($value)) {
return;
}
json_decode($value);
if (json_last_error() !== JSON_ERROR_NONE) {
$fail(__('filament-short-url::default.validation_invalid_json'));
}
};
},
]),
]),
]),
GeneralTab::make(),
GeoIpTab::make(),
Ga4Tab::make(),
AdvancedTab::make(),
TrackingDefaultsTab::make(),
QrDefaultsTab::make(),
DeveloperTab::make(),
DeepLinkingTab::make(),
]),
])
->statePath('data');
}
public function save(): void
public function save(ShortUrlSettingsManager $mgr): void
{
$data = $this->form->getState();
app(ShortUrlSettingsManager::class)->set($data);
$mgr->set($data);
// Clear deep linking cache when settings are saved!
cache()->forget('fsu:deep-linking:aasa');
@@ -963,15 +202,15 @@ class ShortUrlSettingsPage extends Page implements HasForms
if ($newKeys = session()->get('fsu_new_api_keys')) {
foreach ($newKeys as $newKey) {
Notification::make()
->title(__('filament-short-url::default.api_key_generated') ?? 'API Key Generated')
->body(new HtmlString('<strong>'.$newKey['name'].'</strong>: '.(__('filament-short-url::default.api_key_warning') ?? "Please copy this key now. You won't be able to see it again!")."<br/><code style='background:#e4e4e7;color:#18181b;padding:6px;border-radius:4px;user-select:all;display:block;margin-top:6px;font-family:monospace;font-weight:bold;'>".$newKey['plain'].'</code>'))
->title(__('filament-short-url::default.api_key_generated'))
->body(new HtmlString('<strong>'.$newKey['name'].'</strong>: '.__('filament-short-url::default.api_key_warning')."<br/><code style='background:#e4e4e7;color:#18181b;padding:6px;border-radius:4px;user-select:all;display:block;margin-top:6px;font-family:monospace;font-weight:bold;'>".$newKey['plain'].'</code>'))
->warning()
->persistent()
->send();
}
// Re-mount settings form state to reflect the new masked key names in the UI
$this->mount();
$this->mount($mgr);
}
}

Some files were not shown because too many files have changed in this diff Show More