diff --git a/README.md b/README.md index a2bbc52..dfc564a 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Acting as a self-hosted, enterprise-grade alternative to Bitly and Rebrandly, th ### 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. +* **Custom Domain Branding**: Connect your own custom domains with dynamic DNS checks and automatic routing redirection at the root level. * **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. @@ -47,6 +48,7 @@ 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. +- 🌐 **Custom Domain Branding (new in v4.0.0)** β€” Register custom domains, show dynamic DNS setup instructions (A/CNAME records), verify records in real-time, and route redirects directly at the root of verified custom domains. - 🌍 **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. @@ -68,11 +70,15 @@ Acting as a self-hosted, enterprise-grade alternative to Bitly and Rebrandly, th - πŸ›‘οΈ **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. +- πŸ”Œ **Developer REST API (updated in v3.5.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. +- πŸ‘€ **User Attribution (new in v3.5.0)** β€” Automatically assign the authenticated user's ID to every new short URL. Show their avatar and hover card (name + email) directly in the list table. +- πŸ•’ **Relative Time Badges (new in v3.5.0)** β€” Display compact relative timestamps (e.g., `2h`, `5d`, `3mo`) for link creation dates with rich Alpine.js hover popovers showing precise absolute dates and timezone offsets. +- ⌨️ **Keyboard Shortcuts (new in v3.5.0)** β€” Hover over any table row to enable shortcut keys: `E` (edit), `Q` (QR code), `I` (share/copy), `S` (statistics), `X` (delete). Shortcuts are safely ignored when focus is inside modals or text inputs. +- πŸ”˜ **Unified 3-dot Action Dropdown (new in v3.5.0)** β€” All per-row actions are consolidated under a single icon button that opens a styled dropdown with shortcut key badges on the right. --- @@ -244,6 +250,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 @@ -354,7 +363,7 @@ When a short URL is expired, deactivated, or has reached its maximum visit limit --- -## Smart Link Targeting (updated in v3.3.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. @@ -416,10 +425,68 @@ $shortUrl->update([ If your database contains legacy single-strategy rules (e.g. `'type' => 'device'` or `'type' => 'geo'`), the plugin handles them automatically: * **Redirection Engine**: The redirect system detects the legacy structure and processes it on-the-fly using the legacy strategy. * **Filament UI**: When loading a link with legacy rules, the Filament Form automatically upgrades and hydrates them to equivalent new multi-filter rules. -* **A/B Split Rotation**: The legacy rotation strategy is still supported backward-compatibly in redirect logic, but cannot be newly configured through the Filament v3.3.0 form. --- +## A/B Split Testing & Weighted Traffic Rotation (new in v3.5.0) + +A single short URL can distribute traffic across 2–5 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). @@ -589,8 +656,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 + + ServerName go.company.com + DocumentRoot /var/www/your-app/public + + + AllowOverride All + Require all granted + + +``` + +> **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 @@ -754,23 +946,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 | ❌ | Advanced Multi-Filter Targeting Rules JSON schema (see [Smart Link Targeting](#smart-link-targeting-updated-in-v330) for schema details) | +| `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`) | @@ -1007,6 +1200,35 @@ You can also pre-configure all parameters via your `.env` file: |---|---|---|---| | `SHORT_URL_PREFIX` | `route_prefix` | `'s'` | URL prefix for short URL redirects. | | `SHORT_URL_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 1–2 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. | @@ -1024,6 +1246,7 @@ You can also pre-configure all parameters via your `.env` file: | `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. @@ -1171,6 +1394,28 @@ All migrations are compatible with **SQLite**, **MySQL**, and **PostgreSQL**: ## Changelog +### 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). @@ -1201,7 +1446,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 @@ -1209,9 +1453,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. @@ -1220,10 +1462,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. @@ -1235,16 +1476,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. @@ -1252,8 +1489,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. diff --git a/config/filament-short-url.php b/config/filament-short-url.php index 1e75221..c1dfb4a 100644 --- a/config/filament-short-url.php +++ b/config/filament-short-url.php @@ -42,6 +42,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 +88,7 @@ return [ */ 'tracking' => [ 'enabled' => true, + 'anonymize_ips' => env('SHORT_URL_ANONYMIZE_IPS', false), 'fields' => [ 'ip_address' => true, 'browser' => true, @@ -204,6 +223,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 @@ -228,4 +256,20 @@ return [ 'enabled' => env('SHORT_URL_DEEP_LINKING_ENABLED', false), ], + /* + |-------------------------------------------------------------------------- + | 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' => \App\Models\User::class, + 'name_column' => 'name', + 'email_column' => 'email', + 'avatar_column' => 'avatar_url', // can be attribute/method on model or null to auto-detect HasAvatar + ], + ]; + diff --git a/database/migrations/2026_06_04_000000_create_short_url_settings_table.php b/database/migrations/2024_01_01_000001_create_short_url_settings_table.php similarity index 83% rename from database/migrations/2026_06_04_000000_create_short_url_settings_table.php rename to database/migrations/2024_01_01_000001_create_short_url_settings_table.php index 367e752..9ba280c 100644 --- a/database/migrations/2026_06_04_000000_create_short_url_settings_table.php +++ b/database/migrations/2024_01_01_000001_create_short_url_settings_table.php @@ -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'); diff --git a/database/migrations/2024_01_01_000002_create_short_url_pixels_table.php b/database/migrations/2024_01_01_000002_create_short_url_pixels_table.php new file mode 100644 index 0000000..3dbd62a --- /dev/null +++ b/database/migrations/2024_01_01_000002_create_short_url_pixels_table.php @@ -0,0 +1,33 @@ +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'); + } +}; diff --git a/database/migrations/2024_01_01_000002_create_short_url_visits_table.php b/database/migrations/2024_01_01_000002_create_short_url_visits_table.php deleted file mode 100644 index a0947a8..0000000 --- a/database/migrations/2024_01_01_000002_create_short_url_visits_table.php +++ /dev/null @@ -1,52 +0,0 @@ -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'); - } -}; diff --git a/database/migrations/2024_01_01_000001_create_short_urls_table.php b/database/migrations/2024_01_01_000003_create_short_urls_table.php similarity index 59% rename from database/migrations/2024_01_01_000001_create_short_urls_table.php rename to database/migrations/2024_01_01_000003_create_short_urls_table.php index 970e0c7..db65947 100644 --- a/database/migrations/2024_01_01_000001_create_short_urls_table.php +++ b/database/migrations/2024_01_01_000003_create_short_urls_table.php @@ -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'); } }; diff --git a/database/migrations/2024_01_01_000004_create_short_url_visits_table.php b/database/migrations/2024_01_01_000004_create_short_url_visits_table.php new file mode 100644 index 0000000..44e3893 --- /dev/null +++ b/database/migrations/2024_01_01_000004_create_short_url_visits_table.php @@ -0,0 +1,63 @@ +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'); + } +}; diff --git a/database/migrations/2026_06_01_000005_create_short_url_daily_stats_table.php b/database/migrations/2024_01_01_000005_create_short_url_daily_stats_table.php similarity index 63% rename from database/migrations/2026_06_01_000005_create_short_url_daily_stats_table.php rename to database/migrations/2024_01_01_000005_create_short_url_daily_stats_table.php index 7fb6dde..94f1fe2 100644 --- a/database/migrations/2026_06_01_000005_create_short_url_daily_stats_table.php +++ b/database/migrations/2024_01_01_000005_create_short_url_daily_stats_table.php @@ -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'); diff --git a/database/migrations/2026_06_01_000003_add_utm_city_referer_to_short_url_visits_table.php b/database/migrations/2026_06_01_000003_add_utm_city_referer_to_short_url_visits_table.php deleted file mode 100644 index d56f353..0000000 --- a/database/migrations/2026_06_01_000003_add_utm_city_referer_to_short_url_visits_table.php +++ /dev/null @@ -1,36 +0,0 @@ -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', - ]); - }); - } -}; diff --git a/database/migrations/2026_06_01_000004_add_targeting_and_security_to_short_urls_table.php b/database/migrations/2026_06_01_000004_add_targeting_and_security_to_short_urls_table.php deleted file mode 100644 index 7ff0638..0000000 --- a/database/migrations/2026_06_01_000004_add_targeting_and_security_to_short_urls_table.php +++ /dev/null @@ -1,28 +0,0 @@ -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', - ]); - }); - } -}; diff --git a/database/migrations/2026_06_02_000006_add_max_visits_and_expiration_redirect_to_short_urls_table.php b/database/migrations/2026_06_02_000006_add_max_visits_and_expiration_redirect_to_short_urls_table.php deleted file mode 100644 index e3271f5..0000000 --- a/database/migrations/2026_06_02_000006_add_max_visits_and_expiration_redirect_to_short_urls_table.php +++ /dev/null @@ -1,23 +0,0 @@ -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']); - }); - } -}; diff --git a/database/migrations/2026_06_02_000007_add_retargeting_pixels_and_webhooks_to_short_urls_table.php b/database/migrations/2026_06_02_000007_add_retargeting_pixels_and_webhooks_to_short_urls_table.php deleted file mode 100644 index a676222..0000000 --- a/database/migrations/2026_06_02_000007_add_retargeting_pixels_and_webhooks_to_short_urls_table.php +++ /dev/null @@ -1,36 +0,0 @@ -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', - ]); - }); - } -}; diff --git a/database/migrations/2026_06_02_000008_add_bot_and_proxy_to_short_url_visits_table.php b/database/migrations/2026_06_02_000008_add_bot_and_proxy_to_short_url_visits_table.php deleted file mode 100644 index 09f2d83..0000000 --- a/database/migrations/2026_06_02_000008_add_bot_and_proxy_to_short_url_visits_table.php +++ /dev/null @@ -1,23 +0,0 @@ -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']); - }); - } -}; diff --git a/database/migrations/2026_06_03_110000_add_qr_and_language_to_visits_and_daily_stats.php b/database/migrations/2026_06_03_110000_add_qr_and_language_to_visits_and_daily_stats.php deleted file mode 100644 index 4297c51..0000000 --- a/database/migrations/2026_06_03_110000_add_qr_and_language_to_visits_and_daily_stats.php +++ /dev/null @@ -1,43 +0,0 @@ -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']); - }); - } -}; diff --git a/database/migrations/2026_06_03_120000_add_track_browser_language_to_short_urls_table.php b/database/migrations/2026_06_03_120000_add_track_browser_language_to_short_urls_table.php deleted file mode 100644 index fb4b783..0000000 --- a/database/migrations/2026_06_03_120000_add_track_browser_language_to_short_urls_table.php +++ /dev/null @@ -1,22 +0,0 @@ -boolean('track_browser_language')->default(true); - }); - } - - public function down(): void - { - Schema::table('short_urls', function (Blueprint $table): void { - $table->dropColumn('track_browser_language'); - }); - } -}; diff --git a/database/migrations/2026_06_03_150000_create_short_url_pixels_table.php b/database/migrations/2026_06_03_150000_create_short_url_pixels_table.php deleted file mode 100644 index 61d682a..0000000 --- a/database/migrations/2026_06_03_150000_create_short_url_pixels_table.php +++ /dev/null @@ -1,155 +0,0 @@ -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'); - } -}; diff --git a/database/migrations/2026_06_02_224250_add_qr_logo_to_short_urls_table.php b/database/migrations/2026_06_05_000001_add_user_id_to_short_urls_table.php similarity index 79% rename from database/migrations/2026_06_02_224250_add_qr_logo_to_short_urls_table.php rename to database/migrations/2026_06_05_000001_add_user_id_to_short_urls_table.php index acc2724..27d4a38 100644 --- a/database/migrations/2026_06_02_224250_add_qr_logo_to_short_urls_table.php +++ b/database/migrations/2026_06_05_000001_add_user_id_to_short_urls_table.php @@ -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'); }); } }; diff --git a/database/migrations/2026_06_05_000002_create_short_url_custom_domains_table.php b/database/migrations/2026_06_05_000002_create_short_url_custom_domains_table.php new file mode 100644 index 0000000..3bf1dc0 --- /dev/null +++ b/database/migrations/2026_06_05_000002_create_short_url_custom_domains_table.php @@ -0,0 +1,31 @@ +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'); + } +}; diff --git a/database/migrations/2026_06_03_160000_add_auto_open_app_mobile_to_short_urls_table.php b/database/migrations/2026_06_05_000003_add_custom_domain_id_to_short_urls_table.php similarity index 63% rename from database/migrations/2026_06_03_160000_add_auto_open_app_mobile_to_short_urls_table.php rename to database/migrations/2026_06_05_000003_add_custom_domain_id_to_short_urls_table.php index 598a50d..590038f 100644 --- a/database/migrations/2026_06_03_160000_add_auto_open_app_mobile_to_short_urls_table.php +++ b/database/migrations/2026_06_05_000003_add_custom_domain_id_to_short_urls_table.php @@ -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'); }); } }; diff --git a/resources/css/plugin.css b/resources/css/plugin.css index da5ee55..fc96580 100644 --- a/resources/css/plugin.css +++ b/resources/css/plugin.css @@ -7,6 +7,14 @@ 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 +23,66 @@ 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; +} \ No newline at end of file diff --git a/resources/dist/filament-short-url.css b/resources/dist/filament-short-url.css index 68e9d93..63c2bdb 100644 --- a/resources/dist/filament-short-url.css +++ b/resources/dist/filament-short-url.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-content:""}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-serif:ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-red-900:oklch(39.6% .141 25.723);--color-red-950:oklch(25.8% .092 26.042);--color-orange-50:oklch(98% .016 73.684);--color-orange-100:oklch(95.4% .038 75.164);--color-orange-200:oklch(90.1% .076 70.697);--color-orange-300:oklch(83.7% .128 66.29);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-orange-600:oklch(64.6% .222 41.116);--color-orange-700:oklch(55.3% .195 38.402);--color-orange-800:oklch(47% .157 37.304);--color-orange-900:oklch(40.8% .123 38.172);--color-orange-950:oklch(26.6% .079 36.259);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-700:oklch(55.5% .163 48.998);--color-amber-800:oklch(47.3% .137 46.201);--color-amber-900:oklch(41.4% .112 45.904);--color-amber-950:oklch(27.9% .077 45.635);--color-yellow-50:oklch(98.7% .026 102.212);--color-yellow-100:oklch(97.3% .071 103.193);--color-yellow-200:oklch(94.5% .129 101.54);--color-yellow-300:oklch(90.5% .182 98.111);--color-yellow-400:oklch(85.2% .199 91.936);--color-yellow-500:oklch(79.5% .184 86.047);--color-yellow-600:oklch(68.1% .162 75.834);--color-yellow-700:oklch(55.4% .135 66.442);--color-yellow-800:oklch(47.6% .114 61.907);--color-yellow-900:oklch(42.1% .095 57.708);--color-yellow-950:oklch(28.6% .066 53.813);--color-lime-50:oklch(98.6% .031 120.757);--color-lime-100:oklch(96.7% .067 122.328);--color-lime-200:oklch(93.8% .127 124.321);--color-lime-300:oklch(89.7% .196 126.665);--color-lime-400:oklch(84.1% .238 128.85);--color-lime-500:oklch(76.8% .233 130.85);--color-lime-600:oklch(64.8% .2 131.684);--color-lime-700:oklch(53.2% .157 131.589);--color-lime-800:oklch(45.3% .124 130.933);--color-lime-900:oklch(40.5% .101 131.063);--color-lime-950:oklch(27.4% .072 132.109);--color-green-50:oklch(98.2% .018 155.826);--color-green-100:oklch(96.2% .044 156.743);--color-green-200:oklch(92.5% .084 155.995);--color-green-300:oklch(87.1% .15 154.449);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--color-green-700:oklch(52.7% .154 150.069);--color-green-800:oklch(44.8% .119 151.328);--color-green-900:oklch(39.3% .095 152.535);--color-green-950:oklch(26.6% .065 152.934);--color-emerald-50:oklch(97.9% .021 166.113);--color-emerald-100:oklch(95% .052 163.051);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-300:oklch(84.5% .143 164.978);--color-emerald-400:oklch(76.5% .177 163.223);--color-emerald-500:oklch(69.6% .17 162.48);--color-emerald-600:oklch(59.6% .145 163.225);--color-emerald-700:oklch(50.8% .118 165.612);--color-emerald-800:oklch(43.2% .095 166.913);--color-emerald-900:oklch(37.8% .077 168.94);--color-emerald-950:oklch(26.2% .051 172.552);--color-teal-50:oklch(98.4% .014 180.72);--color-teal-100:oklch(95.3% .051 180.801);--color-teal-200:oklch(91% .096 180.426);--color-teal-300:oklch(85.5% .138 181.071);--color-teal-400:oklch(77.7% .152 181.912);--color-teal-500:oklch(70.4% .14 182.503);--color-teal-600:oklch(60% .118 184.704);--color-teal-700:oklch(51.1% .096 186.391);--color-teal-800:oklch(43.7% .078 188.216);--color-teal-900:oklch(38.6% .063 188.416);--color-teal-950:oklch(27.7% .046 192.524);--color-cyan-50:oklch(98.4% .019 200.873);--color-cyan-100:oklch(95.6% .045 203.388);--color-cyan-200:oklch(91.7% .08 205.041);--color-cyan-300:oklch(86.5% .127 207.078);--color-cyan-400:oklch(78.9% .154 211.53);--color-cyan-500:oklch(71.5% .143 215.221);--color-cyan-600:oklch(60.9% .126 221.723);--color-cyan-700:oklch(52% .105 223.128);--color-cyan-800:oklch(45% .085 224.283);--color-cyan-900:oklch(39.8% .07 227.392);--color-cyan-950:oklch(30.2% .056 229.695);--color-sky-50:oklch(97.7% .013 236.62);--color-sky-100:oklch(95.1% .026 236.824);--color-sky-200:oklch(90.1% .058 230.902);--color-sky-300:oklch(82.8% .111 230.318);--color-sky-400:oklch(74.6% .16 232.661);--color-sky-500:oklch(68.5% .169 237.323);--color-sky-600:oklch(58.8% .158 241.966);--color-sky-700:oklch(50% .134 242.749);--color-sky-800:oklch(44.3% .11 240.79);--color-sky-900:oklch(39.1% .09 240.876);--color-sky-950:oklch(29.3% .066 243.157);--color-blue-50:oklch(97% .014 254.604);--color-blue-100:oklch(93.2% .032 255.585);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-300:oklch(80.9% .105 251.813);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-blue-800:oklch(42.4% .199 265.638);--color-blue-900:oklch(37.9% .146 265.522);--color-blue-950:oklch(28.2% .091 267.935);--color-indigo-50:oklch(96.2% .018 272.314);--color-indigo-100:oklch(93% .034 272.788);--color-indigo-200:oklch(87% .065 274.039);--color-indigo-300:oklch(78.5% .115 274.713);--color-indigo-400:oklch(67.3% .182 276.935);--color-indigo-500:oklch(58.5% .233 277.117);--color-indigo-600:oklch(51.1% .262 276.966);--color-indigo-700:oklch(45.7% .24 277.023);--color-indigo-800:oklch(39.8% .195 277.366);--color-indigo-900:oklch(35.9% .144 278.697);--color-indigo-950:oklch(25.7% .09 281.288);--color-violet-50:oklch(96.9% .016 293.756);--color-violet-100:oklch(94.3% .029 294.588);--color-violet-200:oklch(89.4% .057 293.283);--color-violet-300:oklch(81.1% .111 293.571);--color-violet-400:oklch(70.2% .183 293.541);--color-violet-500:oklch(60.6% .25 292.717);--color-violet-600:oklch(54.1% .281 293.009);--color-violet-700:oklch(49.1% .27 292.581);--color-violet-800:oklch(43.2% .232 292.759);--color-violet-900:oklch(38% .189 293.745);--color-violet-950:oklch(28.3% .141 291.089);--color-purple-50:oklch(97.7% .014 308.299);--color-purple-100:oklch(94.6% .033 307.174);--color-purple-200:oklch(90.2% .063 306.703);--color-purple-300:oklch(82.7% .119 306.383);--color-purple-400:oklch(71.4% .203 305.504);--color-purple-500:oklch(62.7% .265 303.9);--color-purple-600:oklch(55.8% .288 302.321);--color-purple-700:oklch(49.6% .265 301.924);--color-purple-800:oklch(43.8% .218 303.724);--color-purple-900:oklch(38.1% .176 304.987);--color-purple-950:oklch(29.1% .149 302.717);--color-fuchsia-50:oklch(97.7% .017 320.058);--color-fuchsia-100:oklch(95.2% .037 318.852);--color-fuchsia-200:oklch(90.3% .076 319.62);--color-fuchsia-300:oklch(83.3% .145 321.434);--color-fuchsia-400:oklch(74% .238 322.16);--color-fuchsia-500:oklch(66.7% .295 322.15);--color-fuchsia-600:oklch(59.1% .293 322.896);--color-fuchsia-700:oklch(51.8% .253 323.949);--color-fuchsia-800:oklch(45.2% .211 324.591);--color-fuchsia-900:oklch(40.1% .17 325.612);--color-fuchsia-950:oklch(29.3% .136 325.661);--color-pink-50:oklch(97.1% .014 343.198);--color-pink-100:oklch(94.8% .028 342.258);--color-pink-200:oklch(89.9% .061 343.231);--color-pink-300:oklch(82.3% .12 346.018);--color-pink-400:oklch(71.8% .202 349.761);--color-pink-500:oklch(65.6% .241 354.308);--color-pink-600:oklch(59.2% .249 .584);--color-pink-700:oklch(52.5% .223 3.958);--color-pink-800:oklch(45.9% .187 3.815);--color-pink-900:oklch(40.8% .153 2.432);--color-pink-950:oklch(28.4% .109 3.907);--color-rose-50:oklch(96.9% .015 12.422);--color-rose-100:oklch(94.1% .03 12.58);--color-rose-200:oklch(89.2% .058 10.001);--color-rose-300:oklch(81% .117 11.638);--color-rose-400:oklch(71.2% .194 13.428);--color-rose-500:oklch(64.5% .246 16.439);--color-rose-600:oklch(58.6% .253 17.585);--color-rose-700:oklch(51.4% .222 16.935);--color-rose-800:oklch(45.5% .188 13.697);--color-rose-900:oklch(41% .159 10.272);--color-rose-950:oklch(27.1% .105 12.094);--color-slate-50:oklch(98.4% .003 247.858);--color-slate-100:oklch(96.8% .007 247.896);--color-slate-200:oklch(92.9% .013 255.508);--color-slate-300:oklch(86.9% .022 252.894);--color-slate-400:oklch(70.4% .04 256.788);--color-slate-500:oklch(55.4% .046 257.417);--color-slate-600:oklch(44.6% .043 257.281);--color-slate-700:oklch(37.2% .044 257.287);--color-slate-800:oklch(27.9% .041 260.031);--color-slate-900:oklch(20.8% .042 265.755);--color-slate-950:oklch(12.9% .042 264.695);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-800:oklch(27.8% .033 256.848);--color-gray-900:oklch(21% .034 264.665);--color-gray-950:oklch(13% .028 261.692);--color-zinc-50:oklch(98.5% 0 0);--color-zinc-100:oklch(96.7% .001 286.375);--color-zinc-200:oklch(92% .004 286.32);--color-zinc-300:oklch(87.1% .006 286.286);--color-zinc-400:oklch(70.5% .015 286.067);--color-zinc-500:oklch(55.2% .016 285.938);--color-zinc-600:oklch(44.2% .017 285.786);--color-zinc-700:oklch(37% .013 285.805);--color-zinc-800:oklch(27.4% .006 286.033);--color-zinc-900:oklch(21% .006 285.885);--color-zinc-950:oklch(14.1% .005 285.823);--color-neutral-50:oklch(98.5% 0 0);--color-neutral-100:oklch(97% 0 0);--color-neutral-200:oklch(92.2% 0 0);--color-neutral-300:oklch(87% 0 0);--color-neutral-400:oklch(70.8% 0 0);--color-neutral-500:oklch(55.6% 0 0);--color-neutral-600:oklch(43.9% 0 0);--color-neutral-700:oklch(37.1% 0 0);--color-neutral-800:oklch(26.9% 0 0);--color-neutral-900:oklch(20.5% 0 0);--color-neutral-950:oklch(14.5% 0 0);--color-stone-50:oklch(98.5% .001 106.423);--color-stone-100:oklch(97% .001 106.424);--color-stone-200:oklch(92.3% .003 48.717);--color-stone-300:oklch(86.9% .005 56.366);--color-stone-400:oklch(70.9% .01 56.259);--color-stone-500:oklch(55.3% .013 58.071);--color-stone-600:oklch(44.4% .011 73.639);--color-stone-700:oklch(37.4% .01 67.558);--color-stone-800:oklch(26.8% .007 34.298);--color-stone-900:oklch(21.6% .006 56.043);--color-stone-950:oklch(14.7% .004 49.25);--color-black:#000;--color-white:#fff;--spacing:.25rem;--breakpoint-sm:40rem;--breakpoint-md:48rem;--breakpoint-lg:64rem;--breakpoint-xl:80rem;--breakpoint-2xl:96rem;--container-3xs:16rem;--container-2xs:18rem;--container-xs:20rem;--container-sm:24rem;--container-md:28rem;--container-lg:32rem;--container-xl:36rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--container-6xl:72rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--text-6xl:3.75rem;--text-6xl--line-height:1;--text-7xl:4.5rem;--text-7xl--line-height:1;--text-8xl:6rem;--text-8xl--line-height:1;--text-9xl:8rem;--text-9xl--line-height:1;--font-weight-thin:100;--font-weight-extralight:200;--font-weight-light:300;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--font-weight-extrabold:800;--font-weight-black:900;--tracking-tighter:-.05em;--tracking-tight:-.025em;--tracking-normal:0em;--tracking-wide:.025em;--tracking-wider:.05em;--tracking-widest:.1em;--leading-tight:1.25;--leading-snug:1.375;--leading-normal:1.5;--leading-relaxed:1.625;--leading-loose:2;--radius-xs:.125rem;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--radius-3xl:1.5rem;--radius-4xl:2rem;--shadow-2xs:0 1px #0000000d;--shadow-xs:0 1px 2px 0 #0000000d;--shadow-sm:0 1px 3px 0 #0000001a, 0 1px 2px -1px #0000001a;--shadow-md:0 4px 6px -1px #0000001a, 0 2px 4px -2px #0000001a;--shadow-lg:0 10px 15px -3px #0000001a, 0 4px 6px -4px #0000001a;--shadow-xl:0 20px 25px -5px #0000001a, 0 8px 10px -6px #0000001a;--shadow-2xl:0 25px 50px -12px #00000040;--inset-shadow-2xs:inset 0 1px #0000000d;--inset-shadow-xs:inset 0 1px 1px #0000000d;--inset-shadow-sm:inset 0 2px 4px #0000000d;--drop-shadow-xs:0 1px 1px #0000000d;--drop-shadow-sm:0 1px 2px #00000026;--drop-shadow-md:0 3px 3px #0000001f;--drop-shadow-lg:0 4px 4px #00000026;--drop-shadow-xl:0 9px 7px #0000001a;--drop-shadow-2xl:0 25px 25px #00000026;--ease-in:cubic-bezier(.4, 0, 1, 1);--ease-out:cubic-bezier(0, 0, .2, 1);--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-ping:ping 1s cubic-bezier(0, 0, .2, 1) infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--animate-bounce:bounce 1s infinite;--blur-xs:4px;--blur-sm:8px;--blur-md:12px;--blur-lg:16px;--blur-xl:24px;--blur-2xl:40px;--blur-3xl:64px;--perspective-dramatic:100px;--perspective-near:300px;--perspective-normal:500px;--perspective-midrange:800px;--perspective-distant:1200px;--aspect-video:16 / 9;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.\@container{container-type:inline-size}.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{inset:calc(var(--spacing) * 0)}.top-0{top:calc(var(--spacing) * 0)}.top-2{top:calc(var(--spacing) * 2)}.top-3{top:calc(var(--spacing) * 3)}.top-4{top:calc(var(--spacing) * 4)}.top-9{top:calc(var(--spacing) * 9)}.top-28{top:calc(var(--spacing) * 28)}.right-0{right:calc(var(--spacing) * 0)}.right-1{right:calc(var(--spacing) * 1)}.right-4{right:calc(var(--spacing) * 4)}.bottom-0{bottom:calc(var(--spacing) * 0)}.left-0{left:calc(var(--spacing) * 0)}.left-2{left:calc(var(--spacing) * 2)}.left-3{left:calc(var(--spacing) * 3)}.left-4{left:calc(var(--spacing) * 4)}.left-\[22px\]{left:22px}.isolate{isolation:isolate}.z-0{z-index:0}.z-10{z-index:10}.z-20{z-index:20}.z-40{z-index:40}.z-50{z-index:50}.col-span-2{grid-column:span 2/span 2}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.container\!{width:100%!important}@media (min-width:40rem){.container\!{max-width:40rem!important}}@media (min-width:48rem){.container\!{max-width:48rem!important}}@media (min-width:64rem){.container\!{max-width:64rem!important}}@media (min-width:80rem){.container\!{max-width:80rem!important}}@media (min-width:96rem){.container\!{max-width:96rem!important}}.m-0{margin:calc(var(--spacing) * 0)}.mx-auto{margin-inline:auto}.my-4{margin-block:calc(var(--spacing) * 4)}.my-5{margin-block:calc(var(--spacing) * 5)}.my-auto{margin-block:auto}.-mt-\[6\.6rem\]{margin-top:-6.6rem}.-mt-px{margin-top:-1px}.mt-0{margin-top:calc(var(--spacing) * 0)}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-2\.5{margin-top:calc(var(--spacing) * 2.5)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mt-auto{margin-top:auto}.mr-2{margin-right:calc(var(--spacing) * 2)}.-mb-px{margin-bottom:-1px}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-12{margin-bottom:calc(var(--spacing) * 12)}.-ml-8{margin-left:calc(var(--spacing) * -8)}.-ml-px{margin-left:-1px}.ml-1{margin-left:calc(var(--spacing) * 1)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-4{margin-left:calc(var(--spacing) * 4)}.ml-7{margin-left:calc(var(--spacing) * 7)}.ml-12{margin-left:calc(var(--spacing) * 12)}.ml-auto{margin-left:auto}.line-clamp-1{-webkit-line-clamp:1;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline\!{display:inline!important}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.inline-grid{display:inline-grid}.inline-table{display:inline-table}.list-item{display:list-item}.table{display:table}.table\!{display:table!important}.table-caption{display:table-caption}.table-cell{display:table-cell}.table-column{display:table-column}.table-column-group{display:table-column-group}.table-footer-group{display:table-footer-group}.table-header-group{display:table-header-group}.table-row{display:table-row}.table-row-group{display:table-row-group}.aspect-\[4\/3\]{aspect-ratio:4/3}.aspect-\[335\/364\]{aspect-ratio:335/364}.aspect-video{aspect-ratio:var(--aspect-video)}.size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.h-0{height:calc(var(--spacing) * 0)}.h-1{height:calc(var(--spacing) * 1)}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-4\.5{height:calc(var(--spacing) * 4.5)}.h-5{height:calc(var(--spacing) * 5)}.h-5\.5{height:calc(var(--spacing) * 5.5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-11{height:calc(var(--spacing) * 11)}.h-12{height:calc(var(--spacing) * 12)}.h-14{height:calc(var(--spacing) * 14)}.h-14\.5{height:calc(var(--spacing) * 14.5)}.h-16{height:calc(var(--spacing) * 16)}.h-20{height:calc(var(--spacing) * 20)}.h-\[1px\]{height:1px}.h-\[44px\]{height:44px}.h-\[55vh\]{height:55vh}.h-\[60px\]{height:60px}.h-\[65vh\]{height:65vh}.h-\[85vh\]{height:85vh}.h-auto{height:auto}.h-full{height:100%}.min-h-screen{min-height:100vh}.w-0{width:calc(var(--spacing) * 0)}.w-1{width:calc(var(--spacing) * 1)}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-3{width:calc(var(--spacing) * 3)}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-4{width:calc(var(--spacing) * 4)}.w-4\.5{width:calc(var(--spacing) * 4.5)}.w-5{width:calc(var(--spacing) * 5)}.w-5\.5{width:calc(var(--spacing) * 5.5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-11{width:calc(var(--spacing) * 11)}.w-12{width:calc(var(--spacing) * 12)}.w-16{width:calc(var(--spacing) * 16)}.w-20{width:calc(var(--spacing) * 20)}.w-28{width:calc(var(--spacing) * 28)}.w-32{width:calc(var(--spacing) * 32)}.w-\[1px\]{width:1px}.w-\[44px\]{width:44px}.w-\[438px\]{width:438px}.w-auto{width:auto}.w-fit{width:fit-content}.w-full{width:100%}.w-px{width:1px}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-7xl{max-width:var(--container-7xl)}.max-w-\[50ch\]{max-width:50ch}.max-w-\[200px\]{max-width:200px}.max-w-\[280px\]{max-width:280px}.max-w-\[335px\]{max-width:335px}.max-w-\[360px\]{max-width:360px}.max-w-lg{max-width:var(--container-lg)}.max-w-max{max-width:max-content}.max-w-md{max-width:var(--container-md)}.max-w-none{max-width:none}.max-w-xl{max-width:var(--container-xl)}.min-w-0{min-width:calc(var(--spacing) * 0)}.flex-1{flex:1}.flex-none{flex:none}.flex-shrink{flex-shrink:1}.flex-shrink-0,.shrink-0{flex-shrink:0}.flex-grow,.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.translate-y-0{--tw-translate-y:calc(var(--spacing) * 0);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-4{--tw-translate-y:calc(var(--spacing) * 4);translate:var(--tw-translate-x) var(--tw-translate-y)}.-rotate-90{rotate:-90deg}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.transform\!{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)!important}.animate-bounce{animation:var(--animate-bounce)}.animate-ping{animation:var(--animate-ping)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-default{cursor:default}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize{resize:both}.resize\!{resize:both!important}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-\[28px_1fr_auto\]{grid-template-columns:28px 1fr auto}.grid-cols-\[140px_1fr\]{grid-template-columns:140px 1fr}.grid-cols-\[minmax\(0\,7rem\)_minmax\(0\,1fr\)\]{grid-template-columns:minmax(0,7rem) minmax(0,1fr)}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.justify-start{justify-content:flex-start}.justify-items-center{justify-items:center}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-3\.5{gap:calc(var(--spacing) * 3.5)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-8{gap:calc(var(--spacing) * 8)}.gap-12{gap:calc(var(--spacing) * 12)}.gap-16{gap:calc(var(--spacing) * 16)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-8>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 8) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-10>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 10) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 10) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-16>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 16) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 16) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-3{column-gap:calc(var(--spacing) * 3)}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-x-6{column-gap:calc(var(--spacing) * 6)}:where(.space-x-1>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 1) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-6>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 6) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-x-reverse)))}.gap-y-1{row-gap:calc(var(--spacing) * 1)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}.gap-y-6{row-gap:calc(var(--spacing) * 6)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-gray-100>:not(:last-child)){border-color:var(--color-gray-100)}.self-end{align-self:flex-end}.self-start{align-self:flex-start}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.scroll-smooth{scroll-behavior:smooth}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-3xl{border-radius:var(--radius-3xl)}.rounded-\[6px\]{border-radius:6px}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.rounded-xl{border-radius:var(--radius-xl)}.rounded-t-lg{border-top-left-radius:var(--radius-lg);border-top-right-radius:var(--radius-lg)}.rounded-l-md{border-top-left-radius:var(--radius-md);border-bottom-left-radius:var(--radius-md)}.rounded-r-md{border-top-right-radius:var(--radius-md);border-bottom-right-radius:var(--radius-md)}.rounded-br-lg{border-bottom-right-radius:var(--radius-lg)}.rounded-bl-lg{border-bottom-left-radius:var(--radius-lg)}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-4{border-style:var(--tw-border-style);border-width:4px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-\[\#19140035\]{border-color:#19140035}.border-\[\#e3e3e0\]{border-color:#e3e3e0}.border-amber-200{border-color:var(--color-amber-200)}.border-black{border-color:var(--color-black)}.border-blue-200{border-color:var(--color-blue-200)}.border-cyan-500{border-color:var(--color-cyan-500)}.border-cyan-500\/20{border-color:#00b7d733}@supports (color:color-mix(in lab, red, red)){.border-cyan-500\/20{border-color:color-mix(in oklab, var(--color-cyan-500) 20%, transparent)}}.border-cyan-500\/30{border-color:#00b7d74d}@supports (color:color-mix(in lab, red, red)){.border-cyan-500\/30{border-color:color-mix(in oklab, var(--color-cyan-500) 30%, transparent)}}.border-emerald-200{border-color:var(--color-emerald-200)}.border-emerald-400{border-color:var(--color-emerald-400)}.border-emerald-500{border-color:var(--color-emerald-500)}.border-emerald-500\/20{border-color:#00bb7f33}@supports (color:color-mix(in lab, red, red)){.border-emerald-500\/20{border-color:color-mix(in oklab, var(--color-emerald-500) 20%, transparent)}}.border-gray-100{border-color:var(--color-gray-100)}.border-gray-200{border-color:var(--color-gray-200)}.border-gray-200\/50{border-color:#e5e7eb80}@supports (color:color-mix(in lab, red, red)){.border-gray-200\/50{border-color:color-mix(in oklab, var(--color-gray-200) 50%, transparent)}}.border-gray-300{border-color:var(--color-gray-300)}.border-gray-400{border-color:var(--color-gray-400)}.border-green-200{border-color:var(--color-green-200)}.border-neutral-100{border-color:var(--color-neutral-100)}.border-neutral-200{border-color:var(--color-neutral-200)}.border-neutral-900{border-color:var(--color-neutral-900)}.border-purple-200{border-color:var(--color-purple-200)}.border-red-100{border-color:var(--color-red-100)}.border-rose-400{border-color:var(--color-rose-400)}.border-slate-700{border-color:var(--color-slate-700)}.border-slate-800{border-color:var(--color-slate-800)}.border-slate-900{border-color:var(--color-slate-900)}.border-transparent{border-color:#0000}.border-white\/10{border-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.border-white\/10{border-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.border-t-current{border-top-color:currentColor}.bg-\[\#0f1419\]{background-color:#0f1419}.bg-\[\#1b1b18\]{background-color:#1b1b18}.bg-\[\#6b7280\]{background-color:#6b7280}.bg-\[\#25d366\]{background-color:#25d366}.bg-\[\#0077b5\]{background-color:#0077b5}.bg-\[\#1877f2\]{background-color:#1877f2}.bg-\[\#FCFCFC\]{background-color:#fcfcfc}.bg-\[\#FDFDFC\]{background-color:#fdfdfc}.bg-\[\#dbdbd7\]{background-color:#dbdbd7}.bg-\[\#f4f4f5\]{background-color:#f4f4f5}.bg-\[\#fff2f2\]{background-color:#fff2f2}.bg-amber-50{background-color:var(--color-amber-50)}.bg-amber-500\/10{background-color:#f99c001a}@supports (color:color-mix(in lab, red, red)){.bg-amber-500\/10{background-color:color-mix(in oklab, var(--color-amber-500) 10%, transparent)}}.bg-blue-50{background-color:var(--color-blue-50)}.bg-blue-500\/10{background-color:#3080ff1a}@supports (color:color-mix(in lab, red, red)){.bg-blue-500\/10{background-color:color-mix(in oklab, var(--color-blue-500) 10%, transparent)}}.bg-cyan-50{background-color:var(--color-cyan-50)}.bg-cyan-400{background-color:var(--color-cyan-400)}.bg-cyan-500{background-color:var(--color-cyan-500)}.bg-cyan-500\/10{background-color:#00b7d71a}@supports (color:color-mix(in lab, red, red)){.bg-cyan-500\/10{background-color:color-mix(in oklab, var(--color-cyan-500) 10%, transparent)}}.bg-cyan-950\/40{background-color:#05334566}@supports (color:color-mix(in lab, red, red)){.bg-cyan-950\/40{background-color:color-mix(in oklab, var(--color-cyan-950) 40%, transparent)}}.bg-emerald-50{background-color:var(--color-emerald-50)}.bg-emerald-100{background-color:var(--color-emerald-100)}.bg-emerald-500{background-color:var(--color-emerald-500)}.bg-emerald-500\/5{background-color:#00bb7f0d}@supports (color:color-mix(in lab, red, red)){.bg-emerald-500\/5{background-color:color-mix(in oklab, var(--color-emerald-500) 5%, transparent)}}.bg-emerald-500\/10{background-color:#00bb7f1a}@supports (color:color-mix(in lab, red, red)){.bg-emerald-500\/10{background-color:color-mix(in oklab, var(--color-emerald-500) 10%, transparent)}}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-50\/50{background-color:#f9fafb80}@supports (color:color-mix(in lab, red, red)){.bg-gray-50\/50{background-color:color-mix(in oklab, var(--color-gray-50) 50%, transparent)}}.bg-gray-50\/60{background-color:#f9fafb99}@supports (color:color-mix(in lab, red, red)){.bg-gray-50\/60{background-color:color-mix(in oklab, var(--color-gray-50) 60%, transparent)}}.bg-gray-100{background-color:var(--color-gray-100)}.bg-gray-100\/50{background-color:#f3f4f680}@supports (color:color-mix(in lab, red, red)){.bg-gray-100\/50{background-color:color-mix(in oklab, var(--color-gray-100) 50%, transparent)}}.bg-gray-200{background-color:var(--color-gray-200)}.bg-gray-500\/10{background-color:#6a72821a}@supports (color:color-mix(in lab, red, red)){.bg-gray-500\/10{background-color:color-mix(in oklab, var(--color-gray-500) 10%, transparent)}}.bg-gray-900{background-color:var(--color-gray-900)}.bg-gray-950\/40{background-color:#03071266}@supports (color:color-mix(in lab, red, red)){.bg-gray-950\/40{background-color:color-mix(in oklab, var(--color-gray-950) 40%, transparent)}}.bg-indigo-50{background-color:var(--color-indigo-50)}.bg-indigo-500{background-color:var(--color-indigo-500)}.bg-neutral-50{background-color:var(--color-neutral-50)}.bg-neutral-200{background-color:var(--color-neutral-200)}.bg-neutral-300{background-color:var(--color-neutral-300)}.bg-neutral-400{background-color:var(--color-neutral-400)}.bg-neutral-500{background-color:var(--color-neutral-500)}.bg-neutral-900{background-color:var(--color-neutral-900)}.bg-pink-50{background-color:var(--color-pink-50)}.bg-pink-500\/10{background-color:#f6339a1a}@supports (color:color-mix(in lab, red, red)){.bg-pink-500\/10{background-color:color-mix(in oklab, var(--color-pink-500) 10%, transparent)}}.bg-purple-50{background-color:var(--color-purple-50)}.bg-purple-500\/10{background-color:#ac4bff1a}@supports (color:color-mix(in lab, red, red)){.bg-purple-500\/10{background-color:color-mix(in oklab, var(--color-purple-500) 10%, transparent)}}.bg-red-50{background-color:var(--color-red-50)}.bg-red-500\/10{background-color:#fb2c361a}@supports (color:color-mix(in lab, red, red)){.bg-red-500\/10{background-color:color-mix(in oklab, var(--color-red-500) 10%, transparent)}}.bg-rose-50{background-color:var(--color-rose-50)}.bg-rose-500{background-color:var(--color-rose-500)}.bg-rose-600{background-color:var(--color-rose-600)}.bg-sky-50{background-color:var(--color-sky-50)}.bg-slate-800{background-color:var(--color-slate-800)}.bg-slate-900{background-color:var(--color-slate-900)}.bg-slate-900\/60{background-color:#0f172b99}@supports (color:color-mix(in lab, red, red)){.bg-slate-900\/60{background-color:color-mix(in oklab, var(--color-slate-900) 60%, transparent)}}.bg-slate-900\/80{background-color:#0f172bcc}@supports (color:color-mix(in lab, red, red)){.bg-slate-900\/80{background-color:color-mix(in oklab, var(--color-slate-900) 80%, transparent)}}.bg-slate-950{background-color:var(--color-slate-950)}.bg-teal-50{background-color:var(--color-teal-50)}.bg-transparent{background-color:#0000}.bg-white{background-color:var(--color-white)}.bg-white\/40{background-color:#fff6}@supports (color:color-mix(in lab, red, red)){.bg-white\/40{background-color:color-mix(in oklab, var(--color-white) 40%, transparent)}}.bg-white\/95{background-color:#fffffff2}@supports (color:color-mix(in lab, red, red)){.bg-white\/95{background-color:color-mix(in oklab, var(--color-white) 95%, transparent)}}.bg-white\/\[0\.02\]{background-color:#ffffff05}@supports (color:color-mix(in lab, red, red)){.bg-white\/\[0\.02\]{background-color:color-mix(in oklab, var(--color-white) 2%, transparent)}}.bg-white\/\[0\.03\]{background-color:#ffffff08}@supports (color:color-mix(in lab, red, red)){.bg-white\/\[0\.03\]{background-color:color-mix(in oklab, var(--color-white) 3%, transparent)}}.bg-gradient-to-b{--tw-gradient-position:to bottom in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.bg-gradient-to-r{--tw-gradient-position:to right in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.bg-gradient-to-t{--tw-gradient-position:to top in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.bg-gradient-to-tr{--tw-gradient-position:to top right in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.bg-\[radial-gradient\(ellipse_at_top_right\,_var\(--tw-gradient-stops\)\)\]{background-image:radial-gradient(ellipse at top right, var(--tw-gradient-stops))}.from-\[\#006aff\]{--tw-gradient-from:#006aff;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-cyan-400{--tw-gradient-from:var(--color-cyan-400);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-cyan-500{--tw-gradient-from:var(--color-cyan-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-cyan-900\/30{--tw-gradient-from:#104e644d}@supports (color:color-mix(in lab, red, red)){.from-cyan-900\/30{--tw-gradient-from:color-mix(in oklab, var(--color-cyan-900) 30%, transparent)}}.from-cyan-900\/30{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-slate-900{--tw-gradient-from:var(--color-slate-900);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-slate-950{--tw-gradient-from:var(--color-slate-950);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-white{--tw-gradient-from:var(--color-white);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.via-\[\#00b2ff\]{--tw-gradient-via:#00b2ff;--tw-gradient-via-stops:var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.via-slate-200{--tw-gradient-via:var(--color-slate-200);--tw-gradient-via-stops:var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.via-slate-900{--tw-gradient-via:var(--color-slate-900);--tw-gradient-via-stops:var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.via-slate-900\/40{--tw-gradient-via:#0f172b66}@supports (color:color-mix(in lab, red, red)){.via-slate-900\/40{--tw-gradient-via:color-mix(in oklab, var(--color-slate-900) 40%, transparent)}}.via-slate-900\/40{--tw-gradient-via-stops:var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.via-slate-950{--tw-gradient-via:var(--color-slate-950);--tw-gradient-via-stops:var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.via-teal-300{--tw-gradient-via:var(--color-teal-300);--tw-gradient-via-stops:var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.to-\[\#00d6ff\]{--tw-gradient-to:#00d6ff;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-blue-500{--tw-gradient-to:var(--color-blue-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-blue-600{--tw-gradient-to:var(--color-blue-600);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-cyan-400{--tw-gradient-to:var(--color-cyan-400);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-slate-950{--tw-gradient-to:var(--color-slate-950);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-transparent{--tw-gradient-to:transparent;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.bg-clip-text{-webkit-background-clip:text;background-clip:text}.mask-repeat{-webkit-mask-repeat:repeat;mask-repeat:repeat}.object-contain{object-fit:contain}.object-cover{object-fit:cover}.p-0{padding:calc(var(--spacing) * 0)}.p-1{padding:calc(var(--spacing) * 1)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-3\.5{padding:calc(var(--spacing) * 3.5)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.px-0\.5{padding-inline:calc(var(--spacing) * .5)}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-3\.5{padding-inline:calc(var(--spacing) * 3.5)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-8{padding-inline:calc(var(--spacing) * 8)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-3\.5{padding-block:calc(var(--spacing) * 3.5)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-10{padding-block:calc(var(--spacing) * 10)}.py-12{padding-block:calc(var(--spacing) * 12)}.py-16{padding-block:calc(var(--spacing) * 16)}.py-20{padding-block:calc(var(--spacing) * 20)}.py-24{padding-block:calc(var(--spacing) * 24)}.pt-0\.5{padding-top:calc(var(--spacing) * .5)}.pt-1{padding-top:calc(var(--spacing) * 1)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-6{padding-top:calc(var(--spacing) * 6)}.pt-8{padding-top:calc(var(--spacing) * 8)}.pr-4{padding-right:calc(var(--spacing) * 4)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pb-6{padding-bottom:calc(var(--spacing) * 6)}.pb-12{padding-bottom:calc(var(--spacing) * 12)}.pb-16{padding-bottom:calc(var(--spacing) * 16)}.pb-px{padding-bottom:1px}.pl-9{padding-left:calc(var(--spacing) * 9)}.pl-\[52px\]{padding-left:52px}.text-center{text-align:center}.text-justify{text-align:justify}.text-left{text-align:left}.text-right{text-align:right}.font-\[\'Bricolage_Grotesque\'\]{font-family:Bricolage Grotesque}.font-mono{font-family:var(--font-mono)}.font-sans{font-family:var(--font-sans)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[8px\]{font-size:8px}.text-\[9px\]{font-size:9px}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[14px\]{font-size:14px}.text-\[15px\]{font-size:15px}.text-\[16px\]{font-size:16px}.leading-5{--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5)}.leading-6{--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6)}.leading-7{--tw-leading:calc(var(--spacing) * 7);line-height:calc(var(--spacing) * 7)}.leading-\[16px\]{--tw-leading:16px;line-height:16px}.leading-\[20px\]{--tw-leading:20px;line-height:20px}.leading-normal{--tw-leading:var(--leading-normal);line-height:var(--leading-normal)}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-extrabold{--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.text-wrap{text-wrap:wrap}.break-all{word-break:break-all}.text-\[\#1B1B18\],.text-\[\#1b1b18\]{color:#1b1b18}.text-\[\#2a5bd7\]{color:#2a5bd7}.text-\[\#706f6c\]{color:#706f6c}.text-\[\#273144\]{color:#273144}.text-\[\#F3BEC7\]{color:#f3bec7}.text-\[\#F8B803\]{color:#f8b803}.text-\[\#F53003\],.text-\[\#f53003\]{color:#f53003}.text-amber-500{color:var(--color-amber-500)}.text-amber-600{color:var(--color-amber-600)}.text-amber-800{color:var(--color-amber-800)}.text-amber-900{color:var(--color-amber-900)}.text-black{color:var(--color-black)}.text-blue-500{color:var(--color-blue-500)}.text-blue-600{color:var(--color-blue-600)}.text-cyan-400{color:var(--color-cyan-400)}.text-cyan-500{color:var(--color-cyan-500)}.text-emerald-400{color:var(--color-emerald-400)}.text-emerald-500{color:var(--color-emerald-500)}.text-emerald-600{color:var(--color-emerald-600)}.text-emerald-700{color:var(--color-emerald-700)}.text-emerald-800{color:var(--color-emerald-800)}.text-gray-200{color:var(--color-gray-200)}.text-gray-300{color:var(--color-gray-300)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-800{color:var(--color-gray-800)}.text-gray-900{color:var(--color-gray-900)}.text-gray-950{color:var(--color-gray-950)}.text-green-500{color:var(--color-green-500)}.text-indigo-500{color:var(--color-indigo-500)}.text-indigo-600{color:var(--color-indigo-600)}.text-neutral-300{color:var(--color-neutral-300)}.text-neutral-400{color:var(--color-neutral-400)}.text-neutral-500{color:var(--color-neutral-500)}.text-neutral-800{color:var(--color-neutral-800)}.text-neutral-900{color:var(--color-neutral-900)}.text-pink-500{color:var(--color-pink-500)}.text-purple-500{color:var(--color-purple-500)}.text-red-500{color:var(--color-red-500)}.text-red-600{color:var(--color-red-600)}.text-rose-500{color:var(--color-rose-500)}.text-rose-600{color:var(--color-rose-600)}.text-sky-500{color:var(--color-sky-500)}.text-slate-100{color:var(--color-slate-100)}.text-slate-200{color:var(--color-slate-200)}.text-slate-300{color:var(--color-slate-300)}.text-slate-400{color:var(--color-slate-400)}.text-slate-500{color:var(--color-slate-500)}.text-slate-600{color:var(--color-slate-600)}.text-slate-950{color:var(--color-slate-950)}.text-teal-500{color:var(--color-teal-500)}.text-transparent{color:#0000}.text-white{color:var(--color-white)}.text-white\/80{color:#fffc}@supports (color:color-mix(in lab, red, red)){.text-white\/80{color:color-mix(in oklab, var(--color-white) 80%, transparent)}}.text-yellow-500{color:var(--color-yellow-500)}.capitalize{text-transform:capitalize}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.italic{font-style:italic}.ordinal{--tw-ordinal:ordinal;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.line-through{text-decoration-line:line-through}.overline{text-decoration-line:overline}.underline{text-decoration-line:underline}.decoration-gray-400\/50{text-decoration-color:#99a1af80}@supports (color:color-mix(in lab, red, red)){.decoration-gray-400\/50{-webkit-text-decoration-color:color-mix(in oklab, var(--color-gray-400) 50%, transparent);-webkit-text-decoration-color:color-mix(in oklab, var(--color-gray-400) 50%, transparent);text-decoration-color:color-mix(in oklab, var(--color-gray-400) 50%, transparent)}}.underline-offset-4{text-underline-offset:4px}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.subpixel-antialiased{-webkit-font-smoothing:auto;-moz-osx-font-smoothing:auto}.placeholder-neutral-300::placeholder{color:var(--color-neutral-300)}.placeholder-slate-500::placeholder{color:var(--color-slate-500)}.opacity-0{opacity:0}.opacity-20{opacity:.2}.opacity-25{opacity:.25}.opacity-40{opacity:.4}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-95{opacity:.95}.opacity-100{opacity:1}.mix-blend-color{mix-blend-mode:color}.mix-blend-darken{mix-blend-mode:darken}.mix-blend-hard-light{mix-blend-mode:hard-light}.mix-blend-multiply{mix-blend-mode:multiply}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0px_0px_1px_0px_rgba\(0\,0\,0\,0\.03\)\,0px_1px_2px_0px_rgba\(0\,0\,0\,0\.06\)\]{--tw-shadow:0px 0px 1px 0px var(--tw-shadow-color,#00000008), 0px 1px 2px 0px var(--tw-shadow-color,#0000000f);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[inset_0px_0px_0px_1px_rgba\(26\,26\,0\,0\.16\)\]{--tw-shadow:inset 0px 0px 0px 1px var(--tw-shadow-color,#1a1a0029);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-none{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring,.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-pink-500\/10{--tw-shadow-color:#f6339a1a}@supports (color:color-mix(in lab, red, red)){.shadow-pink-500\/10{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-pink-500) 10%, transparent) var(--tw-shadow-alpha), transparent)}}.ring-gray-200{--tw-ring-color:var(--color-gray-200)}.ring-gray-300{--tw-ring-color:var(--color-gray-300)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.drop-shadow{--tw-drop-shadow-size:drop-shadow(0 1px 2px var(--tw-drop-shadow-color,#0000001a)) drop-shadow(0 1px 1px var(--tw-drop-shadow-color,#0000000f));--tw-drop-shadow:drop-shadow(0 1px 2px #0000001a) drop-shadow(0 1px 1px #0000000f);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.grayscale{--tw-grayscale:grayscale(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.invert{--tw-invert:invert(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.sepia{--tw-sepia:sepia(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter\!{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)!important}.backdrop-blur-md{--tw-backdrop-blur:blur(var(--blur-md));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-xl{--tw-backdrop-blur:blur(var(--blur-xl));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.delay-200{transition-delay:.2s}.delay-300{transition-delay:.3s}.delay-400{transition-delay:.4s}.duration-150{--tw-duration:.15s;transition-duration:.15s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.duration-500{--tw-duration:.5s;transition-duration:.5s}.duration-700{--tw-duration:.7s;transition-duration:.7s}.duration-750{--tw-duration:.75s;transition-duration:.75s}.ease-in{--tw-ease:var(--ease-in);transition-timing-function:var(--ease-in)}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.ease-linear{--tw-ease:linear;transition-timing-function:linear}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.select-all{-webkit-user-select:all;user-select:all}.select-none{-webkit-user-select:none;user-select:none}.\[--stroke-color\:\#1B1B18\]{--stroke-color:#1b1b18}.\[animation-delay\:-0\.3s\]{animation-delay:-.3s}.\[animation-delay\:-0\.15s\]{animation-delay:-.15s}.not-has-\[nav\]\:hidden:not(:has(:is(nav))){display:none}@media (hover:hover){.group-hover\:scale-105:is(:where(.group):hover *){--tw-scale-x:105%;--tw-scale-y:105%;--tw-scale-z:105%;scale:var(--tw-scale-x) var(--tw-scale-y)}.group-hover\:scale-110:is(:where(.group):hover *){--tw-scale-x:110%;--tw-scale-y:110%;--tw-scale-z:110%;scale:var(--tw-scale-x) var(--tw-scale-y)}.group-hover\:rotate-12:is(:where(.group):hover *){rotate:12deg}.group-hover\:text-cyan-400:is(:where(.group):hover *){color:var(--color-cyan-400)}.group-hover\:text-gray-900:is(:where(.group):hover *){color:var(--color-gray-900)}.group-hover\:text-neutral-900:is(:where(.group):hover *){color:var(--color-neutral-900)}}.selection\:bg-cyan-500 ::selection{background-color:var(--color-cyan-500)}.selection\:bg-cyan-500::selection{background-color:var(--color-cyan-500)}.selection\:text-slate-900 ::selection{color:var(--color-slate-900)}.selection\:text-slate-900::selection{color:var(--color-slate-900)}.before\:absolute:before{content:var(--tw-content);position:absolute}.before\:top-0:before{content:var(--tw-content);top:calc(var(--spacing) * 0)}.before\:top-1\/2:before{content:var(--tw-content);top:50%}.before\:bottom-0:before{content:var(--tw-content);bottom:calc(var(--spacing) * 0)}.before\:bottom-1\/2:before{content:var(--tw-content);bottom:50%}.before\:left-\[0\.4rem\]:before{content:var(--tw-content);left:.4rem}.before\:border-l:before{content:var(--tw-content);border-left-style:var(--tw-border-style);border-left-width:1px}.before\:border-\[\#e3e3e0\]:before{content:var(--tw-content);border-color:#e3e3e0}.focus-within\:ring-2:focus-within{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-within\:ring-neutral-900:focus-within{--tw-ring-color:var(--color-neutral-900)}@media (hover:hover){.hover\:scale-105:hover{--tw-scale-x:105%;--tw-scale-y:105%;--tw-scale-z:105%;scale:var(--tw-scale-x) var(--tw-scale-y)}.hover\:border-\[\#1915014a\]:hover{border-color:#1915014a}.hover\:border-\[\#19140035\]:hover{border-color:#19140035}.hover\:border-amber-200\/50:hover{border-color:#fee68580}@supports (color:color-mix(in lab, red, red)){.hover\:border-amber-200\/50:hover{border-color:color-mix(in oklab, var(--color-amber-200) 50%, transparent)}}.hover\:border-black:hover{border-color:var(--color-black)}.hover\:border-emerald-200\/50:hover{border-color:#a4f4cf80}@supports (color:color-mix(in lab, red, red)){.hover\:border-emerald-200\/50:hover{border-color:color-mix(in oklab, var(--color-emerald-200) 50%, transparent)}}.hover\:border-indigo-200\/50:hover{border-color:#c7d2ff80}@supports (color:color-mix(in lab, red, red)){.hover\:border-indigo-200\/50:hover{border-color:color-mix(in oklab, var(--color-indigo-200) 50%, transparent)}}.hover\:border-pink-200\/50:hover{border-color:#fccee880}@supports (color:color-mix(in lab, red, red)){.hover\:border-pink-200\/50:hover{border-color:color-mix(in oklab, var(--color-pink-200) 50%, transparent)}}.hover\:border-purple-200\/50:hover{border-color:#e9d5ff80}@supports (color:color-mix(in lab, red, red)){.hover\:border-purple-200\/50:hover{border-color:color-mix(in oklab, var(--color-purple-200) 50%, transparent)}}.hover\:border-rose-200\/50:hover{border-color:#ffccd380}@supports (color:color-mix(in lab, red, red)){.hover\:border-rose-200\/50:hover{border-color:color-mix(in oklab, var(--color-rose-200) 50%, transparent)}}.hover\:border-sky-200\/50:hover{border-color:#b8e6fe80}@supports (color:color-mix(in lab, red, red)){.hover\:border-sky-200\/50:hover{border-color:color-mix(in oklab, var(--color-sky-200) 50%, transparent)}}.hover\:border-slate-600:hover{border-color:var(--color-slate-600)}.hover\:border-slate-700:hover{border-color:var(--color-slate-700)}.hover\:bg-\[\#e4e4e7\]:hover{background-color:#e4e4e7}.hover\:bg-black:hover{background-color:var(--color-black)}.hover\:bg-cyan-400:hover{background-color:var(--color-cyan-400)}.hover\:bg-gray-50:hover{background-color:var(--color-gray-50)}.hover\:bg-gray-50\/60:hover{background-color:#f9fafb99}@supports (color:color-mix(in lab, red, red)){.hover\:bg-gray-50\/60:hover{background-color:color-mix(in oklab, var(--color-gray-50) 60%, transparent)}}.hover\:bg-gray-100:hover{background-color:var(--color-gray-100)}.hover\:bg-gray-100\/60:hover{background-color:#f3f4f699}@supports (color:color-mix(in lab, red, red)){.hover\:bg-gray-100\/60:hover{background-color:color-mix(in oklab, var(--color-gray-100) 60%, transparent)}}.hover\:bg-gray-200:hover{background-color:var(--color-gray-200)}.hover\:bg-gray-800:hover{background-color:var(--color-gray-800)}.hover\:bg-neutral-50:hover{background-color:var(--color-neutral-50)}.hover\:bg-neutral-800:hover{background-color:var(--color-neutral-800)}.hover\:bg-rose-500:hover{background-color:var(--color-rose-500)}.hover\:bg-slate-700:hover{background-color:var(--color-slate-700)}.hover\:bg-slate-800:hover{background-color:var(--color-slate-800)}.hover\:from-cyan-400:hover{--tw-gradient-from:var(--color-cyan-400);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.hover\:to-blue-500:hover{--tw-gradient-to:var(--color-blue-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.hover\:text-blue-700:hover{color:var(--color-blue-700)}.hover\:text-cyan-300:hover{color:var(--color-cyan-300)}.hover\:text-cyan-400:hover{color:var(--color-cyan-400)}.hover\:text-gray-500:hover{color:var(--color-gray-500)}.hover\:text-gray-600:hover{color:var(--color-gray-600)}.hover\:text-gray-700:hover{color:var(--color-gray-700)}.hover\:text-neutral-600:hover{color:var(--color-neutral-600)}.hover\:text-neutral-900:hover{color:var(--color-neutral-900)}.hover\:text-slate-200:hover{color:var(--color-slate-200)}.hover\:text-white:hover{color:var(--color-white)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-100:hover{opacity:1}.hover\:shadow:hover{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.hover\:shadow-md:hover{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.hover\:shadow-cyan-500\/20:hover{--tw-shadow-color:#00b7d733}@supports (color:color-mix(in lab, red, red)){.hover\:shadow-cyan-500\/20:hover{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-cyan-500) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.hover\:shadow-pink-500\/20:hover{--tw-shadow-color:#f6339a33}@supports (color:color-mix(in lab, red, red)){.hover\:shadow-pink-500\/20:hover{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-pink-500) 20%, transparent) var(--tw-shadow-alpha), transparent)}}}.focus\:border-emerald-500:focus{border-color:var(--color-emerald-500)}.focus\:ring-1:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-cyan-500:focus{--tw-ring-color:var(--color-cyan-500)}.focus\:ring-emerald-500:focus{--tw-ring-color:var(--color-emerald-500)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:text-black:disabled{color:var(--color-black)}.disabled\:opacity-50:disabled{opacity:.5}@media (min-width:40rem){.sm\:col-span-3{grid-column:span 3/span 3}.sm\:flex{display:flex}.sm\:translate-y-0{--tw-translate-y:calc(var(--spacing) * 0);translate:var(--tw-translate-x) var(--tw-translate-y)}.sm\:scale-95{--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x) var(--tw-scale-y)}.sm\:scale-100{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:items-end{align-items:flex-end}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}.sm\:text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.sm\:text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.sm\:text-6xl{font-size:var(--text-6xl);line-height:var(--tw-leading,var(--text-6xl--line-height))}.sm\:text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}}@media (min-width:48rem){.md\:flex{display:flex}.md\:hidden{display:none}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:items-center{align-items:center}:where(.md\:space-y-0>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 0) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 0) * calc(1 - var(--tw-space-y-reverse)))}.md\:text-7xl{font-size:var(--text-7xl);line-height:var(--tw-leading,var(--text-7xl--line-height))}}@media (min-width:64rem){.lg\:col-span-1{grid-column:span 1/span 1}.lg\:col-span-2{grid-column:span 2/span 2}.lg\:col-span-3{grid-column:span 3/span 3}.lg\:mt-10{margin-top:calc(var(--spacing) * 10)}.lg\:mb-0{margin-bottom:calc(var(--spacing) * 0)}.lg\:mb-6{margin-bottom:calc(var(--spacing) * 6)}.lg\:-ml-px{margin-left:-1px}.lg\:ml-0{margin-left:calc(var(--spacing) * 0)}.lg\:block{display:block}.lg\:aspect-auto{aspect-ratio:auto}.lg\:w-1\/2{width:50%}.lg\:w-\[438px\]{width:438px}.lg\:max-w-4xl{max-width:var(--container-4xl)}.lg\:grow{flex-grow:1}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:flex-row-reverse{flex-direction:row-reverse}.lg\:justify-center{justify-content:center}.lg\:rounded-t-none{border-top-left-radius:0;border-top-right-radius:0}.lg\:rounded-tl-lg{border-top-left-radius:var(--radius-lg)}.lg\:rounded-r-lg{border-top-right-radius:var(--radius-lg);border-bottom-right-radius:var(--radius-lg)}.lg\:rounded-br-none{border-bottom-right-radius:0}.lg\:p-8{padding:calc(var(--spacing) * 8)}.lg\:p-20{padding:calc(var(--spacing) * 20)}.lg\:px-8{padding-inline:calc(var(--spacing) * 8)}.lg\:pb-10{padding-bottom:calc(var(--spacing) * 10)}}@media (min-width:80rem){.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (prefers-color-scheme:dark){:where(.dark\:divide-white\/5>:not(:last-child)){border-color:#ffffff0d}@supports (color:color-mix(in lab, red, red)){:where(.dark\:divide-white\/5>:not(:last-child)){border-color:color-mix(in oklab, var(--color-white) 5%, transparent)}}.dark\:border-\[\#3E3E3A\]{border-color:#3e3e3a}.dark\:border-\[\#eeeeec\]{border-color:#eeeeec}.dark\:border-amber-700{border-color:var(--color-amber-700)}.dark\:border-amber-900\/30{border-color:#7b33064d}@supports (color:color-mix(in lab, red, red)){.dark\:border-amber-900\/30{border-color:color-mix(in oklab, var(--color-amber-900) 30%, transparent)}}.dark\:border-amber-950\/20{border-color:#46190133}@supports (color:color-mix(in lab, red, red)){.dark\:border-amber-950\/20{border-color:color-mix(in oklab, var(--color-amber-950) 20%, transparent)}}.dark\:border-emerald-600{border-color:var(--color-emerald-600)}.dark\:border-emerald-800{border-color:var(--color-emerald-800)}.dark\:border-gray-700{border-color:var(--color-gray-700)}.dark\:border-gray-700\/50{border-color:#36415380}@supports (color:color-mix(in lab, red, red)){.dark\:border-gray-700\/50{border-color:color-mix(in oklab, var(--color-gray-700) 50%, transparent)}}.dark\:border-gray-800{border-color:var(--color-gray-800)}.dark\:border-gray-800\/10{border-color:#1e29391a}@supports (color:color-mix(in lab, red, red)){.dark\:border-gray-800\/10{border-color:color-mix(in oklab, var(--color-gray-800) 10%, transparent)}}.dark\:border-gray-800\/80{border-color:#1e2939cc}@supports (color:color-mix(in lab, red, red)){.dark\:border-gray-800\/80{border-color:color-mix(in oklab, var(--color-gray-800) 80%, transparent)}}.dark\:border-neutral-700{border-color:var(--color-neutral-700)}.dark\:border-neutral-800{border-color:var(--color-neutral-800)}.dark\:border-neutral-900{border-color:var(--color-neutral-900)}.dark\:border-red-900\/30{border-color:#82181a4d}@supports (color:color-mix(in lab, red, red)){.dark\:border-red-900\/30{border-color:color-mix(in oklab, var(--color-red-900) 30%, transparent)}}.dark\:border-white{border-color:var(--color-white)}.dark\:border-white\/10{border-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\:border-white\/10{border-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\:border-zinc-700{border-color:var(--color-zinc-700)}.dark\:bg-\[\#0C0C0C\]{background-color:#0c0c0c}.dark\:bg-\[\#0a0a0a\]{background-color:#0a0a0a}.dark\:bg-\[\#1D0002\]{background-color:#1d0002}.dark\:bg-\[\#3E3E3A\]{background-color:#3e3e3a}.dark\:bg-\[\#161615\]{background-color:#161615}.dark\:bg-\[\#eeeeec\]{background-color:#eeeeec}.dark\:bg-amber-500\/20{background-color:#f99c0033}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-500\/20{background-color:color-mix(in oklab, var(--color-amber-500) 20%, transparent)}}.dark\:bg-amber-950\/10{background-color:#4619011a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-950\/10{background-color:color-mix(in oklab, var(--color-amber-950) 10%, transparent)}}.dark\:bg-amber-950\/20{background-color:#46190133}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-950\/20{background-color:color-mix(in oklab, var(--color-amber-950) 20%, transparent)}}.dark\:bg-amber-950\/30{background-color:#4619014d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-950\/30{background-color:color-mix(in oklab, var(--color-amber-950) 30%, transparent)}}.dark\:bg-amber-950\/50{background-color:#46190180}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-950\/50{background-color:color-mix(in oklab, var(--color-amber-950) 50%, transparent)}}.dark\:bg-blue-950\/50{background-color:#16245680}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-950\/50{background-color:color-mix(in oklab, var(--color-blue-950) 50%, transparent)}}.dark\:bg-cyan-950\/50{background-color:#05334580}@supports (color:color-mix(in lab, red, red)){.dark\:bg-cyan-950\/50{background-color:color-mix(in oklab, var(--color-cyan-950) 50%, transparent)}}.dark\:bg-emerald-500\/5{background-color:#00bb7f0d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-emerald-500\/5{background-color:color-mix(in oklab, var(--color-emerald-500) 5%, transparent)}}.dark\:bg-emerald-900{background-color:var(--color-emerald-900)}.dark\:bg-emerald-950{background-color:var(--color-emerald-950)}.dark\:bg-emerald-950\/30{background-color:#002c224d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-emerald-950\/30{background-color:color-mix(in oklab, var(--color-emerald-950) 30%, transparent)}}.dark\:bg-emerald-950\/50{background-color:#002c2280}@supports (color:color-mix(in lab, red, red)){.dark\:bg-emerald-950\/50{background-color:color-mix(in oklab, var(--color-emerald-950) 50%, transparent)}}.dark\:bg-gray-100{background-color:var(--color-gray-100)}.dark\:bg-gray-800{background-color:var(--color-gray-800)}.dark\:bg-gray-800\/10{background-color:#1e29391a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-gray-800\/10{background-color:color-mix(in oklab, var(--color-gray-800) 10%, transparent)}}.dark\:bg-gray-800\/50{background-color:#1e293980}@supports (color:color-mix(in lab, red, red)){.dark\:bg-gray-800\/50{background-color:color-mix(in oklab, var(--color-gray-800) 50%, transparent)}}.dark\:bg-gray-900{background-color:var(--color-gray-900)}.dark\:bg-gray-900\/30{background-color:#1018284d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-gray-900\/30{background-color:color-mix(in oklab, var(--color-gray-900) 30%, transparent)}}.dark\:bg-gray-900\/40{background-color:#10182866}@supports (color:color-mix(in lab, red, red)){.dark\:bg-gray-900\/40{background-color:color-mix(in oklab, var(--color-gray-900) 40%, transparent)}}.dark\:bg-gray-900\/50{background-color:#10182880}@supports (color:color-mix(in lab, red, red)){.dark\:bg-gray-900\/50{background-color:color-mix(in oklab, var(--color-gray-900) 50%, transparent)}}.dark\:bg-gray-900\/95{background-color:#101828f2}@supports (color:color-mix(in lab, red, red)){.dark\:bg-gray-900\/95{background-color:color-mix(in oklab, var(--color-gray-900) 95%, transparent)}}.dark\:bg-gray-950{background-color:var(--color-gray-950)}.dark\:bg-indigo-950\/30{background-color:#1e1a4d4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-indigo-950\/30{background-color:color-mix(in oklab, var(--color-indigo-950) 30%, transparent)}}.dark\:bg-indigo-950\/50{background-color:#1e1a4d80}@supports (color:color-mix(in lab, red, red)){.dark\:bg-indigo-950\/50{background-color:color-mix(in oklab, var(--color-indigo-950) 50%, transparent)}}.dark\:bg-neutral-400{background-color:var(--color-neutral-400)}.dark\:bg-neutral-500{background-color:var(--color-neutral-500)}.dark\:bg-neutral-600{background-color:var(--color-neutral-600)}.dark\:bg-neutral-800{background-color:var(--color-neutral-800)}.dark\:bg-neutral-900{background-color:var(--color-neutral-900)}.dark\:bg-neutral-950{background-color:var(--color-neutral-950)}.dark\:bg-pink-950\/30{background-color:#5104244d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-pink-950\/30{background-color:color-mix(in oklab, var(--color-pink-950) 30%, transparent)}}.dark\:bg-purple-950\/30{background-color:#3c03664d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-purple-950\/30{background-color:color-mix(in oklab, var(--color-purple-950) 30%, transparent)}}.dark\:bg-purple-950\/50{background-color:#3c036680}@supports (color:color-mix(in lab, red, red)){.dark\:bg-purple-950\/50{background-color:color-mix(in oklab, var(--color-purple-950) 50%, transparent)}}.dark\:bg-red-950\/20{background-color:#46080933}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-950\/20{background-color:color-mix(in oklab, var(--color-red-950) 20%, transparent)}}.dark\:bg-rose-500{background-color:var(--color-rose-500)}.dark\:bg-rose-950\/30{background-color:#4d02184d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-rose-950\/30{background-color:color-mix(in oklab, var(--color-rose-950) 30%, transparent)}}.dark\:bg-rose-950\/50{background-color:#4d021880}@supports (color:color-mix(in lab, red, red)){.dark\:bg-rose-950\/50{background-color:color-mix(in oklab, var(--color-rose-950) 50%, transparent)}}.dark\:bg-sky-950\/30{background-color:#052f4a4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-sky-950\/30{background-color:color-mix(in oklab, var(--color-sky-950) 30%, transparent)}}.dark\:bg-teal-950\/50{background-color:#022f2e80}@supports (color:color-mix(in lab, red, red)){.dark\:bg-teal-950\/50{background-color:color-mix(in oklab, var(--color-teal-950) 50%, transparent)}}.dark\:bg-white{background-color:var(--color-white)}.dark\:bg-white\/5{background-color:#ffffff0d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-white\/5{background-color:color-mix(in oklab, var(--color-white) 5%, transparent)}}.dark\:bg-white\/10{background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-white\/10{background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\:bg-white\/\[0\.02\]{background-color:#ffffff05}@supports (color:color-mix(in lab, red, red)){.dark\:bg-white\/\[0\.02\]{background-color:color-mix(in oklab, var(--color-white) 2%, transparent)}}.dark\:bg-zinc-800{background-color:var(--color-zinc-800)}.dark\:text-\[\#1C1C1A\]{color:#1c1c1a}.dark\:text-\[\#4B0600\]{color:#4b0600}.dark\:text-\[\#391800\]{color:#391800}.dark\:text-\[\#733000\]{color:#733000}.dark\:text-\[\#A1A09A\]{color:#a1a09a}.dark\:text-\[\#EDEDEC\]{color:#ededec}.dark\:text-\[\#F61500\]{color:#f61500}.dark\:text-\[\#FF4433\]{color:#f43}.dark\:text-amber-200{color:var(--color-amber-200)}.dark\:text-amber-300{color:var(--color-amber-300)}.dark\:text-amber-400{color:var(--color-amber-400)}.dark\:text-amber-500{color:var(--color-amber-500)}.dark\:text-black{color:var(--color-black)}.dark\:text-blue-400{color:var(--color-blue-400)}.dark\:text-cyan-400{color:var(--color-cyan-400)}.dark\:text-emerald-200{color:var(--color-emerald-200)}.dark\:text-emerald-400{color:var(--color-emerald-400)}.dark\:text-emerald-500{color:var(--color-emerald-500)}.dark\:text-gray-100{color:var(--color-gray-100)}.dark\:text-gray-200{color:var(--color-gray-200)}.dark\:text-gray-300{color:var(--color-gray-300)}.dark\:text-gray-400{color:var(--color-gray-400)}.dark\:text-gray-500{color:var(--color-gray-500)}.dark\:text-gray-800{color:var(--color-gray-800)}.dark\:text-gray-950{color:var(--color-gray-950)}.dark\:text-indigo-400{color:var(--color-indigo-400)}.dark\:text-neutral-300{color:var(--color-neutral-300)}.dark\:text-neutral-400{color:var(--color-neutral-400)}.dark\:text-neutral-500{color:var(--color-neutral-500)}.dark\:text-neutral-600{color:var(--color-neutral-600)}.dark\:text-neutral-700{color:var(--color-neutral-700)}.dark\:text-neutral-900{color:var(--color-neutral-900)}.dark\:text-neutral-950{color:var(--color-neutral-950)}.dark\:text-purple-400{color:var(--color-purple-400)}.dark\:text-red-400{color:var(--color-red-400)}.dark\:text-rose-400{color:var(--color-rose-400)}.dark\:text-teal-400{color:var(--color-teal-400)}.dark\:text-white{color:var(--color-white)}.dark\:placeholder-neutral-700::placeholder{color:var(--color-neutral-700)}.dark\:mix-blend-hard-light{mix-blend-mode:hard-light}.dark\:mix-blend-normal{mix-blend-mode:normal}.dark\:shadow-\[inset_0px_0px_0px_1px_\#fffaed2d\]{--tw-shadow:inset 0px 0px 0px 1px var(--tw-shadow-color,#fffaed2d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.dark\:ring-white\/10{--tw-ring-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\:ring-white\/10{--tw-ring-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\:\[--stroke-color\:\#FF750F\]{--stroke-color:#ff750f}@media (hover:hover){.dark\:group-hover\:text-white:is(:where(.group):hover *){color:var(--color-white)}}.dark\:before\:border-\[\#3E3E3A\]:before{content:var(--tw-content);border-color:#3e3e3a}.dark\:focus-within\:ring-white:focus-within{--tw-ring-color:var(--color-white)}@media (hover:hover){.dark\:hover\:border-\[\#3E3E3A\]:hover{border-color:#3e3e3a}.dark\:hover\:border-\[\#62605b\]:hover{border-color:#62605b}.dark\:hover\:border-amber-900\/50:hover{border-color:#7b330680}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:border-amber-900\/50:hover{border-color:color-mix(in oklab, var(--color-amber-900) 50%, transparent)}}.dark\:hover\:border-emerald-900\/50:hover{border-color:#004e3b80}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:border-emerald-900\/50:hover{border-color:color-mix(in oklab, var(--color-emerald-900) 50%, transparent)}}.dark\:hover\:border-indigo-900\/50:hover{border-color:#312c8580}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:border-indigo-900\/50:hover{border-color:color-mix(in oklab, var(--color-indigo-900) 50%, transparent)}}.dark\:hover\:border-pink-900\/50:hover{border-color:#86104380}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:border-pink-900\/50:hover{border-color:color-mix(in oklab, var(--color-pink-900) 50%, transparent)}}.dark\:hover\:border-purple-900\/50:hover{border-color:#59168b80}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:border-purple-900\/50:hover{border-color:color-mix(in oklab, var(--color-purple-900) 50%, transparent)}}.dark\:hover\:border-rose-900\/50:hover{border-color:#8b083680}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:border-rose-900\/50:hover{border-color:color-mix(in oklab, var(--color-rose-900) 50%, transparent)}}.dark\:hover\:border-sky-900\/50:hover{border-color:#024a7080}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:border-sky-900\/50:hover{border-color:color-mix(in oklab, var(--color-sky-900) 50%, transparent)}}.dark\:hover\:border-white:hover{border-color:var(--color-white)}.dark\:hover\:bg-gray-700:hover{background-color:var(--color-gray-700)}.dark\:hover\:bg-gray-800:hover{background-color:var(--color-gray-800)}.dark\:hover\:bg-gray-800\/50:hover{background-color:#1e293980}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-gray-800\/50:hover{background-color:color-mix(in oklab, var(--color-gray-800) 50%, transparent)}}.dark\:hover\:bg-neutral-200:hover{background-color:var(--color-neutral-200)}.dark\:hover\:bg-neutral-800:hover{background-color:var(--color-neutral-800)}.dark\:hover\:bg-rose-400:hover{background-color:var(--color-rose-400)}.dark\:hover\:bg-white:hover{background-color:var(--color-white)}.dark\:hover\:bg-white\/\[0\.03\]:hover{background-color:#ffffff08}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-white\/\[0\.03\]:hover{background-color:color-mix(in oklab, var(--color-white) 3%, transparent)}}.dark\:hover\:text-gray-200:hover{color:var(--color-gray-200)}.dark\:hover\:text-neutral-300:hover{color:var(--color-neutral-300)}.dark\:hover\:text-white:hover{color:var(--color-white)}}}@starting-style{.starting\:opacity-0{opacity:0}}@media (prefers-reduced-motion:no-preference){@starting-style{.motion-safe\:starting\:-translate-x-\[26px\]{--tw-translate-x:calc(26px * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}}@starting-style{.motion-safe\:starting\:-translate-x-\[51px\]{--tw-translate-x:calc(51px * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}}@starting-style{.motion-safe\:starting\:-translate-x-\[78px\]{--tw-translate-x:calc(78px * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}}@starting-style{.motion-safe\:starting\:-translate-x-\[102px\]{--tw-translate-x:calc(102px * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}}@starting-style{.motion-safe\:starting\:translate-y-6{--tw-translate-y:calc(var(--spacing) * 6);translate:var(--tw-translate-x) var(--tw-translate-y)}}}.\[_a\]\:border-current:is(a){border-color:currentColor}.\[_a\]\:\!text-current:is(a),.\[_code\]\:\!text-current:is(code),.\[_kbd\]\:\!text-current:is(kbd),.\[_strong\]\:\!text-current:is(strong){color:currentColor!important}}.short-url-card{position:relative!important}.short-url-card .fi-ta-actions{z-index:10!important;margin:0!important;padding:0!important;position:absolute!important;top:1.25rem!important;right:1.25rem!important}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"";inherits:false;initial-value:100%}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-content{syntax:"*";inherits:false;initial-value:""}@keyframes spin{to{transform:rotate(360deg)}}@keyframes ping{75%,to{opacity:0;transform:scale(2)}}@keyframes pulse{50%{opacity:.5}}@keyframes bounce{0%,to{animation-timing-function:cubic-bezier(.8,0,1,1);transform:translateY(-25%)}50%{animation-timing-function:cubic-bezier(0,0,.2,1);transform:none}} \ No newline at end of file +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-content:""}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-serif:ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-red-900:oklch(39.6% .141 25.723);--color-red-950:oklch(25.8% .092 26.042);--color-orange-50:oklch(98% .016 73.684);--color-orange-100:oklch(95.4% .038 75.164);--color-orange-200:oklch(90.1% .076 70.697);--color-orange-300:oklch(83.7% .128 66.29);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-orange-600:oklch(64.6% .222 41.116);--color-orange-700:oklch(55.3% .195 38.402);--color-orange-800:oklch(47% .157 37.304);--color-orange-900:oklch(40.8% .123 38.172);--color-orange-950:oklch(26.6% .079 36.259);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-700:oklch(55.5% .163 48.998);--color-amber-800:oklch(47.3% .137 46.201);--color-amber-900:oklch(41.4% .112 45.904);--color-amber-950:oklch(27.9% .077 45.635);--color-yellow-50:oklch(98.7% .026 102.212);--color-yellow-100:oklch(97.3% .071 103.193);--color-yellow-200:oklch(94.5% .129 101.54);--color-yellow-300:oklch(90.5% .182 98.111);--color-yellow-400:oklch(85.2% .199 91.936);--color-yellow-500:oklch(79.5% .184 86.047);--color-yellow-600:oklch(68.1% .162 75.834);--color-yellow-700:oklch(55.4% .135 66.442);--color-yellow-800:oklch(47.6% .114 61.907);--color-yellow-900:oklch(42.1% .095 57.708);--color-yellow-950:oklch(28.6% .066 53.813);--color-lime-50:oklch(98.6% .031 120.757);--color-lime-100:oklch(96.7% .067 122.328);--color-lime-200:oklch(93.8% .127 124.321);--color-lime-300:oklch(89.7% .196 126.665);--color-lime-400:oklch(84.1% .238 128.85);--color-lime-500:oklch(76.8% .233 130.85);--color-lime-600:oklch(64.8% .2 131.684);--color-lime-700:oklch(53.2% .157 131.589);--color-lime-800:oklch(45.3% .124 130.933);--color-lime-900:oklch(40.5% .101 131.063);--color-lime-950:oklch(27.4% .072 132.109);--color-green-50:oklch(98.2% .018 155.826);--color-green-100:oklch(96.2% .044 156.743);--color-green-200:oklch(92.5% .084 155.995);--color-green-300:oklch(87.1% .15 154.449);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--color-green-700:oklch(52.7% .154 150.069);--color-green-800:oklch(44.8% .119 151.328);--color-green-900:oklch(39.3% .095 152.535);--color-green-950:oklch(26.6% .065 152.934);--color-emerald-50:oklch(97.9% .021 166.113);--color-emerald-100:oklch(95% .052 163.051);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-300:oklch(84.5% .143 164.978);--color-emerald-400:oklch(76.5% .177 163.223);--color-emerald-500:oklch(69.6% .17 162.48);--color-emerald-600:oklch(59.6% .145 163.225);--color-emerald-700:oklch(50.8% .118 165.612);--color-emerald-800:oklch(43.2% .095 166.913);--color-emerald-900:oklch(37.8% .077 168.94);--color-emerald-950:oklch(26.2% .051 172.552);--color-teal-50:oklch(98.4% .014 180.72);--color-teal-100:oklch(95.3% .051 180.801);--color-teal-200:oklch(91% .096 180.426);--color-teal-300:oklch(85.5% .138 181.071);--color-teal-400:oklch(77.7% .152 181.912);--color-teal-500:oklch(70.4% .14 182.503);--color-teal-600:oklch(60% .118 184.704);--color-teal-700:oklch(51.1% .096 186.391);--color-teal-800:oklch(43.7% .078 188.216);--color-teal-900:oklch(38.6% .063 188.416);--color-teal-950:oklch(27.7% .046 192.524);--color-cyan-50:oklch(98.4% .019 200.873);--color-cyan-100:oklch(95.6% .045 203.388);--color-cyan-200:oklch(91.7% .08 205.041);--color-cyan-300:oklch(86.5% .127 207.078);--color-cyan-400:oklch(78.9% .154 211.53);--color-cyan-500:oklch(71.5% .143 215.221);--color-cyan-600:oklch(60.9% .126 221.723);--color-cyan-700:oklch(52% .105 223.128);--color-cyan-800:oklch(45% .085 224.283);--color-cyan-900:oklch(39.8% .07 227.392);--color-cyan-950:oklch(30.2% .056 229.695);--color-sky-50:oklch(97.7% .013 236.62);--color-sky-100:oklch(95.1% .026 236.824);--color-sky-200:oklch(90.1% .058 230.902);--color-sky-300:oklch(82.8% .111 230.318);--color-sky-400:oklch(74.6% .16 232.661);--color-sky-500:oklch(68.5% .169 237.323);--color-sky-600:oklch(58.8% .158 241.966);--color-sky-700:oklch(50% .134 242.749);--color-sky-800:oklch(44.3% .11 240.79);--color-sky-900:oklch(39.1% .09 240.876);--color-sky-950:oklch(29.3% .066 243.157);--color-blue-50:oklch(97% .014 254.604);--color-blue-100:oklch(93.2% .032 255.585);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-300:oklch(80.9% .105 251.813);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-blue-800:oklch(42.4% .199 265.638);--color-blue-900:oklch(37.9% .146 265.522);--color-blue-950:oklch(28.2% .091 267.935);--color-indigo-50:oklch(96.2% .018 272.314);--color-indigo-100:oklch(93% .034 272.788);--color-indigo-200:oklch(87% .065 274.039);--color-indigo-300:oklch(78.5% .115 274.713);--color-indigo-400:oklch(67.3% .182 276.935);--color-indigo-500:oklch(58.5% .233 277.117);--color-indigo-600:oklch(51.1% .262 276.966);--color-indigo-700:oklch(45.7% .24 277.023);--color-indigo-800:oklch(39.8% .195 277.366);--color-indigo-900:oklch(35.9% .144 278.697);--color-indigo-950:oklch(25.7% .09 281.288);--color-violet-50:oklch(96.9% .016 293.756);--color-violet-100:oklch(94.3% .029 294.588);--color-violet-200:oklch(89.4% .057 293.283);--color-violet-300:oklch(81.1% .111 293.571);--color-violet-400:oklch(70.2% .183 293.541);--color-violet-500:oklch(60.6% .25 292.717);--color-violet-600:oklch(54.1% .281 293.009);--color-violet-700:oklch(49.1% .27 292.581);--color-violet-800:oklch(43.2% .232 292.759);--color-violet-900:oklch(38% .189 293.745);--color-violet-950:oklch(28.3% .141 291.089);--color-purple-50:oklch(97.7% .014 308.299);--color-purple-100:oklch(94.6% .033 307.174);--color-purple-200:oklch(90.2% .063 306.703);--color-purple-300:oklch(82.7% .119 306.383);--color-purple-400:oklch(71.4% .203 305.504);--color-purple-500:oklch(62.7% .265 303.9);--color-purple-600:oklch(55.8% .288 302.321);--color-purple-700:oklch(49.6% .265 301.924);--color-purple-800:oklch(43.8% .218 303.724);--color-purple-900:oklch(38.1% .176 304.987);--color-purple-950:oklch(29.1% .149 302.717);--color-fuchsia-50:oklch(97.7% .017 320.058);--color-fuchsia-100:oklch(95.2% .037 318.852);--color-fuchsia-200:oklch(90.3% .076 319.62);--color-fuchsia-300:oklch(83.3% .145 321.434);--color-fuchsia-400:oklch(74% .238 322.16);--color-fuchsia-500:oklch(66.7% .295 322.15);--color-fuchsia-600:oklch(59.1% .293 322.896);--color-fuchsia-700:oklch(51.8% .253 323.949);--color-fuchsia-800:oklch(45.2% .211 324.591);--color-fuchsia-900:oklch(40.1% .17 325.612);--color-fuchsia-950:oklch(29.3% .136 325.661);--color-pink-50:oklch(97.1% .014 343.198);--color-pink-100:oklch(94.8% .028 342.258);--color-pink-200:oklch(89.9% .061 343.231);--color-pink-300:oklch(82.3% .12 346.018);--color-pink-400:oklch(71.8% .202 349.761);--color-pink-500:oklch(65.6% .241 354.308);--color-pink-600:oklch(59.2% .249 .584);--color-pink-700:oklch(52.5% .223 3.958);--color-pink-800:oklch(45.9% .187 3.815);--color-pink-900:oklch(40.8% .153 2.432);--color-pink-950:oklch(28.4% .109 3.907);--color-rose-50:oklch(96.9% .015 12.422);--color-rose-100:oklch(94.1% .03 12.58);--color-rose-200:oklch(89.2% .058 10.001);--color-rose-300:oklch(81% .117 11.638);--color-rose-400:oklch(71.2% .194 13.428);--color-rose-500:oklch(64.5% .246 16.439);--color-rose-600:oklch(58.6% .253 17.585);--color-rose-700:oklch(51.4% .222 16.935);--color-rose-800:oklch(45.5% .188 13.697);--color-rose-900:oklch(41% .159 10.272);--color-rose-950:oklch(27.1% .105 12.094);--color-slate-50:oklch(98.4% .003 247.858);--color-slate-100:oklch(96.8% .007 247.896);--color-slate-200:oklch(92.9% .013 255.508);--color-slate-300:oklch(86.9% .022 252.894);--color-slate-400:oklch(70.4% .04 256.788);--color-slate-500:oklch(55.4% .046 257.417);--color-slate-600:oklch(44.6% .043 257.281);--color-slate-700:oklch(37.2% .044 257.287);--color-slate-800:oklch(27.9% .041 260.031);--color-slate-900:oklch(20.8% .042 265.755);--color-slate-950:oklch(12.9% .042 264.695);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-800:oklch(27.8% .033 256.848);--color-gray-900:oklch(21% .034 264.665);--color-gray-950:oklch(13% .028 261.692);--color-zinc-50:oklch(98.5% 0 0);--color-zinc-100:oklch(96.7% .001 286.375);--color-zinc-200:oklch(92% .004 286.32);--color-zinc-300:oklch(87.1% .006 286.286);--color-zinc-400:oklch(70.5% .015 286.067);--color-zinc-500:oklch(55.2% .016 285.938);--color-zinc-600:oklch(44.2% .017 285.786);--color-zinc-700:oklch(37% .013 285.805);--color-zinc-800:oklch(27.4% .006 286.033);--color-zinc-900:oklch(21% .006 285.885);--color-zinc-950:oklch(14.1% .005 285.823);--color-neutral-50:oklch(98.5% 0 0);--color-neutral-100:oklch(97% 0 0);--color-neutral-200:oklch(92.2% 0 0);--color-neutral-300:oklch(87% 0 0);--color-neutral-400:oklch(70.8% 0 0);--color-neutral-500:oklch(55.6% 0 0);--color-neutral-600:oklch(43.9% 0 0);--color-neutral-700:oklch(37.1% 0 0);--color-neutral-800:oklch(26.9% 0 0);--color-neutral-900:oklch(20.5% 0 0);--color-neutral-950:oklch(14.5% 0 0);--color-stone-50:oklch(98.5% .001 106.423);--color-stone-100:oklch(97% .001 106.424);--color-stone-200:oklch(92.3% .003 48.717);--color-stone-300:oklch(86.9% .005 56.366);--color-stone-400:oklch(70.9% .01 56.259);--color-stone-500:oklch(55.3% .013 58.071);--color-stone-600:oklch(44.4% .011 73.639);--color-stone-700:oklch(37.4% .01 67.558);--color-stone-800:oklch(26.8% .007 34.298);--color-stone-900:oklch(21.6% .006 56.043);--color-stone-950:oklch(14.7% .004 49.25);--color-black:#000;--color-white:#fff;--spacing:.25rem;--breakpoint-sm:40rem;--breakpoint-md:48rem;--breakpoint-lg:64rem;--breakpoint-xl:80rem;--breakpoint-2xl:96rem;--container-3xs:16rem;--container-2xs:18rem;--container-xs:20rem;--container-sm:24rem;--container-md:28rem;--container-lg:32rem;--container-xl:36rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--container-6xl:72rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--text-6xl:3.75rem;--text-6xl--line-height:1;--text-7xl:4.5rem;--text-7xl--line-height:1;--text-8xl:6rem;--text-8xl--line-height:1;--text-9xl:8rem;--text-9xl--line-height:1;--font-weight-thin:100;--font-weight-extralight:200;--font-weight-light:300;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--font-weight-extrabold:800;--font-weight-black:900;--tracking-tighter:-.05em;--tracking-tight:-.025em;--tracking-normal:0em;--tracking-wide:.025em;--tracking-wider:.05em;--tracking-widest:.1em;--leading-tight:1.25;--leading-snug:1.375;--leading-normal:1.5;--leading-relaxed:1.625;--leading-loose:2;--radius-xs:.125rem;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--radius-3xl:1.5rem;--radius-4xl:2rem;--shadow-2xs:0 1px #0000000d;--shadow-xs:0 1px 2px 0 #0000000d;--shadow-sm:0 1px 3px 0 #0000001a, 0 1px 2px -1px #0000001a;--shadow-md:0 4px 6px -1px #0000001a, 0 2px 4px -2px #0000001a;--shadow-lg:0 10px 15px -3px #0000001a, 0 4px 6px -4px #0000001a;--shadow-xl:0 20px 25px -5px #0000001a, 0 8px 10px -6px #0000001a;--shadow-2xl:0 25px 50px -12px #00000040;--inset-shadow-2xs:inset 0 1px #0000000d;--inset-shadow-xs:inset 0 1px 1px #0000000d;--inset-shadow-sm:inset 0 2px 4px #0000000d;--drop-shadow-xs:0 1px 1px #0000000d;--drop-shadow-sm:0 1px 2px #00000026;--drop-shadow-md:0 3px 3px #0000001f;--drop-shadow-lg:0 4px 4px #00000026;--drop-shadow-xl:0 9px 7px #0000001a;--drop-shadow-2xl:0 25px 25px #00000026;--ease-in:cubic-bezier(.4, 0, 1, 1);--ease-out:cubic-bezier(0, 0, .2, 1);--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-ping:ping 1s cubic-bezier(0, 0, .2, 1) infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--animate-bounce:bounce 1s infinite;--blur-xs:4px;--blur-sm:8px;--blur-md:12px;--blur-lg:16px;--blur-xl:24px;--blur-2xl:40px;--blur-3xl:64px;--perspective-dramatic:100px;--perspective-near:300px;--perspective-normal:500px;--perspective-midrange:800px;--perspective-distant:1200px;--aspect-video:16 / 9;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.\@container{container-type:inline-size}.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{inset:calc(var(--spacing) * 0)}.-top-1{top:calc(var(--spacing) * -1)}.top-0{top:calc(var(--spacing) * 0)}.top-0\.5{top:calc(var(--spacing) * .5)}.top-2{top:calc(var(--spacing) * 2)}.top-3{top:calc(var(--spacing) * 3)}.top-4{top:calc(var(--spacing) * 4)}.top-9{top:calc(var(--spacing) * 9)}.top-28{top:calc(var(--spacing) * 28)}.-right-1{right:calc(var(--spacing) * -1)}.-right-1\.5{right:calc(var(--spacing) * -1.5)}.right-0{right:calc(var(--spacing) * 0)}.right-0\.5{right:calc(var(--spacing) * .5)}.right-1{right:calc(var(--spacing) * 1)}.right-4{right:calc(var(--spacing) * 4)}.bottom-0{bottom:calc(var(--spacing) * 0)}.bottom-full{bottom:100%}.left-0{left:calc(var(--spacing) * 0)}.left-1\/2{left:50%}.left-2{left:calc(var(--spacing) * 2)}.left-3{left:calc(var(--spacing) * 3)}.left-4{left:calc(var(--spacing) * 4)}.left-\[22px\]{left:22px}.isolate{isolation:isolate}.z-0{z-index:0}.z-10{z-index:10}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-\[100\]{z-index:100}.col-span-2{grid-column:span 2/span 2}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.container\!{width:100%!important}@media (min-width:40rem){.container\!{max-width:40rem!important}}@media (min-width:48rem){.container\!{max-width:48rem!important}}@media (min-width:64rem){.container\!{max-width:64rem!important}}@media (min-width:80rem){.container\!{max-width:80rem!important}}@media (min-width:96rem){.container\!{max-width:96rem!important}}.m-0{margin:calc(var(--spacing) * 0)}.-mx-2{margin-inline:calc(var(--spacing) * -2)}.mx-auto{margin-inline:auto}.my-1{margin-block:calc(var(--spacing) * 1)}.my-4{margin-block:calc(var(--spacing) * 4)}.my-5{margin-block:calc(var(--spacing) * 5)}.my-auto{margin-block:auto}.-mt-\[6\.6rem\]{margin-top:-6.6rem}.-mt-px{margin-top:-1px}.mt-0{margin-top:calc(var(--spacing) * 0)}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-2\.5{margin-top:calc(var(--spacing) * 2.5)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mt-auto{margin-top:auto}.mr-1{margin-right:calc(var(--spacing) * 1)}.mr-2{margin-right:calc(var(--spacing) * 2)}.-mb-0\.5{margin-bottom:calc(var(--spacing) * -.5)}.-mb-px{margin-bottom:-1px}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-12{margin-bottom:calc(var(--spacing) * 12)}.-ml-1\.5{margin-left:calc(var(--spacing) * -1.5)}.-ml-8{margin-left:calc(var(--spacing) * -8)}.-ml-px{margin-left:-1px}.ml-1{margin-left:calc(var(--spacing) * 1)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-4{margin-left:calc(var(--spacing) * 4)}.ml-6{margin-left:calc(var(--spacing) * 6)}.ml-7{margin-left:calc(var(--spacing) * 7)}.ml-12{margin-left:calc(var(--spacing) * 12)}.ml-auto{margin-left:auto}.line-clamp-1{-webkit-line-clamp:1;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline\!{display:inline!important}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.inline-grid{display:inline-grid}.inline-table{display:inline-table}.list-item{display:list-item}.table{display:table}.table\!{display:table!important}.table-caption{display:table-caption}.table-cell{display:table-cell}.table-column{display:table-column}.table-column-group{display:table-column-group}.table-footer-group{display:table-footer-group}.table-header-group{display:table-header-group}.table-row{display:table-row}.table-row-group{display:table-row-group}.aspect-\[4\/3\]{aspect-ratio:4/3}.aspect-\[335\/364\]{aspect-ratio:335/364}.aspect-video{aspect-ratio:var(--aspect-video)}.size-3{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.h-0{height:calc(var(--spacing) * 0)}.h-0\.5{height:calc(var(--spacing) * .5)}.h-1{height:calc(var(--spacing) * 1)}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-2\/3{height:66.6667%}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-4\.5{height:calc(var(--spacing) * 4.5)}.h-5{height:calc(var(--spacing) * 5)}.h-5\.5{height:calc(var(--spacing) * 5.5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-11{height:calc(var(--spacing) * 11)}.h-12{height:calc(var(--spacing) * 12)}.h-14{height:calc(var(--spacing) * 14)}.h-14\.5{height:calc(var(--spacing) * 14.5)}.h-16{height:calc(var(--spacing) * 16)}.h-20{height:calc(var(--spacing) * 20)}.h-28{height:calc(var(--spacing) * 28)}.h-40{height:calc(var(--spacing) * 40)}.h-\[1px\]{height:1px}.h-\[44px\]{height:44px}.h-\[55vh\]{height:55vh}.h-\[60px\]{height:60px}.h-\[65vh\]{height:65vh}.h-\[85vh\]{height:85vh}.h-auto{height:auto}.h-full{height:100%}.min-h-\[280px\]{min-height:280px}.min-h-screen{min-height:100vh}.w-0{width:calc(var(--spacing) * 0)}.w-1{width:calc(var(--spacing) * 1)}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-3{width:calc(var(--spacing) * 3)}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-4{width:calc(var(--spacing) * 4)}.w-4\.5{width:calc(var(--spacing) * 4.5)}.w-5{width:calc(var(--spacing) * 5)}.w-5\.5{width:calc(var(--spacing) * 5.5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-11{width:calc(var(--spacing) * 11)}.w-12{width:calc(var(--spacing) * 12)}.w-16{width:calc(var(--spacing) * 16)}.w-20{width:calc(var(--spacing) * 20)}.w-28{width:calc(var(--spacing) * 28)}.w-32{width:calc(var(--spacing) * 32)}.w-44{width:calc(var(--spacing) * 44)}.w-48{width:calc(var(--spacing) * 48)}.w-\[1px\]{width:1px}.w-\[44px\]{width:44px}.w-\[438px\]{width:438px}.w-auto{width:auto}.w-fit{width:fit-content}.w-full{width:100%}.w-px{width:1px}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-7xl{max-width:var(--container-7xl)}.max-w-\[50ch\]{max-width:50ch}.max-w-\[130px\]{max-width:130px}.max-w-\[200px\]{max-width:200px}.max-w-\[280px\]{max-width:280px}.max-w-\[335px\]{max-width:335px}.max-w-\[360px\]{max-width:360px}.max-w-lg{max-width:var(--container-lg)}.max-w-max{max-width:max-content}.max-w-md{max-width:var(--container-md)}.max-w-none{max-width:none}.max-w-sm{max-width:var(--container-sm)}.max-w-xl{max-width:var(--container-xl)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:calc(var(--spacing) * 0)}.min-w-\[140px\]{min-width:140px}.min-w-max{min-width:max-content}.flex-1{flex:1}.flex-none{flex:none}.flex-shrink{flex-shrink:1}.flex-shrink-0,.shrink-0{flex-shrink:0}.flex-grow,.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.-translate-x-1\/2{--tw-translate-x:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-0{--tw-translate-y:calc(var(--spacing) * 0);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-1{--tw-translate-y:calc(var(--spacing) * 1);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-4{--tw-translate-y:calc(var(--spacing) * 4);translate:var(--tw-translate-x) var(--tw-translate-y)}.scale-95{--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x) var(--tw-scale-y)}.scale-100{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.-rotate-90{rotate:-90deg}.rotate-180{rotate:180deg}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.transform\!{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)!important}.animate-bounce{animation:var(--animate-bounce)}.animate-ping{animation:var(--animate-ping)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-col-resize{cursor:col-resize}.cursor-default{cursor:default}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize{resize:both}.resize\!{resize:both!important}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-\[28px_1fr_auto\]{grid-template-columns:28px 1fr auto}.grid-cols-\[140px_1fr\]{grid-template-columns:140px 1fr}.grid-cols-\[minmax\(0\,7rem\)_minmax\(0\,1fr\)\]{grid-template-columns:minmax(0,7rem) minmax(0,1fr)}.grid-cols-\[repeat\(4\,max-content\)\]{grid-template-columns:repeat(4,max-content)}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.justify-start{justify-content:flex-start}.justify-items-center{justify-items:center}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-3\.5{gap:calc(var(--spacing) * 3.5)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-8{gap:calc(var(--spacing) * 8)}.gap-12{gap:calc(var(--spacing) * 12)}.gap-16{gap:calc(var(--spacing) * 16)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-8>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 8) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-10>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 10) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 10) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-16>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 16) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 16) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-3{column-gap:calc(var(--spacing) * 3)}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-x-6{column-gap:calc(var(--spacing) * 6)}.gap-x-10{column-gap:calc(var(--spacing) * 10)}:where(.space-x-1>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 1) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-6>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 6) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-x-reverse)))}.gap-y-1{row-gap:calc(var(--spacing) * 1)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}.gap-y-6{row-gap:calc(var(--spacing) * 6)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-gray-100>:not(:last-child)){border-color:var(--color-gray-100)}.self-end{align-self:flex-end}.self-start{align-self:flex-start}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.scroll-smooth{scroll-behavior:smooth}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-3xl{border-radius:var(--radius-3xl)}.rounded-\[6px\]{border-radius:6px}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.rounded-xl{border-radius:var(--radius-xl)}.rounded-t-full{border-top-left-radius:3.40282e38px;border-top-right-radius:3.40282e38px}.rounded-t-lg{border-top-left-radius:var(--radius-lg);border-top-right-radius:var(--radius-lg)}.rounded-l-md{border-top-left-radius:var(--radius-md);border-bottom-left-radius:var(--radius-md)}.rounded-r-md{border-top-right-radius:var(--radius-md);border-bottom-right-radius:var(--radius-md)}.rounded-br-lg{border-bottom-right-radius:var(--radius-lg)}.rounded-bl-lg{border-bottom-left-radius:var(--radius-lg)}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-4{border-style:var(--tw-border-style);border-width:4px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-\[\#19140035\]{border-color:#19140035}.border-\[\#e3e3e0\]{border-color:#e3e3e0}.border-amber-200{border-color:var(--color-amber-200)}.border-black{border-color:var(--color-black)}.border-blue-100\/60{border-color:#dbeafe99}@supports (color:color-mix(in lab, red, red)){.border-blue-100\/60{border-color:color-mix(in oklab, var(--color-blue-100) 60%, transparent)}}.border-blue-200{border-color:var(--color-blue-200)}.border-cyan-500{border-color:var(--color-cyan-500)}.border-cyan-500\/20{border-color:#00b7d733}@supports (color:color-mix(in lab, red, red)){.border-cyan-500\/20{border-color:color-mix(in oklab, var(--color-cyan-500) 20%, transparent)}}.border-cyan-500\/30{border-color:#00b7d74d}@supports (color:color-mix(in lab, red, red)){.border-cyan-500\/30{border-color:color-mix(in oklab, var(--color-cyan-500) 30%, transparent)}}.border-emerald-200{border-color:var(--color-emerald-200)}.border-emerald-400{border-color:var(--color-emerald-400)}.border-emerald-500{border-color:var(--color-emerald-500)}.border-emerald-500\/20{border-color:#00bb7f33}@supports (color:color-mix(in lab, red, red)){.border-emerald-500\/20{border-color:color-mix(in oklab, var(--color-emerald-500) 20%, transparent)}}.border-gray-100{border-color:var(--color-gray-100)}.border-gray-200{border-color:var(--color-gray-200)}.border-gray-200\/50{border-color:#e5e7eb80}@supports (color:color-mix(in lab, red, red)){.border-gray-200\/50{border-color:color-mix(in oklab, var(--color-gray-200) 50%, transparent)}}.border-gray-300{border-color:var(--color-gray-300)}.border-gray-400{border-color:var(--color-gray-400)}.border-gray-900{border-color:var(--color-gray-900)}.border-green-200{border-color:var(--color-green-200)}.border-indigo-100{border-color:var(--color-indigo-100)}.border-indigo-100\/30{border-color:#e0e7ff4d}@supports (color:color-mix(in lab, red, red)){.border-indigo-100\/30{border-color:color-mix(in oklab, var(--color-indigo-100) 30%, transparent)}}.border-neutral-100{border-color:var(--color-neutral-100)}.border-neutral-200{border-color:var(--color-neutral-200)}.border-neutral-200\/60{border-color:#e5e5e599}@supports (color:color-mix(in lab, red, red)){.border-neutral-200\/60{border-color:color-mix(in oklab, var(--color-neutral-200) 60%, transparent)}}.border-neutral-200\/80{border-color:#e5e5e5cc}@supports (color:color-mix(in lab, red, red)){.border-neutral-200\/80{border-color:color-mix(in oklab, var(--color-neutral-200) 80%, transparent)}}.border-neutral-300{border-color:var(--color-neutral-300)}.border-neutral-900{border-color:var(--color-neutral-900)}.border-orange-100\/30{border-color:#ffedd54d}@supports (color:color-mix(in lab, red, red)){.border-orange-100\/30{border-color:color-mix(in oklab, var(--color-orange-100) 30%, transparent)}}.border-purple-200{border-color:var(--color-purple-200)}.border-red-100{border-color:var(--color-red-100)}.border-rose-400{border-color:var(--color-rose-400)}.border-slate-700{border-color:var(--color-slate-700)}.border-slate-800{border-color:var(--color-slate-800)}.border-slate-900{border-color:var(--color-slate-900)}.border-transparent{border-color:#0000}.border-white\/10{border-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.border-white\/10{border-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.border-t-current{border-top-color:currentColor}.bg-\[\#0f1419\]{background-color:#0f1419}.bg-\[\#1b1b18\]{background-color:#1b1b18}.bg-\[\#6b7280\]{background-color:#6b7280}.bg-\[\#25d366\]{background-color:#25d366}.bg-\[\#0077b5\]{background-color:#0077b5}.bg-\[\#1877f2\]{background-color:#1877f2}.bg-\[\#FCFCFC\]{background-color:#fcfcfc}.bg-\[\#FDFDFC\]{background-color:#fdfdfc}.bg-\[\#dbdbd7\]{background-color:#dbdbd7}.bg-\[\#f4f4f5\]{background-color:#f4f4f5}.bg-\[\#fff2f2\]{background-color:#fff2f2}.bg-amber-50{background-color:var(--color-amber-50)}.bg-amber-500\/10{background-color:#f99c001a}@supports (color:color-mix(in lab, red, red)){.bg-amber-500\/10{background-color:color-mix(in oklab, var(--color-amber-500) 10%, transparent)}}.bg-black{background-color:var(--color-black)}.bg-blue-50{background-color:var(--color-blue-50)}.bg-blue-50\/80{background-color:#eff6ffcc}@supports (color:color-mix(in lab, red, red)){.bg-blue-50\/80{background-color:color-mix(in oklab, var(--color-blue-50) 80%, transparent)}}.bg-blue-500\/10{background-color:#3080ff1a}@supports (color:color-mix(in lab, red, red)){.bg-blue-500\/10{background-color:color-mix(in oklab, var(--color-blue-500) 10%, transparent)}}.bg-current{background-color:currentColor}.bg-cyan-400{background-color:var(--color-cyan-400)}.bg-cyan-500{background-color:var(--color-cyan-500)}.bg-cyan-500\/10{background-color:#00b7d71a}@supports (color:color-mix(in lab, red, red)){.bg-cyan-500\/10{background-color:color-mix(in oklab, var(--color-cyan-500) 10%, transparent)}}.bg-cyan-950\/40{background-color:#05334566}@supports (color:color-mix(in lab, red, red)){.bg-cyan-950\/40{background-color:color-mix(in oklab, var(--color-cyan-950) 40%, transparent)}}.bg-emerald-50{background-color:var(--color-emerald-50)}.bg-emerald-100{background-color:var(--color-emerald-100)}.bg-emerald-400{background-color:var(--color-emerald-400)}.bg-emerald-500{background-color:var(--color-emerald-500)}.bg-emerald-500\/5{background-color:#00bb7f0d}@supports (color:color-mix(in lab, red, red)){.bg-emerald-500\/5{background-color:color-mix(in oklab, var(--color-emerald-500) 5%, transparent)}}.bg-emerald-500\/10{background-color:#00bb7f1a}@supports (color:color-mix(in lab, red, red)){.bg-emerald-500\/10{background-color:color-mix(in oklab, var(--color-emerald-500) 10%, transparent)}}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-50\/50{background-color:#f9fafb80}@supports (color:color-mix(in lab, red, red)){.bg-gray-50\/50{background-color:color-mix(in oklab, var(--color-gray-50) 50%, transparent)}}.bg-gray-50\/60{background-color:#f9fafb99}@supports (color:color-mix(in lab, red, red)){.bg-gray-50\/60{background-color:color-mix(in oklab, var(--color-gray-50) 60%, transparent)}}.bg-gray-100{background-color:var(--color-gray-100)}.bg-gray-100\/50{background-color:#f3f4f680}@supports (color:color-mix(in lab, red, red)){.bg-gray-100\/50{background-color:color-mix(in oklab, var(--color-gray-100) 50%, transparent)}}.bg-gray-200{background-color:var(--color-gray-200)}.bg-gray-500\/10{background-color:#6a72821a}@supports (color:color-mix(in lab, red, red)){.bg-gray-500\/10{background-color:color-mix(in oklab, var(--color-gray-500) 10%, transparent)}}.bg-gray-900{background-color:var(--color-gray-900)}.bg-gray-950\/40{background-color:#03071266}@supports (color:color-mix(in lab, red, red)){.bg-gray-950\/40{background-color:color-mix(in oklab, var(--color-gray-950) 40%, transparent)}}.bg-indigo-50{background-color:var(--color-indigo-50)}.bg-indigo-500{background-color:var(--color-indigo-500)}.bg-neutral-50{background-color:var(--color-neutral-50)}.bg-neutral-50\/20{background-color:#fafafa33}@supports (color:color-mix(in lab, red, red)){.bg-neutral-50\/20{background-color:color-mix(in oklab, var(--color-neutral-50) 20%, transparent)}}.bg-neutral-50\/50{background-color:#fafafa80}@supports (color:color-mix(in lab, red, red)){.bg-neutral-50\/50{background-color:color-mix(in oklab, var(--color-neutral-50) 50%, transparent)}}.bg-neutral-100{background-color:var(--color-neutral-100)}.bg-neutral-100\/80{background-color:#f5f5f5cc}@supports (color:color-mix(in lab, red, red)){.bg-neutral-100\/80{background-color:color-mix(in oklab, var(--color-neutral-100) 80%, transparent)}}.bg-neutral-200{background-color:var(--color-neutral-200)}.bg-neutral-300{background-color:var(--color-neutral-300)}.bg-neutral-400{background-color:var(--color-neutral-400)}.bg-neutral-500{background-color:var(--color-neutral-500)}.bg-neutral-900{background-color:var(--color-neutral-900)}.bg-orange-50{background-color:var(--color-orange-50)}.bg-pink-50{background-color:var(--color-pink-50)}.bg-pink-500\/10{background-color:#f6339a1a}@supports (color:color-mix(in lab, red, red)){.bg-pink-500\/10{background-color:color-mix(in oklab, var(--color-pink-500) 10%, transparent)}}.bg-purple-50{background-color:var(--color-purple-50)}.bg-purple-500{background-color:var(--color-purple-500)}.bg-purple-500\/10{background-color:#ac4bff1a}@supports (color:color-mix(in lab, red, red)){.bg-purple-500\/10{background-color:color-mix(in oklab, var(--color-purple-500) 10%, transparent)}}.bg-red-50{background-color:var(--color-red-50)}.bg-red-400{background-color:var(--color-red-400)}.bg-red-500{background-color:var(--color-red-500)}.bg-red-500\/10{background-color:#fb2c361a}@supports (color:color-mix(in lab, red, red)){.bg-red-500\/10{background-color:color-mix(in oklab, var(--color-red-500) 10%, transparent)}}.bg-rose-50{background-color:var(--color-rose-50)}.bg-rose-500{background-color:var(--color-rose-500)}.bg-rose-600{background-color:var(--color-rose-600)}.bg-sky-50{background-color:var(--color-sky-50)}.bg-slate-800{background-color:var(--color-slate-800)}.bg-slate-900{background-color:var(--color-slate-900)}.bg-slate-900\/60{background-color:#0f172b99}@supports (color:color-mix(in lab, red, red)){.bg-slate-900\/60{background-color:color-mix(in oklab, var(--color-slate-900) 60%, transparent)}}.bg-slate-900\/80{background-color:#0f172bcc}@supports (color:color-mix(in lab, red, red)){.bg-slate-900\/80{background-color:color-mix(in oklab, var(--color-slate-900) 80%, transparent)}}.bg-slate-950{background-color:var(--color-slate-950)}.bg-transparent{background-color:#0000}.bg-white{background-color:var(--color-white)}.bg-white\/40{background-color:#fff6}@supports (color:color-mix(in lab, red, red)){.bg-white\/40{background-color:color-mix(in oklab, var(--color-white) 40%, transparent)}}.bg-white\/50{background-color:#ffffff80}@supports (color:color-mix(in lab, red, red)){.bg-white\/50{background-color:color-mix(in oklab, var(--color-white) 50%, transparent)}}.bg-white\/95{background-color:#fffffff2}@supports (color:color-mix(in lab, red, red)){.bg-white\/95{background-color:color-mix(in oklab, var(--color-white) 95%, transparent)}}.bg-white\/\[0\.02\]{background-color:#ffffff05}@supports (color:color-mix(in lab, red, red)){.bg-white\/\[0\.02\]{background-color:color-mix(in oklab, var(--color-white) 2%, transparent)}}.bg-white\/\[0\.03\]{background-color:#ffffff08}@supports (color:color-mix(in lab, red, red)){.bg-white\/\[0\.03\]{background-color:color-mix(in oklab, var(--color-white) 3%, transparent)}}.bg-gradient-to-b{--tw-gradient-position:to bottom in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.bg-gradient-to-r{--tw-gradient-position:to right in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.bg-gradient-to-t{--tw-gradient-position:to top in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.bg-gradient-to-tr{--tw-gradient-position:to top right in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.bg-\[linear-gradient\(to_right\,\#8080800a_1px\,transparent_1px\)\,linear-gradient\(to_bottom\,\#8080800a_1px\,transparent_1px\)\]{background-image:linear-gradient(90deg,#8080800a 1px,#0000 1px),linear-gradient(#8080800a 1px,#0000 1px)}.bg-\[radial-gradient\(ellipse_at_top_right\,_var\(--tw-gradient-stops\)\)\]{background-image:radial-gradient(ellipse at top right, var(--tw-gradient-stops))}.from-\[\#006aff\]{--tw-gradient-from:#006aff;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-cyan-400{--tw-gradient-from:var(--color-cyan-400);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-cyan-500{--tw-gradient-from:var(--color-cyan-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-cyan-900\/30{--tw-gradient-from:#104e644d}@supports (color:color-mix(in lab, red, red)){.from-cyan-900\/30{--tw-gradient-from:color-mix(in oklab, var(--color-cyan-900) 30%, transparent)}}.from-cyan-900\/30{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-slate-900{--tw-gradient-from:var(--color-slate-900);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-slate-950{--tw-gradient-from:var(--color-slate-950);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-white{--tw-gradient-from:var(--color-white);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.via-\[\#00b2ff\]{--tw-gradient-via:#00b2ff;--tw-gradient-via-stops:var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.via-slate-200{--tw-gradient-via:var(--color-slate-200);--tw-gradient-via-stops:var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.via-slate-900{--tw-gradient-via:var(--color-slate-900);--tw-gradient-via-stops:var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.via-slate-900\/40{--tw-gradient-via:#0f172b66}@supports (color:color-mix(in lab, red, red)){.via-slate-900\/40{--tw-gradient-via:color-mix(in oklab, var(--color-slate-900) 40%, transparent)}}.via-slate-900\/40{--tw-gradient-via-stops:var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.via-slate-950{--tw-gradient-via:var(--color-slate-950);--tw-gradient-via-stops:var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.via-teal-300{--tw-gradient-via:var(--color-teal-300);--tw-gradient-via-stops:var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.to-\[\#00d6ff\]{--tw-gradient-to:#00d6ff;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-blue-500{--tw-gradient-to:var(--color-blue-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-blue-600{--tw-gradient-to:var(--color-blue-600);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-cyan-400{--tw-gradient-to:var(--color-cyan-400);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-slate-950{--tw-gradient-to:var(--color-slate-950);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-transparent{--tw-gradient-to:transparent;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.\[mask-image\:linear-gradient\(transparent\,black_15\%\,black_85\%\,transparent\)\]{-webkit-mask-image:linear-gradient(#0000,#000 15% 85%,#0000);mask-image:linear-gradient(#0000,#000 15% 85%,#0000)}.\[mask-image\:radial-gradient\(ellipse_60\%_50\%_at_50\%_0\%\,\#000_70\%\,transparent_100\%\)\]{-webkit-mask-image:radial-gradient(60% 50% at 50% 0,#000 70%,#0000 100%);mask-image:radial-gradient(60% 50% at 50% 0,#000 70%,#0000 100%)}.bg-\[size\:14px_24px\]{background-size:14px 24px}.bg-clip-text{-webkit-background-clip:text;background-clip:text}.mask-repeat{-webkit-mask-repeat:repeat;mask-repeat:repeat}.object-contain{object-fit:contain}.object-cover{object-fit:cover}.p-0{padding:calc(var(--spacing) * 0)}.p-1{padding:calc(var(--spacing) * 1)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-3\.5{padding:calc(var(--spacing) * 3.5)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.px-0\.5{padding-inline:calc(var(--spacing) * .5)}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-3\.5{padding-inline:calc(var(--spacing) * 3.5)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-8{padding-inline:calc(var(--spacing) * 8)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-3\.5{padding-block:calc(var(--spacing) * 3.5)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-5{padding-block:calc(var(--spacing) * 5)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-10{padding-block:calc(var(--spacing) * 10)}.py-12{padding-block:calc(var(--spacing) * 12)}.py-16{padding-block:calc(var(--spacing) * 16)}.py-20{padding-block:calc(var(--spacing) * 20)}.py-24{padding-block:calc(var(--spacing) * 24)}.pt-0\.5{padding-top:calc(var(--spacing) * .5)}.pt-1{padding-top:calc(var(--spacing) * 1)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-6{padding-top:calc(var(--spacing) * 6)}.pt-8{padding-top:calc(var(--spacing) * 8)}.pr-4{padding-right:calc(var(--spacing) * 4)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pb-6{padding-bottom:calc(var(--spacing) * 6)}.pb-12{padding-bottom:calc(var(--spacing) * 12)}.pb-16{padding-bottom:calc(var(--spacing) * 16)}.pb-px{padding-bottom:1px}.pl-2{padding-left:calc(var(--spacing) * 2)}.pl-9{padding-left:calc(var(--spacing) * 9)}.pl-\[52px\]{padding-left:52px}.text-center{text-align:center}.text-justify{text-align:justify}.text-left{text-align:left}.text-right{text-align:right}.font-\[\'Bricolage_Grotesque\'\]{font-family:Bricolage Grotesque}.font-mono{font-family:var(--font-mono)}.font-sans{font-family:var(--font-sans)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[8px\]{font-size:8px}.text-\[9px\]{font-size:9px}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[14px\]{font-size:14px}.text-\[15px\]{font-size:15px}.text-\[16px\]{font-size:16px}.leading-5{--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5)}.leading-6{--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6)}.leading-7{--tw-leading:calc(var(--spacing) * 7);line-height:calc(var(--spacing) * 7)}.leading-\[16px\]{--tw-leading:16px;line-height:16px}.leading-\[20px\]{--tw-leading:20px;line-height:20px}.leading-normal{--tw-leading:var(--leading-normal);line-height:var(--leading-normal)}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-extrabold{--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.text-pretty{text-wrap:pretty}.text-wrap{text-wrap:wrap}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.text-\[\#1B1B18\],.text-\[\#1b1b18\]{color:#1b1b18}.text-\[\#2a5bd7\]{color:#2a5bd7}.text-\[\#706f6c\]{color:#706f6c}.text-\[\#273144\]{color:#273144}.text-\[\#F3BEC7\]{color:#f3bec7}.text-\[\#F8B803\]{color:#f8b803}.text-\[\#F53003\],.text-\[\#f53003\]{color:#f53003}.text-amber-500{color:var(--color-amber-500)}.text-amber-600{color:var(--color-amber-600)}.text-amber-800{color:var(--color-amber-800)}.text-amber-900{color:var(--color-amber-900)}.text-black{color:var(--color-black)}.text-blue-500{color:var(--color-blue-500)}.text-blue-600{color:var(--color-blue-600)}.text-cyan-400{color:var(--color-cyan-400)}.text-cyan-500{color:var(--color-cyan-500)}.text-emerald-400{color:var(--color-emerald-400)}.text-emerald-500{color:var(--color-emerald-500)}.text-emerald-600{color:var(--color-emerald-600)}.text-emerald-700{color:var(--color-emerald-700)}.text-emerald-800{color:var(--color-emerald-800)}.text-gray-200{color:var(--color-gray-200)}.text-gray-300{color:var(--color-gray-300)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-800{color:var(--color-gray-800)}.text-gray-900{color:var(--color-gray-900)}.text-gray-950{color:var(--color-gray-950)}.text-green-500{color:var(--color-green-500)}.text-indigo-500{color:var(--color-indigo-500)}.text-indigo-600{color:var(--color-indigo-600)}.text-indigo-700{color:var(--color-indigo-700)}.text-neutral-300{color:var(--color-neutral-300)}.text-neutral-400{color:var(--color-neutral-400)}.text-neutral-500{color:var(--color-neutral-500)}.text-neutral-600{color:var(--color-neutral-600)}.text-neutral-700{color:var(--color-neutral-700)}.text-neutral-800{color:var(--color-neutral-800)}.text-neutral-900{color:var(--color-neutral-900)}.text-neutral-950{color:var(--color-neutral-950)}.text-orange-600{color:var(--color-orange-600)}.text-pink-500{color:var(--color-pink-500)}.text-purple-500{color:var(--color-purple-500)}.text-red-500{color:var(--color-red-500)}.text-red-600{color:var(--color-red-600)}.text-rose-500{color:var(--color-rose-500)}.text-rose-600{color:var(--color-rose-600)}.text-rose-700{color:var(--color-rose-700)}.text-sky-500{color:var(--color-sky-500)}.text-slate-100{color:var(--color-slate-100)}.text-slate-200{color:var(--color-slate-200)}.text-slate-300{color:var(--color-slate-300)}.text-slate-400{color:var(--color-slate-400)}.text-slate-500{color:var(--color-slate-500)}.text-slate-600{color:var(--color-slate-600)}.text-slate-950{color:var(--color-slate-950)}.text-transparent{color:#0000}.text-white{color:var(--color-white)}.text-white\/80{color:#fffc}@supports (color:color-mix(in lab, red, red)){.text-white\/80{color:color-mix(in oklab, var(--color-white) 80%, transparent)}}.text-yellow-500{color:var(--color-yellow-500)}.capitalize{text-transform:capitalize}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.italic{font-style:italic}.ordinal{--tw-ordinal:ordinal;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.line-through{text-decoration-line:line-through}.overline{text-decoration-line:overline}.underline{text-decoration-line:underline}.decoration-gray-400\/50{text-decoration-color:#99a1af80}@supports (color:color-mix(in lab, red, red)){.decoration-gray-400\/50{-webkit-text-decoration-color:color-mix(in oklab, var(--color-gray-400) 50%, transparent);-webkit-text-decoration-color:color-mix(in oklab, var(--color-gray-400) 50%, transparent);text-decoration-color:color-mix(in oklab, var(--color-gray-400) 50%, transparent)}}.underline-offset-4{text-underline-offset:4px}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.subpixel-antialiased{-webkit-font-smoothing:auto;-moz-osx-font-smoothing:auto}.placeholder-neutral-300::placeholder{color:var(--color-neutral-300)}.placeholder-slate-500::placeholder{color:var(--color-slate-500)}.opacity-0{opacity:0}.opacity-20{opacity:.2}.opacity-25{opacity:.25}.opacity-40{opacity:.4}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-95{opacity:.95}.opacity-100{opacity:1}.mix-blend-color{mix-blend-mode:color}.mix-blend-darken{mix-blend-mode:darken}.mix-blend-hard-light{mix-blend-mode:hard-light}.mix-blend-multiply{mix-blend-mode:multiply}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0px_0px_1px_0px_rgba\(0\,0\,0\,0\.03\)\,0px_1px_2px_0px_rgba\(0\,0\,0\,0\.06\)\]{--tw-shadow:0px 0px 1px 0px var(--tw-shadow-color,#00000008), 0px 1px 2px 0px var(--tw-shadow-color,#0000000f);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[inset_0px_0px_0px_1px_rgba\(26\,26\,0\,0\.16\)\]{--tw-shadow:inset 0px 0px 0px 1px var(--tw-shadow-color,#1a1a0029);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-inner{--tw-shadow:inset 0 2px 4px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-none{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring,.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-pink-500\/10{--tw-shadow-color:#f6339a1a}@supports (color:color-mix(in lab, red, red)){.shadow-pink-500\/10{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-pink-500) 10%, transparent) var(--tw-shadow-alpha), transparent)}}.ring-gray-200{--tw-ring-color:var(--color-gray-200)}.ring-gray-300{--tw-ring-color:var(--color-gray-300)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.drop-shadow{--tw-drop-shadow-size:drop-shadow(0 1px 2px var(--tw-drop-shadow-color,#0000001a)) drop-shadow(0 1px 1px var(--tw-drop-shadow-color,#0000000f));--tw-drop-shadow:drop-shadow(0 1px 2px #0000001a) drop-shadow(0 1px 1px #0000000f);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.grayscale{--tw-grayscale:grayscale(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.invert{--tw-invert:invert(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.sepia{--tw-sepia:sepia(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter\!{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)!important}.backdrop-blur-\[1px\]{--tw-backdrop-blur:blur(1px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-md{--tw-backdrop-blur:blur(var(--blur-md));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-xl{--tw-backdrop-blur:blur(var(--blur-xl));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.delay-200{transition-delay:.2s}.delay-300{transition-delay:.3s}.delay-400{transition-delay:.4s}.duration-75{--tw-duration:75ms;transition-duration:75ms}.duration-150{--tw-duration:.15s;transition-duration:.15s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.duration-500{--tw-duration:.5s;transition-duration:.5s}.duration-700{--tw-duration:.7s;transition-duration:.7s}.duration-750{--tw-duration:.75s;transition-duration:.75s}.ease-in{--tw-ease:var(--ease-in);transition-timing-function:var(--ease-in)}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.ease-linear{--tw-ease:linear;transition-timing-function:linear}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.outline-none{--tw-outline-style:none;outline-style:none}.select-all{-webkit-user-select:all;user-select:all}.select-none{-webkit-user-select:none;user-select:none}.\[--stroke-color\:\#1B1B18\]{--stroke-color:#1b1b18}.\[animation-delay\:-0\.3s\]{animation-delay:-.3s}.\[animation-delay\:-0\.15s\]{animation-delay:-.15s}.not-has-\[nav\]\:hidden:not(:has(:is(nav))){display:none}@media (hover:hover){.group-hover\:scale-105:is(:where(.group):hover *){--tw-scale-x:105%;--tw-scale-y:105%;--tw-scale-z:105%;scale:var(--tw-scale-x) var(--tw-scale-y)}.group-hover\:scale-110:is(:where(.group):hover *){--tw-scale-x:110%;--tw-scale-y:110%;--tw-scale-z:110%;scale:var(--tw-scale-x) var(--tw-scale-y)}.group-hover\:rotate-12:is(:where(.group):hover *){rotate:12deg}.group-hover\:bg-neutral-400:is(:where(.group):hover *){background-color:var(--color-neutral-400)}.group-hover\:text-cyan-400:is(:where(.group):hover *){color:var(--color-cyan-400)}.group-hover\:text-gray-900:is(:where(.group):hover *){color:var(--color-gray-900)}.group-hover\:text-neutral-900:is(:where(.group):hover *){color:var(--color-neutral-900)}}.group-active\:bg-neutral-500:is(:where(.group):active *){background-color:var(--color-neutral-500)}.selection\:bg-cyan-500 ::selection{background-color:var(--color-cyan-500)}.selection\:bg-cyan-500::selection{background-color:var(--color-cyan-500)}.selection\:text-slate-900 ::selection{color:var(--color-slate-900)}.selection\:text-slate-900::selection{color:var(--color-slate-900)}.before\:absolute:before{content:var(--tw-content);position:absolute}.before\:-inset-x-1:before{content:var(--tw-content);inset-inline:calc(var(--spacing) * -1)}.before\:inset-y-0:before{content:var(--tw-content);inset-block:calc(var(--spacing) * 0)}.before\:top-0:before{content:var(--tw-content);top:calc(var(--spacing) * 0)}.before\:top-1\/2:before{content:var(--tw-content);top:50%}.before\:bottom-0:before{content:var(--tw-content);bottom:calc(var(--spacing) * 0)}.before\:bottom-1\/2:before{content:var(--tw-content);bottom:50%}.before\:left-\[0\.4rem\]:before{content:var(--tw-content);left:.4rem}.before\:rounded:before{content:var(--tw-content);border-radius:.25rem}.before\:border-l:before{content:var(--tw-content);border-left-style:var(--tw-border-style);border-left-width:1px}.before\:border-\[\#e3e3e0\]:before{content:var(--tw-content);border-color:#e3e3e0}.before\:opacity-0:before{content:var(--tw-content);opacity:0}.before\:content-\[\'\'\]:before{--tw-content:"";content:var(--tw-content)}.focus-within\:ring-2:focus-within{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-within\:ring-neutral-900:focus-within{--tw-ring-color:var(--color-neutral-900)}@media (hover:hover){.hover\:-translate-y-1:hover{--tw-translate-y:calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.hover\:scale-105:hover{--tw-scale-x:105%;--tw-scale-y:105%;--tw-scale-z:105%;scale:var(--tw-scale-x) var(--tw-scale-y)}.hover\:scale-\[1\.02\]:hover{scale:1.02}.hover\:cursor-copy:hover{cursor:copy}.hover\:border-\[\#1915014a\]:hover{border-color:#1915014a}.hover\:border-\[\#19140035\]:hover{border-color:#19140035}.hover\:border-amber-200\/50:hover{border-color:#fee68580}@supports (color:color-mix(in lab, red, red)){.hover\:border-amber-200\/50:hover{border-color:color-mix(in oklab, var(--color-amber-200) 50%, transparent)}}.hover\:border-black:hover{border-color:var(--color-black)}.hover\:border-emerald-200\/50:hover{border-color:#a4f4cf80}@supports (color:color-mix(in lab, red, red)){.hover\:border-emerald-200\/50:hover{border-color:color-mix(in oklab, var(--color-emerald-200) 50%, transparent)}}.hover\:border-indigo-200\/50:hover{border-color:#c7d2ff80}@supports (color:color-mix(in lab, red, red)){.hover\:border-indigo-200\/50:hover{border-color:color-mix(in oklab, var(--color-indigo-200) 50%, transparent)}}.hover\:border-neutral-300:hover{border-color:var(--color-neutral-300)}.hover\:border-pink-200\/50:hover{border-color:#fccee880}@supports (color:color-mix(in lab, red, red)){.hover\:border-pink-200\/50:hover{border-color:color-mix(in oklab, var(--color-pink-200) 50%, transparent)}}.hover\:border-purple-200\/50:hover{border-color:#e9d5ff80}@supports (color:color-mix(in lab, red, red)){.hover\:border-purple-200\/50:hover{border-color:color-mix(in oklab, var(--color-purple-200) 50%, transparent)}}.hover\:border-rose-200\/50:hover{border-color:#ffccd380}@supports (color:color-mix(in lab, red, red)){.hover\:border-rose-200\/50:hover{border-color:color-mix(in oklab, var(--color-rose-200) 50%, transparent)}}.hover\:border-sky-200\/50:hover{border-color:#b8e6fe80}@supports (color:color-mix(in lab, red, red)){.hover\:border-sky-200\/50:hover{border-color:color-mix(in oklab, var(--color-sky-200) 50%, transparent)}}.hover\:border-slate-600:hover{border-color:var(--color-slate-600)}.hover\:border-slate-700:hover{border-color:var(--color-slate-700)}.hover\:bg-\[\#e4e4e7\]:hover{background-color:#e4e4e7}.hover\:bg-black:hover{background-color:var(--color-black)}.hover\:bg-cyan-400:hover{background-color:var(--color-cyan-400)}.hover\:bg-gray-50:hover{background-color:var(--color-gray-50)}.hover\:bg-gray-50\/60:hover{background-color:#f9fafb99}@supports (color:color-mix(in lab, red, red)){.hover\:bg-gray-50\/60:hover{background-color:color-mix(in oklab, var(--color-gray-50) 60%, transparent)}}.hover\:bg-gray-100:hover{background-color:var(--color-gray-100)}.hover\:bg-gray-100\/60:hover{background-color:#f3f4f699}@supports (color:color-mix(in lab, red, red)){.hover\:bg-gray-100\/60:hover{background-color:color-mix(in oklab, var(--color-gray-100) 60%, transparent)}}.hover\:bg-gray-200:hover{background-color:var(--color-gray-200)}.hover\:bg-gray-800:hover{background-color:var(--color-gray-800)}.hover\:bg-neutral-50:hover{background-color:var(--color-neutral-50)}.hover\:bg-neutral-100:hover{background-color:var(--color-neutral-100)}.hover\:bg-neutral-200:hover{background-color:var(--color-neutral-200)}.hover\:bg-neutral-800:hover{background-color:var(--color-neutral-800)}.hover\:bg-rose-500:hover{background-color:var(--color-rose-500)}.hover\:bg-slate-700:hover{background-color:var(--color-slate-700)}.hover\:bg-slate-800:hover{background-color:var(--color-slate-800)}.hover\:from-cyan-400:hover{--tw-gradient-from:var(--color-cyan-400);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.hover\:to-blue-500:hover{--tw-gradient-to:var(--color-blue-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.hover\:text-blue-700:hover{color:var(--color-blue-700)}.hover\:text-cyan-300:hover{color:var(--color-cyan-300)}.hover\:text-cyan-400:hover{color:var(--color-cyan-400)}.hover\:text-gray-500:hover{color:var(--color-gray-500)}.hover\:text-gray-600:hover{color:var(--color-gray-600)}.hover\:text-gray-700:hover{color:var(--color-gray-700)}.hover\:text-indigo-600:hover{color:var(--color-indigo-600)}.hover\:text-indigo-900:hover{color:var(--color-indigo-900)}.hover\:text-neutral-600:hover{color:var(--color-neutral-600)}.hover\:text-neutral-700:hover{color:var(--color-neutral-700)}.hover\:text-neutral-900:hover{color:var(--color-neutral-900)}.hover\:text-slate-200:hover{color:var(--color-slate-200)}.hover\:text-white:hover{color:var(--color-white)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-100:hover{opacity:1}.hover\:shadow:hover{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.hover\:ring-4:hover{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.hover\:shadow-cyan-500\/20:hover{--tw-shadow-color:#00b7d733}@supports (color:color-mix(in lab, red, red)){.hover\:shadow-cyan-500\/20:hover{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-cyan-500) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.hover\:shadow-pink-500\/20:hover{--tw-shadow-color:#f6339a33}@supports (color:color-mix(in lab, red, red)){.hover\:shadow-pink-500\/20:hover{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-pink-500) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.hover\:ring-neutral-200:hover{--tw-ring-color:var(--color-neutral-200)}.hover\:before\:opacity-60:hover:before{content:var(--tw-content);opacity:.6}}.focus\:border-emerald-500:focus{border-color:var(--color-emerald-500)}.focus\:ring-1:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-cyan-500:focus{--tw-ring-color:var(--color-cyan-500)}.focus\:ring-emerald-500:focus{--tw-ring-color:var(--color-emerald-500)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.active\:bg-neutral-200:active{background-color:var(--color-neutral-200)}.active\:bg-neutral-300:active{background-color:var(--color-neutral-300)}.active\:before\:opacity-100:active:before{content:var(--tw-content);opacity:1}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:text-black:disabled{color:var(--color-black)}.disabled\:opacity-50:disabled{opacity:.5}.data-\[state\=open\]\:border-neutral-500[data-state=open]{border-color:var(--color-neutral-500)}.data-\[state\=open\]\:ring-4[data-state=open]{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}@media (min-width:40rem){.sm\:col-span-3{grid-column:span 3/span 3}.sm\:ml-auto{margin-left:auto}.sm\:flex{display:flex}.sm\:inline-flex{display:inline-flex}.sm\:translate-y-0{--tw-translate-y:calc(var(--spacing) * 0);translate:var(--tw-translate-x) var(--tw-translate-y)}.sm\:scale-95{--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x) var(--tw-scale-y)}.sm\:scale-100{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:items-end{align-items:flex-end}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}.sm\:text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.sm\:text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.sm\:text-6xl{font-size:var(--text-6xl);line-height:var(--tw-leading,var(--text-6xl--line-height))}.sm\:text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}@media (hover:hover){.sm\:group-hover\/card\:border-neutral-200:is(:where(.group\/card):hover *),.sm\:group-hover\/card\:data-\[state\=closed\]\:border-neutral-200:is(:where(.group\/card):hover *)[data-state=closed]{border-color:var(--color-neutral-200)}}}@media (min-width:48rem){.md\:flex{display:flex}.md\:hidden{display:none}.md\:min-h-\[500px\]{min-height:500px}.md\:min-h-\[550px\]{min-height:550px}.md\:max-w-\[180px\]{max-width:180px}.md\:max-w-md{max-width:var(--container-md)}.md\:max-w-sm{max-width:var(--container-sm)}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:items-center{align-items:center}:where(.md\:space-y-0>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 0) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 0) * calc(1 - var(--tw-space-y-reverse)))}.md\:text-7xl{font-size:var(--text-7xl);line-height:var(--tw-leading,var(--text-7xl--line-height))}}@media (min-width:64rem){.lg\:col-span-1{grid-column:span 1/span 1}.lg\:col-span-2{grid-column:span 2/span 2}.lg\:col-span-3{grid-column:span 3/span 3}.lg\:mt-10{margin-top:calc(var(--spacing) * 10)}.lg\:mb-0{margin-bottom:calc(var(--spacing) * 0)}.lg\:mb-6{margin-bottom:calc(var(--spacing) * 6)}.lg\:-ml-px{margin-left:-1px}.lg\:ml-0{margin-left:calc(var(--spacing) * 0)}.lg\:block{display:block}.lg\:aspect-auto{aspect-ratio:auto}.lg\:w-1\/2{width:50%}.lg\:w-\[438px\]{width:438px}.lg\:max-w-4xl{max-width:var(--container-4xl)}.lg\:grow{flex-grow:1}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:flex-row-reverse{flex-direction:row-reverse}.lg\:justify-center{justify-content:center}.lg\:rounded-t-none{border-top-left-radius:0;border-top-right-radius:0}.lg\:rounded-tl-lg{border-top-left-radius:var(--radius-lg)}.lg\:rounded-r-lg{border-top-right-radius:var(--radius-lg);border-bottom-right-radius:var(--radius-lg)}.lg\:rounded-br-none{border-bottom-right-radius:0}.lg\:p-8{padding:calc(var(--spacing) * 8)}.lg\:p-20{padding:calc(var(--spacing) * 20)}.lg\:px-8{padding-inline:calc(var(--spacing) * 8)}.lg\:pb-10{padding-bottom:calc(var(--spacing) * 10)}}@media (min-width:80rem){.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (prefers-color-scheme:dark){:where(.dark\:divide-white\/5>:not(:last-child)){border-color:#ffffff0d}@supports (color:color-mix(in lab, red, red)){:where(.dark\:divide-white\/5>:not(:last-child)){border-color:color-mix(in oklab, var(--color-white) 5%, transparent)}}.dark\:border-\[\#3E3E3A\]{border-color:#3e3e3a}.dark\:border-\[\#eeeeec\]{border-color:#eeeeec}.dark\:border-amber-700{border-color:var(--color-amber-700)}.dark\:border-amber-900\/30{border-color:#7b33064d}@supports (color:color-mix(in lab, red, red)){.dark\:border-amber-900\/30{border-color:color-mix(in oklab, var(--color-amber-900) 30%, transparent)}}.dark\:border-amber-950\/20{border-color:#46190133}@supports (color:color-mix(in lab, red, red)){.dark\:border-amber-950\/20{border-color:color-mix(in oklab, var(--color-amber-950) 20%, transparent)}}.dark\:border-blue-900\/40{border-color:#1c398e66}@supports (color:color-mix(in lab, red, red)){.dark\:border-blue-900\/40{border-color:color-mix(in oklab, var(--color-blue-900) 40%, transparent)}}.dark\:border-emerald-600{border-color:var(--color-emerald-600)}.dark\:border-emerald-800{border-color:var(--color-emerald-800)}.dark\:border-emerald-900\/40{border-color:#004e3b66}@supports (color:color-mix(in lab, red, red)){.dark\:border-emerald-900\/40{border-color:color-mix(in oklab, var(--color-emerald-900) 40%, transparent)}}.dark\:border-gray-700{border-color:var(--color-gray-700)}.dark\:border-gray-700\/50{border-color:#36415380}@supports (color:color-mix(in lab, red, red)){.dark\:border-gray-700\/50{border-color:color-mix(in oklab, var(--color-gray-700) 50%, transparent)}}.dark\:border-gray-800{border-color:var(--color-gray-800)}.dark\:border-gray-800\/10{border-color:#1e29391a}@supports (color:color-mix(in lab, red, red)){.dark\:border-gray-800\/10{border-color:color-mix(in oklab, var(--color-gray-800) 10%, transparent)}}.dark\:border-gray-800\/80{border-color:#1e2939cc}@supports (color:color-mix(in lab, red, red)){.dark\:border-gray-800\/80{border-color:color-mix(in oklab, var(--color-gray-800) 80%, transparent)}}.dark\:border-indigo-900\/30{border-color:#312c854d}@supports (color:color-mix(in lab, red, red)){.dark\:border-indigo-900\/30{border-color:color-mix(in oklab, var(--color-indigo-900) 30%, transparent)}}.dark\:border-indigo-900\/60{border-color:#312c8599}@supports (color:color-mix(in lab, red, red)){.dark\:border-indigo-900\/60{border-color:color-mix(in oklab, var(--color-indigo-900) 60%, transparent)}}.dark\:border-neutral-700{border-color:var(--color-neutral-700)}.dark\:border-neutral-800{border-color:var(--color-neutral-800)}.dark\:border-neutral-800\/80{border-color:#262626cc}@supports (color:color-mix(in lab, red, red)){.dark\:border-neutral-800\/80{border-color:color-mix(in oklab, var(--color-neutral-800) 80%, transparent)}}.dark\:border-neutral-900{border-color:var(--color-neutral-900)}.dark\:border-orange-900\/30{border-color:#7e2a0c4d}@supports (color:color-mix(in lab, red, red)){.dark\:border-orange-900\/30{border-color:color-mix(in oklab, var(--color-orange-900) 30%, transparent)}}.dark\:border-red-900\/30{border-color:#82181a4d}@supports (color:color-mix(in lab, red, red)){.dark\:border-red-900\/30{border-color:color-mix(in oklab, var(--color-red-900) 30%, transparent)}}.dark\:border-red-900\/40{border-color:#82181a66}@supports (color:color-mix(in lab, red, red)){.dark\:border-red-900\/40{border-color:color-mix(in oklab, var(--color-red-900) 40%, transparent)}}.dark\:border-white{border-color:var(--color-white)}.dark\:border-white\/10{border-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\:border-white\/10{border-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\:border-zinc-700{border-color:var(--color-zinc-700)}.dark\:bg-\[\#0C0C0C\]{background-color:#0c0c0c}.dark\:bg-\[\#0a0a0a\]{background-color:#0a0a0a}.dark\:bg-\[\#1D0002\]{background-color:#1d0002}.dark\:bg-\[\#3E3E3A\]{background-color:#3e3e3a}.dark\:bg-\[\#161615\]{background-color:#161615}.dark\:bg-\[\#eeeeec\]{background-color:#eeeeec}.dark\:bg-amber-500\/20{background-color:#f99c0033}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-500\/20{background-color:color-mix(in oklab, var(--color-amber-500) 20%, transparent)}}.dark\:bg-amber-950\/10{background-color:#4619011a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-950\/10{background-color:color-mix(in oklab, var(--color-amber-950) 10%, transparent)}}.dark\:bg-amber-950\/20{background-color:#46190133}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-950\/20{background-color:color-mix(in oklab, var(--color-amber-950) 20%, transparent)}}.dark\:bg-amber-950\/30{background-color:#4619014d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-950\/30{background-color:color-mix(in oklab, var(--color-amber-950) 30%, transparent)}}.dark\:bg-blue-950\/30{background-color:#1624564d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-950\/30{background-color:color-mix(in oklab, var(--color-blue-950) 30%, transparent)}}.dark\:bg-emerald-500\/5{background-color:#00bb7f0d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-emerald-500\/5{background-color:color-mix(in oklab, var(--color-emerald-500) 5%, transparent)}}.dark\:bg-emerald-900{background-color:var(--color-emerald-900)}.dark\:bg-emerald-950{background-color:var(--color-emerald-950)}.dark\:bg-emerald-950\/20{background-color:#002c2233}@supports (color:color-mix(in lab, red, red)){.dark\:bg-emerald-950\/20{background-color:color-mix(in oklab, var(--color-emerald-950) 20%, transparent)}}.dark\:bg-emerald-950\/30{background-color:#002c224d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-emerald-950\/30{background-color:color-mix(in oklab, var(--color-emerald-950) 30%, transparent)}}.dark\:bg-gray-100{background-color:var(--color-gray-100)}.dark\:bg-gray-800{background-color:var(--color-gray-800)}.dark\:bg-gray-800\/10{background-color:#1e29391a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-gray-800\/10{background-color:color-mix(in oklab, var(--color-gray-800) 10%, transparent)}}.dark\:bg-gray-800\/30{background-color:#1e29394d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-gray-800\/30{background-color:color-mix(in oklab, var(--color-gray-800) 30%, transparent)}}.dark\:bg-gray-800\/50{background-color:#1e293980}@supports (color:color-mix(in lab, red, red)){.dark\:bg-gray-800\/50{background-color:color-mix(in oklab, var(--color-gray-800) 50%, transparent)}}.dark\:bg-gray-900{background-color:var(--color-gray-900)}.dark\:bg-gray-900\/30{background-color:#1018284d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-gray-900\/30{background-color:color-mix(in oklab, var(--color-gray-900) 30%, transparent)}}.dark\:bg-gray-900\/40{background-color:#10182866}@supports (color:color-mix(in lab, red, red)){.dark\:bg-gray-900\/40{background-color:color-mix(in oklab, var(--color-gray-900) 40%, transparent)}}.dark\:bg-gray-900\/50{background-color:#10182880}@supports (color:color-mix(in lab, red, red)){.dark\:bg-gray-900\/50{background-color:color-mix(in oklab, var(--color-gray-900) 50%, transparent)}}.dark\:bg-gray-900\/95{background-color:#101828f2}@supports (color:color-mix(in lab, red, red)){.dark\:bg-gray-900\/95{background-color:color-mix(in oklab, var(--color-gray-900) 95%, transparent)}}.dark\:bg-gray-950{background-color:var(--color-gray-950)}.dark\:bg-indigo-950\/20{background-color:#1e1a4d33}@supports (color:color-mix(in lab, red, red)){.dark\:bg-indigo-950\/20{background-color:color-mix(in oklab, var(--color-indigo-950) 20%, transparent)}}.dark\:bg-indigo-950\/30{background-color:#1e1a4d4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-indigo-950\/30{background-color:color-mix(in oklab, var(--color-indigo-950) 30%, transparent)}}.dark\:bg-indigo-950\/40{background-color:#1e1a4d66}@supports (color:color-mix(in lab, red, red)){.dark\:bg-indigo-950\/40{background-color:color-mix(in oklab, var(--color-indigo-950) 40%, transparent)}}.dark\:bg-indigo-950\/50{background-color:#1e1a4d80}@supports (color:color-mix(in lab, red, red)){.dark\:bg-indigo-950\/50{background-color:color-mix(in oklab, var(--color-indigo-950) 50%, transparent)}}.dark\:bg-neutral-400{background-color:var(--color-neutral-400)}.dark\:bg-neutral-500{background-color:var(--color-neutral-500)}.dark\:bg-neutral-600{background-color:var(--color-neutral-600)}.dark\:bg-neutral-800{background-color:var(--color-neutral-800)}.dark\:bg-neutral-800\/50{background-color:#26262680}@supports (color:color-mix(in lab, red, red)){.dark\:bg-neutral-800\/50{background-color:color-mix(in oklab, var(--color-neutral-800) 50%, transparent)}}.dark\:bg-neutral-900{background-color:var(--color-neutral-900)}.dark\:bg-neutral-900\/30{background-color:#1717174d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-neutral-900\/30{background-color:color-mix(in oklab, var(--color-neutral-900) 30%, transparent)}}.dark\:bg-neutral-900\/50{background-color:#17171780}@supports (color:color-mix(in lab, red, red)){.dark\:bg-neutral-900\/50{background-color:color-mix(in oklab, var(--color-neutral-900) 50%, transparent)}}.dark\:bg-neutral-900\/95{background-color:#171717f2}@supports (color:color-mix(in lab, red, red)){.dark\:bg-neutral-900\/95{background-color:color-mix(in oklab, var(--color-neutral-900) 95%, transparent)}}.dark\:bg-neutral-950{background-color:var(--color-neutral-950)}.dark\:bg-neutral-950\/5{background-color:#0a0a0a0d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-neutral-950\/5{background-color:color-mix(in oklab, var(--color-neutral-950) 5%, transparent)}}.dark\:bg-neutral-950\/20{background-color:#0a0a0a33}@supports (color:color-mix(in lab, red, red)){.dark\:bg-neutral-950\/20{background-color:color-mix(in oklab, var(--color-neutral-950) 20%, transparent)}}.dark\:bg-neutral-950\/40{background-color:#0a0a0a66}@supports (color:color-mix(in lab, red, red)){.dark\:bg-neutral-950\/40{background-color:color-mix(in oklab, var(--color-neutral-950) 40%, transparent)}}.dark\:bg-orange-950\/20{background-color:#44130633}@supports (color:color-mix(in lab, red, red)){.dark\:bg-orange-950\/20{background-color:color-mix(in oklab, var(--color-orange-950) 20%, transparent)}}.dark\:bg-pink-950\/30{background-color:#5104244d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-pink-950\/30{background-color:color-mix(in oklab, var(--color-pink-950) 30%, transparent)}}.dark\:bg-purple-950\/30{background-color:#3c03664d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-purple-950\/30{background-color:color-mix(in oklab, var(--color-purple-950) 30%, transparent)}}.dark\:bg-red-950\/20{background-color:#46080933}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-950\/20{background-color:color-mix(in oklab, var(--color-red-950) 20%, transparent)}}.dark\:bg-red-950\/30{background-color:#4608094d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-950\/30{background-color:color-mix(in oklab, var(--color-red-950) 30%, transparent)}}.dark\:bg-rose-500{background-color:var(--color-rose-500)}.dark\:bg-rose-950\/30{background-color:#4d02184d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-rose-950\/30{background-color:color-mix(in oklab, var(--color-rose-950) 30%, transparent)}}.dark\:bg-sky-950\/30{background-color:#052f4a4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-sky-950\/30{background-color:color-mix(in oklab, var(--color-sky-950) 30%, transparent)}}.dark\:bg-white{background-color:var(--color-white)}.dark\:bg-white\/5{background-color:#ffffff0d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-white\/5{background-color:color-mix(in oklab, var(--color-white) 5%, transparent)}}.dark\:bg-white\/10{background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-white\/10{background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\:bg-white\/\[0\.02\]{background-color:#ffffff05}@supports (color:color-mix(in lab, red, red)){.dark\:bg-white\/\[0\.02\]{background-color:color-mix(in oklab, var(--color-white) 2%, transparent)}}.dark\:bg-zinc-800{background-color:var(--color-zinc-800)}.dark\:stroke-neutral-800{stroke:var(--color-neutral-800)}.dark\:text-\[\#1C1C1A\]{color:#1c1c1a}.dark\:text-\[\#4B0600\]{color:#4b0600}.dark\:text-\[\#391800\]{color:#391800}.dark\:text-\[\#733000\]{color:#733000}.dark\:text-\[\#A1A09A\]{color:#a1a09a}.dark\:text-\[\#EDEDEC\]{color:#ededec}.dark\:text-\[\#F61500\]{color:#f61500}.dark\:text-\[\#FF4433\]{color:#f43}.dark\:text-amber-200{color:var(--color-amber-200)}.dark\:text-amber-300{color:var(--color-amber-300)}.dark\:text-amber-400{color:var(--color-amber-400)}.dark\:text-amber-500{color:var(--color-amber-500)}.dark\:text-black{color:var(--color-black)}.dark\:text-blue-400{color:var(--color-blue-400)}.dark\:text-emerald-200{color:var(--color-emerald-200)}.dark\:text-emerald-400{color:var(--color-emerald-400)}.dark\:text-emerald-500{color:var(--color-emerald-500)}.dark\:text-gray-100{color:var(--color-gray-100)}.dark\:text-gray-200{color:var(--color-gray-200)}.dark\:text-gray-300{color:var(--color-gray-300)}.dark\:text-gray-400{color:var(--color-gray-400)}.dark\:text-gray-500{color:var(--color-gray-500)}.dark\:text-gray-800{color:var(--color-gray-800)}.dark\:text-gray-950{color:var(--color-gray-950)}.dark\:text-indigo-300{color:var(--color-indigo-300)}.dark\:text-indigo-400{color:var(--color-indigo-400)}.dark\:text-neutral-100{color:var(--color-neutral-100)}.dark\:text-neutral-200{color:var(--color-neutral-200)}.dark\:text-neutral-300{color:var(--color-neutral-300)}.dark\:text-neutral-400{color:var(--color-neutral-400)}.dark\:text-neutral-500{color:var(--color-neutral-500)}.dark\:text-neutral-600{color:var(--color-neutral-600)}.dark\:text-neutral-700{color:var(--color-neutral-700)}.dark\:text-neutral-900{color:var(--color-neutral-900)}.dark\:text-neutral-950{color:var(--color-neutral-950)}.dark\:text-orange-400{color:var(--color-orange-400)}.dark\:text-red-400{color:var(--color-red-400)}.dark\:text-red-500{color:var(--color-red-500)}.dark\:text-rose-400{color:var(--color-rose-400)}.dark\:text-white{color:var(--color-white)}.dark\:placeholder-neutral-700::placeholder{color:var(--color-neutral-700)}.dark\:mix-blend-hard-light{mix-blend-mode:hard-light}.dark\:mix-blend-normal{mix-blend-mode:normal}.dark\:shadow-\[inset_0px_0px_0px_1px_\#fffaed2d\]{--tw-shadow:inset 0px 0px 0px 1px var(--tw-shadow-color,#fffaed2d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.dark\:ring-white\/10{--tw-ring-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\:ring-white\/10{--tw-ring-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\:\[--stroke-color\:\#FF750F\]{--stroke-color:#ff750f}@media (hover:hover){.dark\:group-hover\:bg-neutral-400:is(:where(.group):hover *){background-color:var(--color-neutral-400)}.dark\:group-hover\:text-white:is(:where(.group):hover *){color:var(--color-white)}}.dark\:before\:border-\[\#3E3E3A\]:before{content:var(--tw-content);border-color:#3e3e3a}.dark\:focus-within\:ring-white:focus-within{--tw-ring-color:var(--color-white)}@media (hover:hover){.dark\:hover\:border-\[\#3E3E3A\]:hover{border-color:#3e3e3a}.dark\:hover\:border-\[\#62605b\]:hover{border-color:#62605b}.dark\:hover\:border-amber-900\/50:hover{border-color:#7b330680}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:border-amber-900\/50:hover{border-color:color-mix(in oklab, var(--color-amber-900) 50%, transparent)}}.dark\:hover\:border-emerald-900\/50:hover{border-color:#004e3b80}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:border-emerald-900\/50:hover{border-color:color-mix(in oklab, var(--color-emerald-900) 50%, transparent)}}.dark\:hover\:border-indigo-900\/50:hover{border-color:#312c8580}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:border-indigo-900\/50:hover{border-color:color-mix(in oklab, var(--color-indigo-900) 50%, transparent)}}.dark\:hover\:border-neutral-700:hover{border-color:var(--color-neutral-700)}.dark\:hover\:border-pink-900\/50:hover{border-color:#86104380}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:border-pink-900\/50:hover{border-color:color-mix(in oklab, var(--color-pink-900) 50%, transparent)}}.dark\:hover\:border-purple-900\/50:hover{border-color:#59168b80}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:border-purple-900\/50:hover{border-color:color-mix(in oklab, var(--color-purple-900) 50%, transparent)}}.dark\:hover\:border-rose-900\/50:hover{border-color:#8b083680}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:border-rose-900\/50:hover{border-color:color-mix(in oklab, var(--color-rose-900) 50%, transparent)}}.dark\:hover\:border-sky-900\/50:hover{border-color:#024a7080}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:border-sky-900\/50:hover{border-color:color-mix(in oklab, var(--color-sky-900) 50%, transparent)}}.dark\:hover\:border-white:hover{border-color:var(--color-white)}.dark\:hover\:bg-gray-700:hover{background-color:var(--color-gray-700)}.dark\:hover\:bg-gray-800:hover{background-color:var(--color-gray-800)}.dark\:hover\:bg-gray-800\/50:hover{background-color:#1e293980}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-gray-800\/50:hover{background-color:color-mix(in oklab, var(--color-gray-800) 50%, transparent)}}.dark\:hover\:bg-neutral-100:hover{background-color:var(--color-neutral-100)}.dark\:hover\:bg-neutral-200:hover{background-color:var(--color-neutral-200)}.dark\:hover\:bg-neutral-800:hover{background-color:var(--color-neutral-800)}.dark\:hover\:bg-neutral-900:hover{background-color:var(--color-neutral-900)}.dark\:hover\:bg-neutral-950:hover{background-color:var(--color-neutral-950)}.dark\:hover\:bg-rose-400:hover{background-color:var(--color-rose-400)}.dark\:hover\:bg-white:hover{background-color:var(--color-white)}.dark\:hover\:bg-white\/5:hover{background-color:#ffffff0d}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-white\/5:hover{background-color:color-mix(in oklab, var(--color-white) 5%, transparent)}}.dark\:hover\:bg-white\/\[0\.03\]:hover{background-color:#ffffff08}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-white\/\[0\.03\]:hover{background-color:color-mix(in oklab, var(--color-white) 3%, transparent)}}.dark\:hover\:text-gray-200:hover{color:var(--color-gray-200)}.dark\:hover\:text-gray-300:hover{color:var(--color-gray-300)}.dark\:hover\:text-indigo-100:hover{color:var(--color-indigo-100)}.dark\:hover\:text-indigo-400:hover{color:var(--color-indigo-400)}.dark\:hover\:text-neutral-300:hover{color:var(--color-neutral-300)}.dark\:hover\:text-white:hover{color:var(--color-white)}.dark\:hover\:ring-neutral-800\/50:hover{--tw-ring-color:#26262680}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:ring-neutral-800\/50:hover{--tw-ring-color:color-mix(in oklab, var(--color-neutral-800) 50%, transparent)}}}.dark\:active\:bg-neutral-700:active{background-color:var(--color-neutral-700)}}@starting-style{.starting\:opacity-0{opacity:0}}@media (prefers-reduced-motion:no-preference){@starting-style{.motion-safe\:starting\:-translate-x-\[26px\]{--tw-translate-x:calc(26px * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}}@starting-style{.motion-safe\:starting\:-translate-x-\[51px\]{--tw-translate-x:calc(51px * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}}@starting-style{.motion-safe\:starting\:-translate-x-\[78px\]{--tw-translate-x:calc(78px * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}}@starting-style{.motion-safe\:starting\:-translate-x-\[102px\]{--tw-translate-x:calc(102px * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}}@starting-style{.motion-safe\:starting\:translate-y-6{--tw-translate-y:calc(var(--spacing) * 6);translate:var(--tw-translate-x) var(--tw-translate-y)}}}.\[_a\]\:border-current:is(a){border-color:currentColor}.\[_a\]\:\!text-current:is(a),.\[_code\]\:\!text-current:is(code),.\[_kbd\]\:\!text-current:is(kbd),.\[_strong\]\:\!text-current:is(strong){color:currentColor!important}}.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{z-index:10!important;margin:0!important;padding:0!important;position:absolute!important;top:1.25rem!important;right:1.25rem!important}.ab-test-slider{margin-top:0}.ab-test-slider .noUi-tooltip{-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);background:#ffffff26;border-radius:100px;justify-content:center;align-items:center;width:30px;height:20px;padding:0;font-size:12px;display:flex;position:absolute;top:-24px;bottom:inherit!important}.action-trigger-btn{transition:all .2s ease-in-out;background-color:#0000!important;border:1px solid #0000!important}.short-url-card:hover .action-trigger-btn{background-color:#f9fafb!important;border-color:#e5e7eb!important}.dark .short-url-card:hover .action-trigger-btn{background-color:#1f2937!important;border-color:#374151!important}.action-trigger-btn[aria-expanded=true]{background-color:#f3f4f6!important;border-color:#9ca3af!important;box-shadow:0 0 0 4px #e5e6eb80!important}.dark .action-trigger-btn[aria-expanded=true]{background-color:#111827!important;border-color:#4b5563!important;box-shadow:0 0 0 4px #37415180!important}.custom-fused .fi-input-wrp{border:0!important}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:.75rem!important}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"";inherits:false;initial-value:100%}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-content{syntax:"*";inherits:false;initial-value:""}@keyframes spin{to{transform:rotate(360deg)}}@keyframes ping{75%,to{opacity:0;transform:scale(2)}}@keyframes pulse{50%{opacity:.5}}@keyframes bounce{0%,to{animation-timing-function:cubic-bezier(.8,0,1,1);transform:translateY(-25%)}50%{animation-timing-function:cubic-bezier(0,0,.2,1);transform:none}} \ No newline at end of file diff --git a/resources/lang/en/default.php b/resources/lang/en/default.php index 872fac0..f777155 100644 --- a/resources/lang/en/default.php +++ b/resources/lang/en/default.php @@ -1,9 +1,15 @@ 'Short Links', + 'navigation_label' => 'Links', 'navigation_group' => 'Tools', '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,6 +153,8 @@ 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', 'share_title' => 'Share Link', 'share_description' => 'Share this short link via:', @@ -184,6 +195,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 +223,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 +248,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' => '
How to configure MaxMind (Offline Geo-IP):
1. Register for a free account at maxmind.com.
2. Download the free GeoLite2 Country or GeoLite2 City database in binary format (file extension .mmdb).
3. Upload the file to your server, e.g. under the path storage/geoip/GeoLite2-Country.mmdb.
4. Paste the absolute path below and click the Verify file button on the right side of the field.
', + 'settings_maxmind_info_callout' => '
How to configure MaxMind (Offline Geo-IP):
1. Register for a free account at maxmind.com.
2. Download the free GeoLite2 Country or GeoLite2 City database in binary format (file extension .mmdb).
3. Upload the file to your server, e.g. under the path storage/geoip/GeoLite2-Country.mmdb.
4. Paste the absolute path below and click the Verify file button on the right side of the field.
', 'settings_maxmind_verify' => 'Verify file', 'settings_maxmind_verify_ok' => 'βœ… File found & readable', 'settings_maxmind_verify_fail' => '❌ File not found or not readable', @@ -250,7 +267,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' => '
Required Cron Task (Scheduler): You have enabled click counter buffering. You must add the following task to your server\'s crontab:
* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1
Command run automatically in the background by the scheduler:
php artisan short-url:sync-counters
', + 'settings_buffering_worker_info' => '
Required Cron Task (Scheduler): You have enabled click counter buffering. You must add the following task to your server\'s crontab:
* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1
Command run automatically in the background by the scheduler:
php artisan short-url:sync-counters
', // CDN Trust Settings 'settings_trust_cdn_headers' => 'Trust CDN & Proxy Headers', @@ -269,6 +286,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 +318,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 +360,12 @@ 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', @@ -376,7 +410,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' => '
Queue Worker Required: The selected connection runs asynchronously. You must run a background worker to process these jobs:
php artisan queue:work --queue=:queue
', + 'settings_queue_worker_info' => '
Queue Worker Required: The selected connection runs asynchronously. You must run a background worker to process these jobs:
php artisan queue:work --queue=:queue
', '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).', @@ -393,6 +427,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', @@ -456,6 +493,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', @@ -492,6 +531,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', @@ -542,4 +582,97 @@ 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 :short_url 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', ]; + diff --git a/resources/lang/pl/default.php b/resources/lang/pl/default.php index 1ea1d0c..9616869 100644 --- a/resources/lang/pl/default.php +++ b/resources/lang/pl/default.php @@ -1,9 +1,15 @@ 'KrΓ³tkie linki', + 'navigation_label' => 'Linki', 'navigation_group' => 'NarzΔ™dzia', '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,6 +149,8 @@ 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', 'share_title' => 'UdostΔ™pnij Link', 'share_description' => 'UdostΔ™pnij ten krΓ³tki link przez:', @@ -180,6 +191,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 +220,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 +245,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' => '
Jak skonfigurować MaxMind (Offline Geo-IP):
1. Zarejestruj darmowe konto na maxmind.com.
2. Pobierz bezpΕ‚atnΔ… bazΔ™ danych GeoLite2 Country lub GeoLite2 City w formacie binarnym (plik z rozszerzeniem .mmdb).
3. PrzeΕ›lij plik na swΓ³j serwer, np. do katalogu storage/geoip/GeoLite2-Country.mmdb.
4. Wpisz peΕ‚nΔ… Ε›cieΕΌkΔ™ bezwzglΔ™dnΔ… poniΕΌej i kliknij przycisk Zweryfikuj plik z prawej strony pola.
', + 'settings_maxmind_info_callout' => '
Jak skonfigurować MaxMind (Offline Geo-IP):
1. Zarejestruj darmowe konto na maxmind.com.
2. Pobierz bezpΕ‚atnΔ… bazΔ™ danych GeoLite2 Country lub GeoLite2 City w formacie binarnym (plik z rozszerzeniem .mmdb).
3. PrzeΕ›lij plik na swΓ³j serwer, np. do katalogu storage/geoip/GeoLite2-Country.mmdb.
4. Wpisz peΕ‚nΔ… Ε›cieΕΌkΔ™ bezwzglΔ™dnΔ… poniΕΌej i kliknij przycisk Zweryfikuj plik z prawej strony pola.
', '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 +264,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' => '
Wymagane zadanie Cron (Scheduler): Włączyłeś buforowanie kliknięć. Musisz dodać do harmonogramu zadań (cron) komendę synchronizującą liczniki z bazą danych:
* * * * * cd /sciezka-do-twojego-projektu && php artisan schedule:run >> /dev/null 2>&1
Komenda uruchamiana automatycznie w tle przez harmonogram:
php artisan short-url:sync-counters
', + 'settings_buffering_worker_info' => '
Wymagane zadanie Cron (Scheduler): Włączyłeś buforowanie kliknięć. Musisz dodać do harmonogramu zadań (cron) komendę synchronizującą liczniki z bazą danych:
* * * * * cd /sciezka-do-twojego-projektu && php artisan schedule:run >> /dev/null 2>&1
Komenda uruchamiana automatycznie w tle przez harmonogram:
php artisan short-url:sync-counters
', // CDN Trust Settings 'settings_trust_cdn_headers' => 'Ufaj nagΕ‚Γ³wkom CDN i proxy', @@ -266,6 +283,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 +315,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 +357,12 @@ 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', @@ -373,7 +407,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' => '
Wymagany proces w tle (Queue Worker): Wybrane połączenie działa asynchronicznie. Musisz uruchomić proces w tle (workera), aby przetwarzać te zadania:
php artisan queue:work --queue=:queue
', + 'settings_queue_worker_info' => '
Wymagany proces w tle (Queue Worker): Wybrane połączenie działa asynchronicznie. Musisz uruchomić proces w tle (workera), aby przetwarzać te zadania:
php artisan queue:work --queue=:queue
', '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).', @@ -390,6 +424,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', @@ -453,6 +490,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', @@ -489,6 +528,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Δ™Δ‡', @@ -539,4 +579,98 @@ 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 :short_url', + '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', ]; + diff --git a/resources/views/app-redirect.blade.php b/resources/views/app-redirect.blade.php index c9be468..2970908 100644 --- a/resources/views/app-redirect.blade.php +++ b/resources/views/app-redirect.blade.php @@ -3,7 +3,7 @@ - {{ __('filament-short-url::default.app_redirect_title') ?? 'Opening App...' }} + {{ __('filament-short-url::default.app_redirect_title') }} @@ -154,10 +154,10 @@

- {{ __('filament-short-url::default.app_redirect_opening_in', ['app' => $appName]) ?? "Opening in {$appName}" }} + {{ __('filament-short-url::default.app_redirect_opening_in', ['app' => $appName]) }}

- {{ __('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') }}

@@ -171,7 +171,7 @@ onmouseover="this.style.filter='brightness(1.05)';" onmouseout="this.style.filter='brightness(0.95)';" id="deep-link-btn"> - {{ __('filament-short-url::default.app_redirect_btn_open') ?? 'Open Native App' }} + {{ __('filament-short-url::default.app_redirect_btn_open') }} @@ -181,7 +181,7 @@ - {{ __('filament-short-url::default.app_redirect_btn_browser') ?? 'Open in Browser' }} + {{ __('filament-short-url::default.app_redirect_btn_browser') }} @@ -191,10 +191,10 @@ - {{ __('filament-short-url::default.app_redirect_in_app_warning_title') ?? 'In-App Browser Detected' }} + {{ __('filament-short-url::default.app_redirect_in_app_warning_title') }}

- {{ __('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') }}

diff --git a/resources/views/expired.blade.php b/resources/views/expired.blade.php index cf1d602..3845823 100644 --- a/resources/views/expired.blade.php +++ b/resources/views/expired.blade.php @@ -3,7 +3,7 @@ - {{ __('filament-short-url::default.expired_title') ?? 'Link Inactive or Expired' }} + {{ __('filament-short-url::default.expired_title') }} @@ -50,10 +50,10 @@

- {{ __('filament-short-url::default.expired_title') ?? 'Link Inactive or Expired' }} + {{ __('filament-short-url::default.expired_title') }}

- {{ __('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') }}

@@ -74,7 +74,7 @@ {{-- Action Button --}} - {{ __('filament-short-url::default.expired_btn_home') ?? 'Go to Homepage' }} + {{ __('filament-short-url::default.expired_btn_home') }} diff --git a/resources/views/forms/components/traffic-splitter.blade.php b/resources/views/forms/components/traffic-splitter.blade.php new file mode 100644 index 0000000..20d5157 --- /dev/null +++ b/resources/views/forms/components/traffic-splitter.blade.php @@ -0,0 +1,159 @@ + +
({ + 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" + > + +
+
+ +
+
+
+
diff --git a/resources/views/password-prompt.blade.php b/resources/views/password-prompt.blade.php index 02cd39b..9da5528 100644 --- a/resources/views/password-prompt.blade.php +++ b/resources/views/password-prompt.blade.php @@ -3,7 +3,7 @@ - {{ __('filament-short-url::default.password_title') ?? 'Password Protected' }} + {{ __('filament-short-url::default.password_title') }} @@ -49,10 +49,10 @@ {{ $siteName }} @endif

- {{ __('filament-short-url::default.password_title') ?? 'Password Protected' }} + {{ __('filament-short-url::default.password_title') }}

- {{ __('filament-short-url::default.password_description') ?? 'This link is password-protected. Please enter the correct password to continue.' }} + {{ __('filament-short-url::default.password_description') }}

@@ -63,7 +63,7 @@
- {{ __('filament-short-url::default.password_btn_unlock') ?? 'Unlock & Redirect' }} + {{ __('filament-short-url::default.password_btn_unlock') }} diff --git a/resources/views/pixel-loading.blade.php b/resources/views/pixel-loading.blade.php index 7f26733..e484e4d 100644 --- a/resources/views/pixel-loading.blade.php +++ b/resources/views/pixel-loading.blade.php @@ -3,7 +3,7 @@ - {{ __('filament-short-url::default.pixel_loading_title') ?? 'Connecting...' }} + {{ __('filament-short-url::default.pixel_loading_title') }} @@ -148,10 +148,10 @@

- {{ __('filament-short-url::default.pixel_loading_title') ?? 'Connecting...' }} + {{ __('filament-short-url::default.pixel_loading_title') }}

- {{ __('filament-short-url::default.pixel_loading_description') ?? 'Preparing your connection and forwarding you now.' }} + {{ __('filament-short-url::default.pixel_loading_description') }}

diff --git a/resources/views/qr-designer.blade.php b/resources/views/qr-designer.blade.php index 5420029..f567e28 100644 --- a/resources/views/qr-designer.blade.php +++ b/resources/views/qr-designer.blade.php @@ -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') { diff --git a/resources/views/stats.blade.php b/resources/views/stats.blade.php index 9976930..8bea089 100644 --- a/resources/views/stats.blade.php +++ b/resources/views/stats.blade.php @@ -20,6 +20,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
@@ -31,34 +33,97 @@
+ @if (! empty($activeFilters)) +
+ Active Filters: + @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 +
+ {{ $key === 'country_code' ? 'country' : str_replace('_', ' ', $key) }}: + {{ $displayValue }} + +
+ @endforeach + +
+ @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)) +
+ @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)) +
@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, [ +
+ @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)) +
+ + @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)) diff --git a/resources/views/table/custom-domain-row.blade.php b/resources/views/table/custom-domain-row.blade.php new file mode 100644 index 0000000..5980394 --- /dev/null +++ b/resources/views/table/custom-domain-row.blade.php @@ -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 + +
+ +
+ +
+ +
+ + + +
+ +
+ +
+ + {{ $record->domain }} + + @if($record->is_active) + + Primary + + @endif +
+ +
+ ↳ + @if($shortUrlsCount === 0) + No redirect configured + @else + Mapped to {{ $shortUrlsCount }} short {{ Str::plural('link', $shortUrlsCount) }} + @endif +
+
+
+ + +
+ + + + + + {{ $clicksCount }} clicks + + + + @if($record->is_verified) + + + + + Valid + + @else + + + + + Invalid + + @endif +
+ + +
+ + + + +
+ +
+ + + + +
+ + +
+
+
+
+ + +
+
+ +
+
+ +
+ +
+
+
+
+ +
+ +
+
+
+
+
+
+ + +
+ +
+

+ To configure your {{ $record->domain }}, set the following on your DNS provider: +

+
+ + +
+ +

Type

+

Name

+

Value

+

TTL

+ + +
+

A

+

@

+

+ {{ $serverIp }} + +

+

86400

+
+ + +
+

CNAME

+

{{ $subdomainName }}

+

+ {{ $appHost }} + +

+

86400

+
+
+ + +
+ + + + + + + +

+ If a TTL value of 86400 is not available, choose the highest available value. Domain propagation may take up to 12 hours. +

+
+
+
+
+
diff --git a/resources/views/table/destination-column.blade.php b/resources/views/table/destination-column.blade.php new file mode 100644 index 0000000..a237950 --- /dev/null +++ b/resources/views/table/destination-column.blade.php @@ -0,0 +1,22 @@ +
+ + + + @if(($record->destination_type ?? 'single') === 'split') + + {{ __('filament-short-url::default.destination_type_split') }} + + @else + + {{ $record->destination_url }} + + @endif +
diff --git a/resources/views/table/dns-guide-panel.blade.php b/resources/views/table/dns-guide-panel.blade.php new file mode 100644 index 0000000..8d0d4b4 --- /dev/null +++ b/resources/views/table/dns-guide-panel.blade.php @@ -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 + +
+ +
+
+ +
+ +
+
+
+
+ +
+ +
+
+
+
+
+
+ + +
+ +
+

+ {!! __('filament-short-url::default.dns_instructions_apex', ['domain' => '' . e($record->domain) . '']) !!} +

+

+ {!! __('filament-short-url::default.dns_instructions_subdomain', ['domain' => '' . e($record->domain) . '']) !!} +

+
+ + +
+ +

{{ __('filament-short-url::default.dns_table_type') }}

+

{{ __('filament-short-url::default.dns_table_name') }}

+

{{ __('filament-short-url::default.dns_table_value') }}

+

{{ __('filament-short-url::default.dns_table_ttl') }}

+ + +
+

A

+

@

+

+ {{ $serverIp }} + +

+

86400

+
+ + +
+

CNAME

+

{{ $subdomainName }}

+

+ {{ $appHost }} + +

+

86400

+
+
+ + +
+ + + + + + + +

+ {{ __('filament-short-url::default.dns_warning_ttl') }} +

+
+
+
diff --git a/resources/views/table/domain-column.blade.php b/resources/views/table/domain-column.blade.php new file mode 100644 index 0000000..839215e --- /dev/null +++ b/resources/views/table/domain-column.blade.php @@ -0,0 +1,38 @@ +@php + $shortUrlsCount = $record->short_urls_count ?? $record->shortUrls()->count(); +@endphp + +
+ +
+ + + +
+ +
+ +
+ + {{ $record->domain }} + +
+ +
+ + + + @if($shortUrlsCount === 0) + {{ __('filament-short-url::default.domain_no_redirects') }} + @else + {{ trans_choice('filament-short-url::default.domain_mapped_links', $shortUrlsCount, ['count' => $shortUrlsCount]) }} + @endif +
+
+
diff --git a/resources/views/table/empty-state-domains.blade.php b/resources/views/table/empty-state-domains.blade.php new file mode 100644 index 0000000..938d5d9 --- /dev/null +++ b/resources/views/table/empty-state-domains.blade.php @@ -0,0 +1,67 @@ +
+ +
+
+
+ +
+ + + + + + + + +
+ + +
+ + + + + + + +
+ + +
+ + + +
+
+
+ + +
+ + {{ __('filament-short-url::default.empty_state_domain_heading') }} + +
+ {{ __('filament-short-url::default.empty_state_domain_description') }} +
+
+ + + @if(!isset($hideCreateButton) || !$hideCreateButton) +
+ +
+ @endif +
diff --git a/resources/views/table/empty-state-pixels.blade.php b/resources/views/table/empty-state-pixels.blade.php new file mode 100644 index 0000000..df7ac00 --- /dev/null +++ b/resources/views/table/empty-state-pixels.blade.php @@ -0,0 +1,173 @@ +
+ + + +
+
+ + +
+
+ +
+
+
Meta Ads Pixel
+
ID: 9876543210
+
+ + Meta + +
+ + +
+
+ +
+
+
Google Analytics 4
+
ID: G-9X8Y7Z6W5V
+
+ + Google + +
+ + +
+
+ +
+
+
LinkedIn Insight Tag
+
ID: 1029384756
+
+ + LinkedIn + +
+ + +
+
+ +
+
+
TikTok Pixel
+
ID: C1234567890
+
+ + TikTok + +
+ + + +
+
+ +
+
+
Meta Ads Pixel
+
ID: 9876543210
+
+ + Meta + +
+ + +
+
+ +
+
+
Google Analytics 4
+
ID: G-9X8Y7Z6W5V
+
+ + Google + +
+ + +
+
+ +
+
+
LinkedIn Insight Tag
+
ID: 1029384756
+
+ + LinkedIn + +
+ + +
+
+ +
+
+
TikTok Pixel
+
ID: C1234567890
+
+ + TikTok + +
+
+
+ + +
+ {{ __('filament-short-url::default.empty_state_pixel_heading') }} +
+ {{ __('filament-short-url::default.empty_state_pixel_description') }} +
+
+ + +
+ +
+
diff --git a/resources/views/table/empty-state.blade.php b/resources/views/table/empty-state.blade.php new file mode 100644 index 0000000..346eb52 --- /dev/null +++ b/resources/views/table/empty-state.blade.php @@ -0,0 +1,245 @@ +
+ + + +
+
+ + +
+
+ +
+
+
short.io/youtube-tour
+
youtube.com/watch...
+
+ + + + + + 1.4k + +
+ + +
+
+ +
+
+
demo.to/monaco-map
+
maps.google.com/place...
+
+ + + + + + 890 + +
+ + +
+
+ +
+
+
link.xyz/whatsapp-chat
+
wa.me/385912345678
+
+ + + + + + 340 + +
+ + +
+
+ +
+
+
example.com/brochure-pdf
+
dropbox.com/s/spec-brochure...
+
+ + + + + + 620 + +
+ + +
+
+ +
+
+
short.io/promo-campaign
+
A/B Split Traffic
+
+ + + + + + 2.1k + +
+ + + +
+
+ +
+
+
short.io/youtube-tour
+
youtube.com/watch...
+
+ + + + + + 1.4k + +
+ + +
+
+ +
+
+
demo.to/monaco-map
+
maps.google.com/place...
+
+ + + + + + 890 + +
+ + +
+
+ +
+
+
link.xyz/whatsapp-chat
+
wa.me/385912345678
+
+ + + + + + 340 + +
+ + +
+
+ +
+
+
example.com/brochure-pdf
+
dropbox.com/s/spec-brochure...
+
+ + + + + + 620 + +
+ + +
+
+ +
+
+
short.io/promo-campaign
+
A/B Split Traffic
+
+ + + + + + 2.1k + +
+
+
+ + +
+ {{ __('filament-short-url::default.empty_state_heading') }} +
+ {{ __('filament-short-url::default.empty_state_description') }} +
+
+ + +
+ +
+
diff --git a/resources/views/table/metadata-badges.blade.php b/resources/views/table/metadata-badges.blade.php new file mode 100644 index 0000000..d0bc19d --- /dev/null +++ b/resources/views/table/metadata-badges.blade.php @@ -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 + +
+ + + + + + + {{ number_format($record->total_visits) }} {{ __('filament-short-url::default.badge_clicks') }} + + + + + + + + {{ number_format($record->unique_visits) }} {{ __('filament-short-url::default.badge_unique') }} + + + + + + + + + {{ number_format($record->qr_scans) }} {{ __('filament-short-url::default.badge_qr_scans') }} + + + +
+
+ + + + {{ $shortTime }} +
+ + +
+
+ {{ $detailedRelative }} + + + + + + + +
+ {{ $timezoneBadge }} + {{ $absoluteDate }}
+
+
+
+ + @if ($user) + +
+
+ {{ $userName }} +
+ + +
+
+ Avatar for {{ $userName }} +
+

{{ $userName }}

+
+
+

{{ $userEmail }}

+
+
+
+
+ @endif + + + @if ($record->expires_at) + + + + + {{ __('filament-short-url::default.badge_expires', ['date' => $record->expires_at->translatedFormat('M d, Y')]) }} + + @endif + + {{-- Keyboard Shortcuts Handler (Alpine.js) --}} + +
diff --git a/resources/views/table/qr-code-modal.blade.php b/resources/views/table/qr-code-modal.blade.php new file mode 100644 index 0000000..da10073 --- /dev/null +++ b/resources/views/table/qr-code-modal.blade.php @@ -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 + +
+ + + + +
+

{{ $qrHelperText }}

+
+ + +
+ + +
+
+
+
+
+ + +
+
diff --git a/resources/views/table/share-copy-field.blade.php b/resources/views/table/share-copy-field.blade.php new file mode 100644 index 0000000..63514e7 --- /dev/null +++ b/resources/views/table/share-copy-field.blade.php @@ -0,0 +1,36 @@ +@php + $shortUrl = $record->getShortUrl(); + $shareCopiedMsg = __('filament-short-url::default.share_copied'); + $shareCopyBtnText = __('filament-short-url::default.share_copy'); +@endphp + +
+ + + +
diff --git a/resources/views/table/share-platforms.blade.php b/resources/views/table/share-platforms.blade.php new file mode 100644 index 0000000..dd1a5bb --- /dev/null +++ b/resources/views/table/share-platforms.blade.php @@ -0,0 +1,61 @@ +@php + $shortUrl = $record->getShortUrl(); + $encodedUrl = urlencode($shortUrl); +@endphp + + diff --git a/resources/views/table/url-key-column.blade.php b/resources/views/table/url-key-column.blade.php new file mode 100644 index 0000000..824dfba --- /dev/null +++ b/resources/views/table/url-key-column.blade.php @@ -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 + +
+
+ +
+ + {{ $shortUrl }} + + + + + + +
diff --git a/resources/views/warning.blade.php b/resources/views/warning.blade.php index cb8ea8c..3521cd0 100644 --- a/resources/views/warning.blade.php +++ b/resources/views/warning.blade.php @@ -3,7 +3,7 @@ - {{ __('filament-short-url::default.warning_title') ?? 'Redirect Warning' }} + {{ __('filament-short-url::default.warning_title') }} @@ -49,10 +49,10 @@ {{ $siteName }} @endif

- {{ __('filament-short-url::default.warning_title') ?? 'Security Redirect Warning' }} + {{ __('filament-short-url::default.warning_title') }}

- {{ __('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') }}

@@ -90,7 +90,7 @@ - {{ __('filament-short-url::default.warning_btn_continue') ?? 'Continue to Destination' }} + {{ __('filament-short-url::default.warning_btn_continue') }} @@ -99,7 +99,7 @@ diff --git a/resources/views/webhook-payload-example.blade.php b/resources/views/webhook-payload-example.blade.php index 49747e9..2f31c83 100644 --- a/resources/views/webhook-payload-example.blade.php +++ b/resources/views/webhook-payload-example.blade.php @@ -36,7 +36,7 @@
+ + + {{ $heading }} + + +
+
+ +
+

+ {{ $message }} +

+
+
+ diff --git a/resources/views/widgets/device-breakdown.blade.php b/resources/views/widgets/device-breakdown.blade.php new file mode 100644 index 0000000..81bbd36 --- /dev/null +++ b/resources/views/widgets/device-breakdown.blade.php @@ -0,0 +1,164 @@ + +
+ +
+ + +
+ + +
+ +
+
+ +
+
+ + +
+ + @if ($activeTab === 'devices') +
+ @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 +
+
+ + @if (strtolower($device) === 'desktop') + + @elseif (strtolower($device) === 'mobile') + + @elseif (strtolower($device) === 'tablet') + + @else + + @endif + {{ $deviceLabel }} + + {{ number_format($count) }} ({{ $pct }}%) +
+
+
+
+
+ @empty +
+
+ +
+

+ {{ __('filament-short-url::default.stats_no_device_data') }} +

+
+ @endforelse +
+ @endif + + + @if ($activeTab === 'browsers') +
+ @forelse ($visitsByBrowser as $browser => $count) + @php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp +
+
+
+ + {{ $browser }} +
+ @if (!empty($visitsByBrowserVersion[$browser])) + @php + $versionsList = []; + foreach ($visitsByBrowserVersion[$browser] as $ver => $vCount) { + $versionsList[] = "v{$ver} ({$vCount})"; + } + @endphp + + {{ implode(', ', $versionsList) }} + + @endif +
+ + {{ number_format($count) }} ({{ $pct }}%) + +
+ @empty +
+
+ +
+

+ {{ __('filament-short-url::default.stats_no_browser_data') }} +

+
+ @endforelse +
+ @endif + + + @if ($activeTab === 'os') +
+ @forelse ($visitsByOs as $os => $count) + @php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp +
+
+
+ + {{ $os }} +
+ @if (!empty($visitsByOsVersion[$os])) + @php + $versionsList = []; + foreach ($visitsByOsVersion[$os] as $ver => $vCount) { + $versionsList[] = "v{$ver} ({$vCount})"; + } + @endphp + + {{ implode(', ', $versionsList) }} + + @endif +
+ + {{ number_format($count) }} ({{ $pct }}%) + +
+ @empty +
+
+ +
+

+ {{ __('filament-short-url::default.stats_no_os_data') }} +

+
+ @endforelse +
+ @endif +
+
+
+
diff --git a/resources/views/widgets/empty.blade.php b/resources/views/widgets/empty.blade.php new file mode 100644 index 0000000..7c89b54 --- /dev/null +++ b/resources/views/widgets/empty.blade.php @@ -0,0 +1 @@ +
diff --git a/resources/views/widgets/geo-breakdown.blade.php b/resources/views/widgets/geo-breakdown.blade.php new file mode 100644 index 0000000..24255f6 --- /dev/null +++ b/resources/views/widgets/geo-breakdown.blade.php @@ -0,0 +1,133 @@ + +
+ +
+ + +
+ + +
+ +
+
+ +
+
+ + +
+ + @if ($activeTab === 'countries') +
+ @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 +
+
+ + @if ($code) + {{ $translatedCountry }} + @endif + {{ $translatedCountry }} + + {{ number_format($count) }} ({{ $pct }}%) +
+
+
+
+
+ @empty +
+
+ +
+

+ {{ __('filament-short-url::default.stats_no_country_data') }} +

+
+ @endforelse +
+ @endif + + + @if ($activeTab === 'cities') +
+ @forelse ($visitsByCity as $city => $count) + @php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp +
+
+ {{ $city }} + {{ number_format($count) }} ({{ $pct }}%) +
+
+
+
+
+ @empty +
+
+ +
+

+ {{ __('filament-short-url::default.stats_no_city_data') }} +

+
+ @endforelse +
+ @endif + + + @if ($activeTab === 'languages') +
+ @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 +
+
+ {{ $langName }} ({{ strtoupper($langCode) }}) + {{ number_format($count) }} ({{ $pct }}%) +
+
+
+
+
+ @empty +
+
+ +
+

+ {{ __('filament-short-url::default.stats_no_language_data') }} +

+
+ @endforelse +
+ @endif +
+
+
+
diff --git a/resources/views/widgets/security-breakdown.blade.php b/resources/views/widgets/security-breakdown.blade.php new file mode 100644 index 0000000..08319aa --- /dev/null +++ b/resources/views/widgets/security-breakdown.blade.php @@ -0,0 +1,107 @@ + +
+ +
+ +
+ + +
+ +
+
+ +
+
+ + +
+ + @if ($activeTab === 'bots') +
+ {{-- Human Clicks --}} +
+
+ + + {{ __('filament-short-url::default.stats_security_real_users') }} + + + {{ number_format($humanClicks) }} + ({{ $humanPercentage }}%) + +
+
+
+
+
+ + {{-- Bot Clicks --}} +
+
+ + + {{ __('filament-short-url::default.stats_security_bots') }} + + + {{ number_format($botClicks) }} + ({{ $botPercentage }}%) + +
+
+
+
+
+
+ @endif + + + @if ($activeTab === 'vpn') +
+ @if ($proxyClicks === 0) +
+
+ +
+

+ {{ __('filament-short-url::default.stats_security_no_vpn_blocks') }} +

+
+ @else +
+
+

+ {{ number_format($proxyClicks) }} +

+

+ {{ __('filament-short-url::default.stats_security_proxy_clicks') }} +

+
+
+
+ + VPN / Proxy Traffic +
+
+
+
+
+
+ @endif +
+ @endif +
+
+
+
diff --git a/resources/views/widgets/sources-breakdown.blade.php b/resources/views/widgets/sources-breakdown.blade.php new file mode 100644 index 0000000..bf49dae --- /dev/null +++ b/resources/views/widgets/sources-breakdown.blade.php @@ -0,0 +1,205 @@ + +
+ +
+ +
+ + + @if ($activeTab === 'utm') +
+ + + + + +
+ @endif + + +
+ +
+
+ +
+
+ + +
+ + @if ($activeTab === 'referrers') +
+ @forelse ($visitsByReferer as $referer => $count) + @php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp +
+
+ {{ $referer }} + {{ number_format($count) }} ({{ $pct }}%) +
+
+
+
+
+ @empty +
+
+ +
+

+ {{ __('filament-short-url::default.stats_no_referer_data') }} +

+
+ @endforelse +
+ @endif + + + @if ($activeTab === 'utm') +
+ +
+ @if ($activeSubTab === 'sources') +
+ @forelse ($utmSources as $source => $count) + @php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp +
+ {{ $source }} + + {{ number_format($count) }} ({{ $pct }}%) + +
+ @empty +
+
+ +
+

+ {{ __('filament-short-url::default.stats_no_utm_data') }} +

+
+ @endforelse +
+ @elseif ($activeSubTab === 'mediums') +
+ @forelse ($utmMediums as $medium => $count) + @php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp +
+ {{ $medium }} + + {{ number_format($count) }} ({{ $pct }}%) + +
+ @empty +
+
+ +
+

+ {{ __('filament-short-url::default.stats_no_utm_data') }} +

+
+ @endforelse +
+ @elseif ($activeSubTab === 'campaigns') +
+ @forelse ($utmCampaigns as $campaign => $count) + @php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp +
+ {{ $campaign }} + + {{ number_format($count) }} ({{ $pct }}%) + +
+ @empty +
+
+ +
+

+ {{ __('filament-short-url::default.stats_no_utm_data') }} +

+
+ @endforelse +
+ @elseif ($activeSubTab === 'terms') +
+ @forelse ($utmTerms as $term => $count) + @php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp +
+ {{ $term }} + + {{ number_format($count) }} ({{ $pct }}%) + +
+ @empty +
+
+ +
+

+ {{ __('filament-short-url::default.stats_no_utm_data') }} +

+
+ @endforelse +
+ @elseif ($activeSubTab === 'contents') +
+ @forelse ($utmContents as $content => $count) + @php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp +
+ {{ $content }} + + {{ number_format($count) }} ({{ $pct }}%) + +
+ @empty +
+
+ +
+

+ {{ __('filament-short-url::default.stats_no_utm_data') }} +

+
+ @endforelse +
+ @endif +
+
+ @endif +
+
+
+
diff --git a/resources/views/widgets/variants.blade.php b/resources/views/widgets/variants.blade.php new file mode 100644 index 0000000..c8867ef --- /dev/null +++ b/resources/views/widgets/variants.blade.php @@ -0,0 +1,38 @@ + + + + A/B Testing Variants + + +
+ @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 +
+
+ {{ $variant }} + + {{ number_format($count) }} + + ({{ $pct }}%{{ $expectedWeight !== null ? ' vs expected ' . $expectedWeight . '%' : '' }}) + + +
+
+
+
+
+ @endforeach +
+
+
diff --git a/resources/views/widgets/visits-bottom-breakdown.blade.php b/resources/views/widgets/visits-bottom-breakdown.blade.php deleted file mode 100644 index 2ff398b..0000000 --- a/resources/views/widgets/visits-bottom-breakdown.blade.php +++ /dev/null @@ -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 - - -
- {{-- Row 1: Devices, Browsers, Operating Systems --}} -
- - {{-- Devices --}} -
-
-
- -
-

{{ __('filament-short-url::default.stats_breakdown_devices') }}

-
-
- @forelse ($visitsByDevice as $device => $count) - @php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp -
-
- -
-
-
- {{ $device }} - {{ $pct }}% -
-
-
-
-
-
- @empty -

{{ __('filament-short-url::default.stats_no_device_data') }}

- @endforelse -
-
- - {{-- Browsers --}} -
-
-
- -
-

{{ __('filament-short-url::default.stats_breakdown_browsers') }}

-
-
- @forelse ($visitsByBrowser as $browser => $count) - @php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp -
-
- - {{ $browser }} -
- - {{ number_format($count) }} ({{ $pct }}%) - -
- @empty -

{{ __('filament-short-url::default.stats_no_browser_data') }}

- @endforelse -
-
- - {{-- Operating Systems --}} -
-
-
- -
-

{{ __('filament-short-url::default.stats_breakdown_os') }}

-
-
- @forelse ($visitsByOs as $os => $count) - @php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp -
-
- - {{ $os }} -
- - {{ number_format($count) }} ({{ $pct }}%) - -
- @empty -

{{ __('filament-short-url::default.stats_no_os_data') }}

- @endforelse -
-
- -
- - {{-- Row 2: Referrers, Cities --}} -
- - {{-- UTM Campaigns Section --}} -
- - {{-- UTM Sources --}} -
-
-
- -
-

{{ __('filament-short-url::default.stats_breakdown_utm_source') }}

-
-
- @forelse ($utmSources as $source => $count) - @php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp -
- {{ $source }} - - {{ number_format($count) }} ({{ $pct }}%) - -
- @empty -

{{ __('filament-short-url::default.stats_no_utm_data') }}

- @endforelse -
-
- - {{-- UTM Mediums --}} -
-
-
- -
-

{{ __('filament-short-url::default.stats_breakdown_utm_medium') }}

-
-
- @forelse ($utmMediums as $medium => $count) - @php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp -
- {{ $medium }} - - {{ number_format($count) }} ({{ $pct }}%) - -
- @empty -

{{ __('filament-short-url::default.stats_no_utm_data') }}

- @endforelse -
-
- - {{-- UTM Campaigns --}} -
-
-
- -
-

{{ __('filament-short-url::default.stats_breakdown_utm_campaign') }}

-
-
- @forelse ($utmCampaigns as $campaign => $count) - @php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp -
- {{ $campaign }} - - {{ number_format($count) }} ({{ $pct }}%) - -
- @empty -

{{ __('filament-short-url::default.stats_no_utm_data') }}

- @endforelse -
-
- -
-
- diff --git a/resources/views/widgets/visits-chart.blade.php b/resources/views/widgets/visits-chart.blade.php new file mode 100644 index 0000000..8c3f2cc --- /dev/null +++ b/resources/views/widgets/visits-chart.blade.php @@ -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 + + + + +
+ + + + + + + + + + + @if ($filters) + + + @foreach ($filters as $value => $label) + + @endforeach + + + @endif +
+
+ +
getPollingInterval()) + wire:poll.{{ $pollingInterval }}="updateChartData" + @endif + > +
color(ChartWidgetComponent::class, $color) + ->class([ + 'fi-wi-chart-canvas-ctn', + 'fi-wi-chart-canvas-ctn-no-aspect-ratio' => $hasMaxHeight, + ]) + }} + > + ! $hasMaxHeight, + "max-height: {$maxHeight}" => $hasMaxHeight, + ]) + > + + + + + + + + +
+
+
+
diff --git a/resources/views/widgets/visits-right-breakdown.blade.php b/resources/views/widgets/visits-right-breakdown.blade.php deleted file mode 100644 index f108d23..0000000 --- a/resources/views/widgets/visits-right-breakdown.blade.php +++ /dev/null @@ -1,69 +0,0 @@ - -
- - {{-- Countries --}} -
-
-
- -
-

{{ __('filament-short-url::default.stats_breakdown_countries') }}

-
-
- @forelse ($visitsByCountry as $country => $count) - @php - $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; - $translatedCountry = \Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsRightBreakdown::getCountryTranslation($country); - $code = \Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsRightBreakdown::getCountryCode($country); - @endphp -
-
- - @if ($code) - {{ $translatedCountry }} - @endif - {{ $translatedCountry }} - - {{ number_format($count) }} ({{ $pct }}%) -
-
-
-
-
- @empty -

{{ __('filament-short-url::default.stats_no_country_data') }}

- @endforelse -
-
- - {{-- Languages --}} -
-
-
- -
-

{{ __('filament-short-url::default.stats_breakdown_languages') }}

-
-
- @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 -
-
- {{ $langName }} ({{ strtoupper($langCode) }}) - {{ number_format($count) }} ({{ $pct }}%) -
-
-
-
-
- @empty -

{{ __('filament-short-url::default.stats_no_language_data') }}

- @endforelse -
-
- -
-
diff --git a/routes/web.php b/routes/web.php index e8d45a1..7facb44 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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']) @@ -42,11 +40,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', ['web', 'throttle:120,1'])); +} diff --git a/src/Assets/ShortUrlCss.php b/src/Assets/ShortUrlCss.php new file mode 100644 index 0000000..9a6303d --- /dev/null +++ b/src/Assets/ShortUrlCss.php @@ -0,0 +1,22 @@ +getPublicPath(); + if (file_exists($path)) { + return (string) filemtime($path); + } + } catch (Throwable $exception) { + } + + return parent::getVersion(); + } +} diff --git a/src/Console/Commands/AggregateAndPruneVisitsCommand.php b/src/Console/Commands/AggregateAndPruneVisitsCommand.php index 0b1422f..59a4b40 100644 --- a/src/Console/Commands/AggregateAndPruneVisitsCommand.php +++ b/src/Console/Commands/AggregateAndPruneVisitsCommand.php @@ -46,7 +46,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 +60,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 +85,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 +131,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 +163,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'], ]); } }); diff --git a/src/Console/Commands/SyncBufferedCountersCommand.php b/src/Console/Commands/SyncBufferedCountersCommand.php index 91ba3f1..2d9df12 100644 --- a/src/Console/Commands/SyncBufferedCountersCommand.php +++ b/src/Console/Commands/SyncBufferedCountersCommand.php @@ -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) { diff --git a/src/Filament/Forms/Components/TrafficSplitter.php b/src/Filament/Forms/Components/TrafficSplitter.php new file mode 100644 index 0000000..d0d952f --- /dev/null +++ b/src/Filament/Forms/Components/TrafficSplitter.php @@ -0,0 +1,32 @@ + + * @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; + } +} diff --git a/src/Filament/Resources/ShortUrlCustomDomainResource.php b/src/Filament/Resources/ShortUrlCustomDomainResource.php new file mode 100644 index 0000000..6b148c6 --- /dev/null +++ b/src/Filament/Resources/ShortUrlCustomDomainResource.php @@ -0,0 +1,182 @@ +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('/'), + ]; + } +} diff --git a/src/Filament/Resources/ShortUrlCustomDomainResource/Pages/ListShortUrlCustomDomains.php b/src/Filament/Resources/ShortUrlCustomDomainResource/Pages/ListShortUrlCustomDomains.php new file mode 100644 index 0000000..e4f87f7 --- /dev/null +++ b/src/Filament/Resources/ShortUrlCustomDomainResource/Pages/ListShortUrlCustomDomains.php @@ -0,0 +1,56 @@ +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]); + } +} diff --git a/src/Filament/Resources/ShortUrlPixelResource.php b/src/Filament/Resources/ShortUrlPixelResource.php index 271cafc..2204fa7 100644 --- a/src/Filament/Resources/ShortUrlPixelResource.php +++ b/src/Filament/Resources/ShortUrlPixelResource.php @@ -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 diff --git a/src/Filament/Resources/ShortUrlPixelResource/Pages/ListShortUrlPixels.php b/src/Filament/Resources/ShortUrlPixelResource/Pages/ListShortUrlPixels.php index 97967cb..81a97a3 100644 --- a/src/Filament/Resources/ShortUrlPixelResource/Pages/ListShortUrlPixels.php +++ b/src/Filament/Resources/ShortUrlPixelResource/Pages/ListShortUrlPixels.php @@ -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), ]; } } diff --git a/src/Filament/Resources/ShortUrlResource/Pages/ListShortUrls.php b/src/Filament/Resources/ShortUrlResource/Pages/ListShortUrls.php index f80502b..03baabf 100644 --- a/src/Filament/Resources/ShortUrlResource/Pages/ListShortUrls.php +++ b/src/Filament/Resources/ShortUrlResource/Pages/ListShortUrls.php @@ -20,13 +20,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 +71,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', []); diff --git a/src/Filament/Resources/ShortUrlResource/Pages/Settings/Tabs/AdvancedTab.php b/src/Filament/Resources/ShortUrlResource/Pages/Settings/Tabs/AdvancedTab.php new file mode 100644 index 0000000..92eeefb --- /dev/null +++ b/src/Filament/Resources/ShortUrlResource/Pages/Settings/Tabs/AdvancedTab.php @@ -0,0 +1,179 @@ + + * @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')), + ]), + ]); + } +} diff --git a/src/Filament/Resources/ShortUrlResource/Pages/Settings/Tabs/DeepLinkingTab.php b/src/Filament/Resources/ShortUrlResource/Pages/Settings/Tabs/DeepLinkingTab.php new file mode 100644 index 0000000..124c5de --- /dev/null +++ b/src/Filament/Resources/ShortUrlResource/Pages/Settings/Tabs/DeepLinkingTab.php @@ -0,0 +1,83 @@ + + * @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')); + } + }; + }, + ]), + ]), + ]); + } +} diff --git a/src/Filament/Resources/ShortUrlResource/Pages/Settings/Tabs/DeveloperTab.php b/src/Filament/Resources/ShortUrlResource/Pages/Settings/Tabs/DeveloperTab.php new file mode 100644 index 0000000..785414d --- /dev/null +++ b/src/Filament/Resources/ShortUrlResource/Pages/Settings/Tabs/DeveloperTab.php @@ -0,0 +1,112 @@ + + * @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))), + 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')), + ]), + ]); + } +} diff --git a/src/Filament/Resources/ShortUrlResource/Pages/Settings/Tabs/Ga4Tab.php b/src/Filament/Resources/ShortUrlResource/Pages/Settings/Tabs/Ga4Tab.php new file mode 100644 index 0000000..e69b1b5 --- /dev/null +++ b/src/Filament/Resources/ShortUrlResource/Pages/Settings/Tabs/Ga4Tab.php @@ -0,0 +1,107 @@ + + * @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(); + } + }), + ]), + ]), + ]); + } +} diff --git a/src/Filament/Resources/ShortUrlResource/Pages/Settings/Tabs/GeneralTab.php b/src/Filament/Resources/ShortUrlResource/Pages/Settings/Tabs/GeneralTab.php new file mode 100644 index 0000000..9857298 --- /dev/null +++ b/src/Filament/Resources/ShortUrlResource/Pages/Settings/Tabs/GeneralTab.php @@ -0,0 +1,160 @@ + + * @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('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(), + ]), + ]); + } +} diff --git a/src/Filament/Resources/ShortUrlResource/Pages/Settings/Tabs/GeoIpTab.php b/src/Filament/Resources/ShortUrlResource/Pages/Settings/Tabs/GeoIpTab.php new file mode 100644 index 0000000..edde875 --- /dev/null +++ b/src/Filament/Resources/ShortUrlResource/Pages/Settings/Tabs/GeoIpTab.php @@ -0,0 +1,154 @@ + + * @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'), + ]), + ]); + } +} diff --git a/src/Filament/Resources/ShortUrlResource/Pages/Settings/Tabs/QrDefaultsTab.php b/src/Filament/Resources/ShortUrlResource/Pages/Settings/Tabs/QrDefaultsTab.php new file mode 100644 index 0000000..5241021 --- /dev/null +++ b/src/Filament/Resources/ShortUrlResource/Pages/Settings/Tabs/QrDefaultsTab.php @@ -0,0 +1,101 @@ + + * @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')), + ]), + ]); + } +} diff --git a/src/Filament/Resources/ShortUrlResource/Pages/Settings/Tabs/TrackingDefaultsTab.php b/src/Filament/Resources/ShortUrlResource/Pages/Settings/Tabs/TrackingDefaultsTab.php new file mode 100644 index 0000000..35cd27a --- /dev/null +++ b/src/Filament/Resources/ShortUrlResource/Pages/Settings/Tabs/TrackingDefaultsTab.php @@ -0,0 +1,86 @@ + + * @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), + ]); + } +} diff --git a/src/Filament/Resources/ShortUrlResource/Pages/ShortUrlSettingsPage.php b/src/Filament/Resources/ShortUrlResource/Pages/ShortUrlSettingsPage.php index 9ef8d68..2a30853 100644 --- a/src/Filament/Resources/ShortUrlResource/Pages/ShortUrlSettingsPage.php +++ b/src/Filament/Resources/ShortUrlResource/Pages/ShortUrlSettingsPage.php @@ -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() + 3; } catch (\Throwable) { - return 52; + return 53; } } @@ -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('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(''.$newKey['name'].': '.(__('filament-short-url::default.api_key_warning') ?? "Please copy this key now. You won't be able to see it again!")."
".$newKey['plain'].'')) + ->title(__('filament-short-url::default.api_key_generated')) + ->body(new HtmlString(''.$newKey['name'].': '.__('filament-short-url::default.api_key_warning')."
".$newKey['plain'].'')) ->warning() ->persistent() ->send(); } // Re-mount settings form state to reflect the new masked key names in the UI - $this->mount(); + $this->mount($mgr); } } diff --git a/src/Filament/Resources/ShortUrlResource/Pages/ViewShortUrlLogs.php b/src/Filament/Resources/ShortUrlResource/Pages/ViewShortUrlLogs.php index 371597b..0f45489 100644 --- a/src/Filament/Resources/ShortUrlResource/Pages/ViewShortUrlLogs.php +++ b/src/Filament/Resources/ShortUrlResource/Pages/ViewShortUrlLogs.php @@ -6,10 +6,13 @@ use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource; use Bjanczak\FilamentShortUrl\Models\ShortUrl; use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit; use Filament\Actions\Action; +use Filament\Actions\ActionGroup; use Filament\Forms\Components\DatePicker; +use Filament\Forms\Components\Placeholder; use Filament\Forms\Concerns\InteractsWithForms; use Filament\Forms\Contracts\HasForms; use Filament\Resources\Pages\Page; +use Filament\Schemas\Components\Grid; use Filament\Tables; use Filament\Tables\Concerns\InteractsWithTable; use Filament\Tables\Contracts\HasTable; @@ -40,49 +43,153 @@ class ViewShortUrlLogs extends Page implements HasForms, HasTable ->columns([ Tables\Columns\TextColumn::make('visited_at') ->label(__('filament-short-url::default.stats_col_time')) - ->dateTime() + ->dateTime('M j, g:i A') ->sortable(), + + Tables\Columns\TextColumn::make('shortUrl.url_key') + ->label(__('filament-short-url::default.stats_col_link')) + ->icon('heroicon-o-link') + ->formatStateUsing(function ($state, $record) { + if (! $record->shortUrl) { + return 'β€”'; + } + + return str_replace(['http://', 'https://'], '', $record->shortUrl->getShortUrl()); + }) + ->sortable() + ->toggleable(), + + Tables\Columns\TextColumm::make('referer_host') + ->label(__('filament-short-url::default.stats_col_referrer')) + ->icon('heroicon-o-link') + ->formatStateUsing(fn ($state) => empty($state) ? __('filament-short-url::default.stats_referer_direct') : $state) + ->color(fn ($state) => empty($state) ? 'gray' : null) + ->searchable() + ->sortable() + ->toggleable(), + + Tables\Columns\TextColumn::make('country') + ->label(__('filament-short-url::default.stats_col_country')) + ->searchable() + ->sortable() + ->formatStateUsing(function ($state, $record) { + if (! $state) { + return 'β€”'; + } + if ($record->country_code) { + try { + $flag = implode('', array_map(fn ($char) => mb_chr(ord($char) + 127397), str_split(strtoupper($record->country_code)))); + + return "{$flag} {$state}"; + } catch (\Throwable) { + return $state; + } + } + + return $state; + }) + ->placeholder('β€”') + ->toggleable(), + + Tables\Columns\TextColumn::make('device_type') + ->label(__('filament-short-url::default.stats_col_device')) + ->icon(fn ($state) => match (strtolower($state)) { + 'desktop' => 'heroicon-m-computer-desktop', + 'mobile' => 'heroicon-m-device-phone-mobile', + 'tablet' => 'heroicon-m-device-tablet', + default => 'heroicon-m-question-mark-circle', + }) + ->formatStateUsing(fn ($state) => match (strtolower($state)) { + '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($state), + }) + ->searchable() + ->sortable() + ->toggleable(), + + // Hidden/toggleable columns for advanced details Tables\Columns\TextColumn::make('ip_address') ->label(__('filament-short-url::default.stats_col_ip')) ->searchable() ->sortable() ->placeholder('β€”') - ->toggleable(), - Tables\Columns\TextColumn::make('country') - ->label(__('filament-short-url::default.stats_col_country')) - ->searchable() - ->sortable() - ->formatStateUsing(fn ($state, $record) => $record->country_code ? "{$record->country_code} - {$state}" : ($state ?? 'β€”')) - ->placeholder('β€”') - ->toggleable(), - Tables\Columns\TextColumn::make('device_type') - ->label(__('filament-short-url::default.stats_col_device')) - ->searchable() - ->sortable() - ->formatStateUsing(fn ($state) => ucfirst($state)) - ->placeholder('β€”') - ->toggleable(), + ->toggleable(isToggledHiddenByDefault: true), + Tables\Columns\TextColumn::make('browser') ->label(__('filament-short-url::default.stats_col_browser')) ->searchable() ->sortable() ->formatStateUsing(fn ($state, $record) => $record->browser_version ? "{$state} ({$record->browser_version})" : ($state ?? 'β€”')) ->placeholder('β€”') - ->toggleable(), + ->toggleable(isToggledHiddenByDefault: true), + Tables\Columns\TextColumn::make('operating_system') ->label(__('filament-short-url::default.stats_col_os')) ->searchable() ->sortable() ->formatStateUsing(fn ($state, $record) => $record->operating_system_version ? "{$state} ({$record->operating_system_version})" : ($state ?? 'β€”')) ->placeholder('β€”') - ->toggleable(), - Tables\Columns\TextColumn::make('referer_url') - ->label(__('filament-short-url::default.track_referer')) + ->toggleable(isToggledHiddenByDefault: true), + + Tables\Columns\TextColumn::make('browser_language') + ->label(__('filament-short-url::default.stats_col_language')) ->searchable() ->sortable() - ->limit(30) ->placeholder('β€”') ->toggleable(isToggledHiddenByDefault: true), + + Tables\Columns\TextColumn::make('utm_source') + ->label(__('filament-short-url::default.stats_col_utm_source')) + ->searchable() + ->placeholder('β€”') + ->toggleable(isToggledHiddenByDefault: true), + + Tables\Columns\TextColumn::make('utm_medium') + ->label(__('filament-short-url::default.stats_col_utm_medium')) + ->searchable() + ->placeholder('β€”') + ->toggleable(isToggledHiddenByDefault: true), + + Tables\Columns\TextColumn::make('utm_campaign') + ->label(__('filament-short-url::default.stats_col_utm_campaign')) + ->searchable() + ->placeholder('β€”') + ->toggleable(isToggledHiddenByDefault: true), + + Tables\Columns\TextColumn::make('utm_term') + ->label(__('filament-short-url::default.stats_col_utm_term')) + ->searchable() + ->placeholder('β€”') + ->toggleable(isToggledHiddenByDefault: true), + + Tables\Columns\TextColumn::make('utm_content') + ->label(__('filament-short-url::default.stats_col_utm_content')) + ->searchable() + ->placeholder('β€”') + ->toggleable(isToggledHiddenByDefault: true), + + Tables\Columns\TextColumn::make('selected_variant') + ->label(__('filament-short-url::default.stats_col_variant')) + ->searchable() + ->placeholder('β€”') + ->toggleable(isToggledHiddenByDefault: true), + + Tables\Columns\IconColumn::make('is_qr_scan') + ->label(__('filament-short-url::default.stats_col_qr_scan')) + ->boolean() + ->toggleable(isToggledHiddenByDefault: true), + + Tables\Columns\IconColumn::make('is_bot') + ->label(__('filament-short-url::default.stats_col_bot')) + ->boolean() + ->toggleable(isToggledHiddenByDefault: true), + + Tables\Columns\IconColumn::make('is_proxy') + ->label(__('filament-short-url::default.stats_col_proxy')) + ->boolean() + ->toggleable(isToggledHiddenByDefault: true), ]) ->defaultSort('visited_at', 'desc') ->paginated([10, 25, 50, 100]) @@ -90,10 +197,10 @@ class ViewShortUrlLogs extends Page implements HasForms, HasTable Tables\Filters\SelectFilter::make('device_type') ->label(__('filament-short-url::default.stats_col_device')) ->options([ - 'desktop' => 'Desktop', - 'mobile' => 'Mobile', - 'tablet' => 'Tablet', - 'robot' => 'Robot / Bot', + 'desktop' => __('filament-short-url::default.stats_device_desktop'), + 'mobile' => __('filament-short-url::default.stats_device_mobile'), + 'tablet' => __('filament-short-url::default.stats_device_tablet'), + 'robot' => __('filament-short-url::default.stats_device_bot'), ]), Tables\Filters\SelectFilter::make('country_code') @@ -122,6 +229,76 @@ class ViewShortUrlLogs extends Page implements HasForms, HasTable ->when($data['visited_until'], fn ($q, $date) => $q->where('visited_at', '<=', $date.' 23:59:59')); }), ]) + ->actions([ + ActionGroup::make([ + Action::make('details') + ->label(__('filament-short-url::default.stats_action_view_details')) + ->icon('heroicon-o-information-circle') + ->modalHeading(__('filament-short-url::default.stats_modal_visit_details')) + ->modalWidth('md') + ->form([ + Grid::make(2) + ->schema([ + Placeholder::make('ip_address') + ->label(__('filament-short-url::default.stats_csv_ip')) + ->content(fn ($record) => $record->ip_address ?? 'β€”'), + Placeholder::make('country') + ->label(__('filament-short-url::default.stats_detail_location')) + ->content(fn ($record) => $record->country + ? ($record->country_code ? implode('', array_map(fn ($char) => mb_chr(ord($char) + 127397), str_split(strtoupper($record->country_code)))).' '.$record->country : $record->country) + : 'β€”'), + Placeholder::make('device') + ->label(__('filament-short-url::default.stats_col_device')) + ->content(fn ($record) => $record->device_type ? match (strtolower($record->device_type)) { + '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($record->device_type) + } : 'β€”'), + Placeholder::make('browser') + ->label(__('filament-short-url::default.stats_col_browser')) + ->content(fn ($record) => $record->browser ? "{$record->browser} ({$record->browser_version})" : 'β€”'), + Placeholder::make('os') + ->label(__('filament-short-url::default.stats_col_os')) + ->content(fn ($record) => $record->operating_system ? "{$record->operating_system} ({$record->operating_system_version})" : 'β€”'), + Placeholder::make('referer') + ->label(__('filament-short-url::default.stats_col_referrer')) + ->content(fn ($record) => $record->referer_url ?? 'β€”') + ->columnSpanFull(), + Placeholder::make('utm_source') + ->label(__('filament-short-url::default.stats_col_utm_source')) + ->content(fn ($record) => $record->utm_source ?? 'β€”'), + Placeholder::make('utm_medium') + ->label(__('filament-short-url::default.stats_col_utm_medium')) + ->content(fn ($record) => $record->utm_medium ?? 'β€”'), + Placeholder::make('utm_campaign') + ->label(__('filament-short-url::default.stats_col_utm_campaign')) + ->content(fn ($record) => $record->utm_campaign ?? 'β€”'), + Placeholder::make('utm_term') + ->label(__('filament-short-url::default.stats_col_utm_term')) + ->content(fn ($record) => $record->utm_term ?? 'β€”'), + Placeholder::make('utm_content') + ->label(__('filament-short-url::default.stats_col_utm_content')) + ->content(fn ($record) => $record->utm_content ?? 'β€”'), + Placeholder::make('selected_variant') + ->label(__('filament-short-url::default.stats_col_variant')) + ->content(fn ($record) => $record->selected_variant ?? 'β€”'), + Placeholder::make('is_bot') + ->label(__('filament-short-url::default.stats_detail_is_bot')) + ->content(fn ($record) => $record->is_bot ? __('filament-short-url::default.stats_yes') : __('filament-short-url::default.stats_no')), + Placeholder::make('is_proxy') + ->label(__('filament-short-url::default.stats_detail_is_proxy')) + ->content(fn ($record) => $record->is_proxy ? __('filament-short-url::default.stats_yes') : __('filament-short-url::default.stats_no')), + Placeholder::make('is_qr_scan') + ->label(__('filament-short-url::default.stats_detail_is_qr')) + ->content(fn ($record) => $record->is_qr_scan ? __('filament-short-url::default.stats_yes') : __('filament-short-url::default.stats_no')), + Placeholder::make('visited_at') + ->label(__('filament-short-url::default.stats_detail_visited_at')) + ->content(fn ($record) => $record->visited_at->toDateTimeString()), + ]), + ]), + ]), + ]) ->headerActions([ Action::make('export_csv') ->label(__('filament-short-url::default.stats_action_export')) @@ -141,6 +318,15 @@ class ViewShortUrlLogs extends Page implements HasForms, HasTable __('filament-short-url::default.stats_csv_browser'), __('filament-short-url::default.stats_csv_os'), __('filament-short-url::default.stats_csv_referer'), + __('filament-short-url::default.stats_csv_utm_source'), + __('filament-short-url::default.stats_csv_utm_medium'), + __('filament-short-url::default.stats_csv_utm_campaign'), + __('filament-short-url::default.stats_csv_utm_term'), + __('filament-short-url::default.stats_csv_utm_content'), + __('filament-short-url::default.stats_csv_variant'), + __('filament-short-url::default.stats_csv_qr_scan'), + __('filament-short-url::default.stats_csv_bot'), + __('filament-short-url::default.stats_csv_proxy'), ]); $livewire->getFilteredTableQuery() @@ -151,10 +337,24 @@ class ViewShortUrlLogs extends Page implements HasForms, HasTable $visit->visited_at->toDateTimeString(), $visit->ip_address ?? 'β€”', $visit->country ? "{$visit->country_code} - {$visit->country}" : 'β€”', - ucfirst($visit->device_type ?? 'β€”'), + $visit->device_type ? match (strtolower($visit->device_type)) { + '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($visit->device_type) + } : 'β€”', $visit->browser ? "{$visit->browser} ({$visit->browser_version})" : 'β€”', $visit->operating_system ? "{$visit->operating_system} ({$visit->operating_system_version})" : 'β€”', $visit->referer_url ?? 'β€”', + $visit->utm_source ?? 'β€”', + $visit->utm_medium ?? 'β€”', + $visit->utm_campaign ?? 'β€”', + $visit->utm_term ?? 'β€”', + $visit->utm_content ?? 'β€”', + $visit->selected_variant ?? 'β€”', + $visit->is_qr_scan ? __('filament-short-url::default.stats_yes') : __('filament-short-url::default.stats_no'), + $visit->is_bot ? __('filament-short-url::default.stats_yes') : __('filament-short-url::default.stats_no'), + $visit->is_proxy ? __('filament-short-url::default.stats_yes') : __('filament-short-url::default.stats_no'), ]); } }); diff --git a/src/Filament/Resources/ShortUrlResource/Pages/ViewShortUrlStats.php b/src/Filament/Resources/ShortUrlResource/Pages/ViewShortUrlStats.php index d1df569..850283e 100644 --- a/src/Filament/Resources/ShortUrlResource/Pages/ViewShortUrlStats.php +++ b/src/Filament/Resources/ShortUrlResource/Pages/ViewShortUrlStats.php @@ -9,8 +9,10 @@ use Filament\Forms\Components\DatePicker; use Filament\Forms\Components\Select; use Filament\Forms\Concerns\InteractsWithForms; use Filament\Forms\Contracts\HasForms; +use Filament\Notifications\Notification; use Filament\Resources\Pages\Page; use Filament\Schemas\Schema; +use Livewire\Attributes\On; class ViewShortUrlStats extends Page implements HasForms { @@ -28,6 +30,50 @@ class ViewShortUrlStats extends Page implements HasForms public ?array $filterData = []; + public array $activeFilters = []; + + #[On('set-stats-filter')] + public function setStatsFilter(string $key, $value): void + { + if (count($this->activeFilters) >= 5 && ! isset($this->activeFilters[$key])) { + Notification::make() + ->title(__('filament-short-url::default.stats_filter_limit_exceeded')) + ->warning() + ->send(); + + return; + } + + $this->activeFilters[$key] = $value; + $this->totalVisits = $this->record->getCachedStats( + $this->filterData['date_from'] ?? null, + $this->filterData['date_to'] ?? null, + $this->activeFilters + )['totalVisits'] ?? 0; + } + + #[On('clear-stats-filter')] + public function clearStatsFilter(string $key): void + { + unset($this->activeFilters[$key]); + $this->totalVisits = $this->record->getCachedStats( + $this->filterData['date_from'] ?? null, + $this->filterData['date_to'] ?? null, + $this->activeFilters + )['totalVisits'] ?? 0; + } + + #[On('clear-all-stats-filters')] + public function clearAllStatsFilters(): void + { + $this->activeFilters = []; + $this->totalVisits = $this->record->getCachedStats( + $this->filterData['date_from'] ?? null, + $this->filterData['date_to'] ?? null, + $this->activeFilters + )['totalVisits'] ?? 0; + } + public function mount(ShortUrl $record): void { $this->record = $record; @@ -46,7 +92,11 @@ class ViewShortUrlStats extends Page implements HasForms ]; } - $this->totalVisits = $record->getCachedStats($this->filterData['date_from'], $this->filterData['date_to'])['totalVisits'] ?? 0; + $this->totalVisits = $record->getCachedStats( + $this->filterData['date_from'] ?? null, + $this->filterData['date_to'] ?? null, + $this->activeFilters + )['totalVisits'] ?? 0; } public function form(Schema $schema): Schema @@ -116,6 +166,25 @@ class ViewShortUrlStats extends Page implements HasForms ->color('gray') ->url(ShortUrlResource::getUrl()), + Action::make('refresh') + ->label(__('filament-short-url::default.stats_btn_refresh')) + ->icon('heroicon-o-arrow-path') + ->color('gray') + ->action(function () { + $this->record->clearStatsCache( + $this->filterData['date_from'] ?? null, + $this->filterData['date_to'] ?? null, + $this->activeFilters + ); + + $this->dispatch('$refresh'); + + Notification::make() + ->title(__('filament-short-url::default.stats_refresh_success')) + ->success() + ->send(); + }), + Action::make('copy_url') ->label(__('filament-short-url::default.stats_btn_copy')) ->icon('heroicon-o-clipboard') diff --git a/src/Filament/Resources/ShortUrlResource/Schemas/ShortUrlForm.php b/src/Filament/Resources/ShortUrlResource/Schemas/ShortUrlForm.php index bd25bc6..431e520 100644 --- a/src/Filament/Resources/ShortUrlResource/Schemas/ShortUrlForm.php +++ b/src/Filament/Resources/ShortUrlResource/Schemas/ShortUrlForm.php @@ -1,916 +1,39 @@ + * @copyright 2026 Bartek Janczak + * @license Custom Source-Available License (see LICENSE file) + */ + namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas; -use Bjanczak\FilamentShortUrl\Models\ShortUrl; -use Bjanczak\FilamentShortUrl\Services\SafeBrowsingService; -use Bjanczak\FilamentShortUrl\Services\ShortUrlService; -use Filament\Actions\Action; -use Filament\Forms\Components\Builder; -use Filament\Forms\Components\Builder\Block; -use Filament\Forms\Components\CheckboxList; -use Filament\Forms\Components\DateTimePicker; -use Filament\Forms\Components\Hidden; -use Filament\Forms\Components\Placeholder; -use Filament\Forms\Components\Repeater; -use Filament\Forms\Components\Select; -use Filament\Forms\Components\Textarea; -use Filament\Forms\Components\TextInput; -use Filament\Forms\Components\Toggle; -use Filament\Forms\Components\ViewField; -use Filament\Notifications\Notification; -use Filament\Schemas\Components\Actions; -use Filament\Schemas\Components\Group; -use Filament\Schemas\Components\Section; +use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs\AppLinkingTab; +use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs\LinkTab; +use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs\MarketingTab; +use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs\QrDesignTab; +use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs\TargetingTab; +use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs\TrackingTab; use Filament\Schemas\Components\Tabs; -use Filament\Schemas\Components\Tabs\Tab; -use Filament\Schemas\Components\Utilities\Get; -use Filament\Schemas\Components\Utilities\Set; use Filament\Schemas\Schema; -use Illuminate\Support\HtmlString; class ShortUrlForm { + /** + * Configure the short URL resource form schema. + */ public static function configure(Schema $schema): Schema { return $schema->components([ - Tabs::make()->tabs([ - static::linkTab(), - static::targetingTab(), - static::appLinkingTab(), - static::trackingTab(), - static::marketingTab(), - static::qrDesignTab(), - ])->columnSpanFull(), + Tabs::make() + ->tabs([ + LinkTab::make(), + TargetingTab::make(), + AppLinkingTab::make(), + TrackingTab::make(), + MarketingTab::make(), + QrDesignTab::make(), + ])->columnSpanFull(), ]); } - - private static function linkTab(): Tab - { - return Tab::make(__('filament-short-url::default.tab_link')) - ->icon('heroicon-o-link') - ->schema([ - Section::make()->schema([ - TextInput::make('destination_url') - ->label(__('filament-short-url::default.destination_url')) - ->helperText(__('filament-short-url::default.destination_url_helper')) - ->required() - ->url() - ->maxLength(2048) - ->rules([ - fn (): \Closure => function (string $attribute, $value, \Closure $fail) { - $safeBrowsing = app(SafeBrowsingService::class); - if (! $safeBrowsing->isSafe($value)) { - $fail(__('filament-short-url::default.safe_browsing_error') ?? 'This URL has been flagged by Google Safe Browsing as unsafe.'); - } - }, - ]) - ->live(onBlur: true) - ->afterStateHydrated(function (TextInput $component, $state, Set $set) { - if (! $state) { - return; - } - $parts = parse_url($state); - if (isset($parts['query'])) { - parse_str($parts['query'], $query); - $set('utm_source', $query['utm_source'] ?? null); - $set('utm_medium', $query['utm_medium'] ?? null); - $set('utm_campaign', $query['utm_campaign'] ?? null); - $set('utm_term', $query['utm_term'] ?? null); - $set('utm_content', $query['utm_content'] ?? null); - } - }) - ->afterStateUpdated(function ($state, Set $set) { - if (! $state) { - return; - } - $parts = parse_url($state); - if (isset($parts['query'])) { - parse_str($parts['query'], $query); - $set('utm_source', $query['utm_source'] ?? null); - $set('utm_medium', $query['utm_medium'] ?? null); - $set('utm_campaign', $query['utm_campaign'] ?? null); - $set('utm_term', $query['utm_term'] ?? null); - $set('utm_content', $query['utm_content'] ?? null); - } else { - $set('utm_source', null); - $set('utm_medium', null); - $set('utm_campaign', null); - $set('utm_term', null); - $set('utm_content', null); - } - }) - ->columnSpanFull(), - - TextInput::make('url_key') - ->label(__('filament-short-url::default.url_key')) - ->helperText(__('filament-short-url::default.url_key_helper')) - ->alphaDash() - ->maxLength(32) - ->unique('short_urls', 'url_key', ignoreRecord: true) - ->suffixAction( - Action::make('regenerate') - ->icon('heroicon-o-arrow-path') - ->tooltip('Generate new key') - ->action(function (Set $set): void { - $set('url_key', app(ShortUrlService::class)->generateKey()); - }) - ) - ->placeholder('auto-generated'), - - Select::make('redirect_status_code') - ->label(__('filament-short-url::default.redirect_code')) - ->options([ - 302 => __('filament-short-url::default.redirect_code_302'), - 301 => __('filament-short-url::default.redirect_code_301'), - ]) - ->default(fn () => config('filament-short-url.redirect_status_code', 302)) - ->required(), - ])->columns(2), - - Section::make(__('filament-short-url::default.form_section_options'))->schema([ - Toggle::make('is_enabled') - ->label(__('filament-short-url::default.status')) - ->default(true) - ->inline(false), - - Toggle::make('forward_query_params') - ->label(__('filament-short-url::default.forward_query_params')) - ->helperText(__('filament-short-url::default.forward_query_params_helper')) - ->default(false) - ->inline(false), - ])->columns(2), - - Section::make(__('filament-short-url::default.form_section_validity')) - ->schema([ - Toggle::make('use_date_validity') - ->label(__('filament-short-url::default.use_date_validity')) - ->dehydrated(false) - ->live() - ->afterStateHydrated(function (Toggle $component, $state, Get $get, Set $set) { - $set('use_date_validity', $get('activated_at') !== null || $get('expires_at') !== null); - }) - ->afterStateUpdated(function ($state, Set $set) { - if ($state) { - $set('activated_at', now()->startOfMinute()); - } else { - $set('activated_at', null); - $set('expires_at', null); - $set('expiration_redirect_url', null); - } - }) - ->columnSpanFull(), - - DateTimePicker::make('activated_at') - ->label(__('filament-short-url::default.activated_at')) - ->nullable() - ->native(false) - ->withoutSeconds() - ->live(onBlur: true) - ->required(fn (Get $get): bool => (bool) $get('use_date_validity')) - ->visible(fn (Get $get): bool => (bool) $get('use_date_validity')) - ->minDate(now()->startOfDay()) - ->maxDate(fn (Get $get) => $get('expires_at')), - - DateTimePicker::make('expires_at') - ->label(__('filament-short-url::default.expires_at')) - ->nullable() - ->native(false) - ->withoutSeconds() - ->live(onBlur: true) - ->visible(fn (Get $get): bool => (bool) $get('use_date_validity')) - ->minDate(fn (Get $get) => $get('activated_at') ?: now()->startOfDay()), - - TextInput::make('expiration_redirect_url') - ->label(__('filament-short-url::default.expiration_redirect_url')) - ->helperText(__('filament-short-url::default.expiration_redirect_url_helper')) - ->url() - ->maxLength(2048) - ->nullable() - ->visible(fn (Get $get): bool => (bool) $get('use_date_validity')) - ->columnSpanFull(), - - Toggle::make('single_use') - ->label(__('filament-short-url::default.single_use')) - ->helperText(__('filament-short-url::default.single_use_helper')) - ->default(false) - ->inline(false) - ->live(), - - TextInput::make('max_visits') - ->label(__('filament-short-url::default.max_visits')) - ->helperText(__('filament-short-url::default.max_visits_helper')) - ->numeric() - ->integer() - ->minValue(1) - ->nullable() - ->hidden(fn (Get $get): bool => (bool) $get('single_use')), - ])->columns(2), - - Section::make(__('filament-short-url::default.form_section_notes'))->schema([ - Textarea::make('notes') - ->label(__('filament-short-url::default.notes')) - ->rows(3) - ->columnSpanFull(), - ]), - ]); - } - - public static function syncUtmToDestination(Get $get, Set $set): void - { - $url = $get('destination_url'); - if (! $url) { - return; - } - - $parts = parse_url($url); - $query = []; - if (isset($parts['query'])) { - parse_str($parts['query'], $query); - } - - // List of UTM parameters we build - $utms = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content']; - foreach ($utms as $utm) { - $val = $get($utm); - if ($val !== null && $val !== '') { - $query[$utm] = $val; - } else { - unset($query[$utm]); - } - } - - $scheme = isset($parts['scheme']) ? $parts['scheme'].'://' : ''; - $host = $parts['host'] ?? ''; - $port = isset($parts['port']) ? ':'.$parts['port'] : ''; - $path = $parts['path'] ?? ''; - $queryString = ! empty($query) ? '?'.http_build_query($query) : ''; - $fragment = isset($parts['fragment']) ? '#'.$parts['fragment'] : ''; - - $set('destination_url', $scheme.$host.$port.$path.$queryString.$fragment); - } - - private static function trackingTab(): Tab - { - return Tab::make(__('filament-short-url::default.tab_tracking')) - ->icon('heroicon-o-chart-bar') - ->schema([ - Section::make(__('filament-short-url::default.form_section_tracking')) - ->schema([ - Toggle::make('track_visits') - ->label(__('filament-short-url::default.track_visits')) - ->default(fn () => config('filament-short-url.tracking.enabled', true)) - ->live() - ->inline(false) - ->columnSpanFull(), - ])->columns(1), - - Section::make(__('filament-short-url::default.form_section_tracked_fields')) - ->schema([ - Toggle::make('track_ip_address') - ->label(__('filament-short-url::default.track_ip')) - ->default(fn () => config('filament-short-url.tracking.fields.ip_address', true)) - ->inline(false) - ->disabled(fn (Get $get): bool => ! $get('track_visits')), - - Toggle::make('track_browser') - ->label(__('filament-short-url::default.track_browser')) - ->default(fn () => config('filament-short-url.tracking.fields.browser', true)) - ->inline(false) - ->disabled(fn (Get $get): bool => ! $get('track_visits')), - - Toggle::make('track_browser_version') - ->label(__('filament-short-url::default.track_browser_version')) - ->default(fn () => config('filament-short-url.tracking.fields.browser_version', true)) - ->inline(false) - ->disabled(fn (Get $get): bool => ! $get('track_visits')), - - Toggle::make('track_operating_system') - ->label(__('filament-short-url::default.track_os')) - ->default(fn () => config('filament-short-url.tracking.fields.operating_system', true)) - ->inline(false) - ->disabled(fn (Get $get): bool => ! $get('track_visits')), - - Toggle::make('track_operating_system_version') - ->label(__('filament-short-url::default.track_os_version')) - ->default(fn () => config('filament-short-url.tracking.fields.operating_system_version', true)) - ->inline(false) - ->disabled(fn (Get $get): bool => ! $get('track_visits')), - - Toggle::make('track_device_type') - ->label(__('filament-short-url::default.track_device_type')) - ->default(fn () => config('filament-short-url.tracking.fields.device_type', true)) - ->inline(false) - ->disabled(fn (Get $get): bool => ! $get('track_visits')), - - Toggle::make('track_referer_url') - ->label(__('filament-short-url::default.track_referer')) - ->default(fn () => config('filament-short-url.tracking.fields.referer_url', true)) - ->inline(false) - ->disabled(fn (Get $get): bool => ! $get('track_visits')), - - Toggle::make('track_browser_language') - ->label(__('filament-short-url::default.track_browser_language')) - ->default(fn () => config('filament-short-url.tracking.fields.browser_language', true)) - ->inline(false) - ->disabled(fn (Get $get): bool => ! $get('track_visits')), - ]) - ->columns(4) - ->hidden(fn (Get $get): bool => ! $get('track_visits')), - - Section::make(__('filament-short-url::default.utm_builder')) - ->description(__('filament-short-url::default.utm_builder_helper')) - ->collapsible() - ->collapsed() - ->schema([ - TextInput::make('utm_source') - ->label(__('filament-short-url::default.utm_source')) - ->placeholder(__('filament-short-url::default.utm_source_placeholder')) - ->dehydrated(false) - ->live(onBlur: true) - ->afterStateUpdated(fn (Get $get, Set $set) => static::syncUtmToDestination($get, $set)), - - TextInput::make('utm_medium') - ->label(__('filament-short-url::default.utm_medium')) - ->placeholder(__('filament-short-url::default.utm_medium_placeholder')) - ->dehydrated(false) - ->live(onBlur: true) - ->afterStateUpdated(fn (Get $get, Set $set) => static::syncUtmToDestination($get, $set)), - - TextInput::make('utm_campaign') - ->label(__('filament-short-url::default.utm_campaign')) - ->placeholder(__('filament-short-url::default.utm_campaign_placeholder')) - ->dehydrated(false) - ->live(onBlur: true) - ->afterStateUpdated(fn (Get $get, Set $set) => static::syncUtmToDestination($get, $set)), - - TextInput::make('utm_term') - ->label(__('filament-short-url::default.utm_term')) - ->placeholder(__('filament-short-url::default.utm_term_placeholder')) - ->dehydrated(false) - ->live(onBlur: true) - ->afterStateUpdated(fn (Get $get, Set $set) => static::syncUtmToDestination($get, $set)), - - TextInput::make('utm_content') - ->label(__('filament-short-url::default.utm_content')) - ->placeholder(__('filament-short-url::default.utm_content_placeholder')) - ->dehydrated(false) - ->live(onBlur: true) - ->columnSpanFull() - ->afterStateUpdated(fn (Get $get, Set $set) => static::syncUtmToDestination($get, $set)), - ]) - ->columns(2), - - Section::make(__('filament-short-url::default.form_section_analytics')) - ->schema([ - TextInput::make('ga_tracking_id') - ->label(__('filament-short-url::default.ga_tracking_id')) - ->helperText(__('filament-short-url::default.ga_tracking_id_helper')) - ->placeholder('G-XXXXXXXXXX') - ->regex('/^G-[A-Z0-9]+$/') - ->nullable(), - ]), - ]); - } - - private static function qrDesignTab(): Tab - { - return Tab::make(__('filament-short-url::default.tab_qr_design')) - ->icon('heroicon-o-qr-code') - ->schema([ - Group::make([ - TextInput::make('qr_options') - ->extraAttributes([ - 'style' => 'display: none !important;', - ]) - ->extraInputAttributes([ - 'id' => 'qr-options-json-input', - ]) - ->hiddenLabel() - ->dehydrateStateUsing(fn (?string $state): array => json_decode($state ?? '{}', true) ?: []) - ->afterStateHydrated(function (TextInput $component, mixed $state): void { - $component->state(is_array($state) ? json_encode($state) : ($state ?? '{}')); - }), - - TextInput::make('qr_logo') - ->extraAttributes([ - 'style' => 'display: none !important;', - ]) - ->extraInputAttributes([ - 'id' => 'qr-logo-path-input', - ]) - ->hiddenLabel() - ->nullable(), - ])->extraAttributes([ - 'style' => 'display: none !important;', - ]), - - ViewField::make('qr_designer') - ->view('filament-short-url::qr-designer') - ->columnSpanFull() - ->dehydrated(false), - ]); - } - - private static function targetingTab(): Tab - { - return Tab::make(__('filament-short-url::default.tab_targeting')) - ->icon('heroicon-o-shield-check') - ->schema([ - Section::make(__('filament-short-url::default.security_section_title')) - ->schema([ - Hidden::make('password'), - - Hidden::make('password_active_flag') - ->dehydrated(false) - ->afterStateHydrated(function (Hidden $component, $state, ?ShortUrl $record) { - $component->state($record && ! empty($record->password)); - }), - - Hidden::make('is_entering_password') - ->dehydrated(false) - ->default(false), - - // State 1: Password is active (password_active_flag is true) - Group::make([ - Placeholder::make('password_status') - ->label(__('filament-short-url::default.password')) - ->content(new HtmlString( - '
'. - ''. - ''.(__('filament-short-url::default.password_status_active') ?? 'Password protection is enabled.').''. - '
' - )) - ->columnSpan(1), - - Actions::make([ - Action::make('change_password') - ->label(__('filament-short-url::default.change_password')) - ->icon('heroicon-o-pencil') - ->color('primary') - ->action(function (Set $set) { - $set('password_active_flag', false); - $set('is_entering_password', true); - $set('new_password_input', null); - $set('new_password_confirmation_input', null); - }), - Action::make('remove_password') - ->label(__('filament-short-url::default.remove_password')) - ->icon('heroicon-o-trash') - ->color('danger') - ->requiresConfirmation() - ->action(function (Set $set, ?ShortUrl $record) { - $set('password', null); - $set('new_password_input', null); - $set('new_password_confirmation_input', null); - $set('password_active_flag', false); - $set('is_entering_password', false); - if ($record) { - $record->password = null; - } - }), - ]) - ->columnSpan(1), - ]) - ->columns(2) - ->columnSpanFull() - ->visible(fn (Get $get): bool => (bool) $get('password_active_flag')), - - // State 2: No Password, Setup Button Group (password_active_flag is false, is_entering_password is false) - Group::make([ - Actions::make([ - Action::make('setup_password') - ->label(__('filament-short-url::default.set_password')) - ->icon('heroicon-o-key') - ->color('primary') - ->action(function (Set $set) { - $set('is_entering_password', true); - }), - ]) - ->columnSpanFull(), - ]) - ->columnSpanFull() - ->visible(fn (Get $get): bool => ! $get('password_active_flag') && ! $get('is_entering_password')), - - // State 3: Entering Password Group (password_active_flag is false, is_entering_password is true) - Group::make([ - Group::make([ - TextInput::make('new_password_input') - ->label(__('filament-short-url::default.new_password')) - ->password() - ->revealable() - ->live() - ->maxLength(255) - ->dehydrated(false) - ->required(fn (Get $get): bool => ! $get('password_active_flag') && $get('is_entering_password')) - ->columnSpan(1), - - TextInput::make('new_password_confirmation_input') - ->label(__('filament-short-url::default.confirm_password')) - ->password() - ->revealable() - ->same('new_password_input') - ->maxLength(255) - ->dehydrated(false) - ->required(fn (Get $get): bool => ! empty($get('new_password_input'))) - ->columnSpan(1), - ]) - ->columns(2) - ->columnSpanFull(), - - Actions::make([ - Action::make('confirm_password') - ->label(__('filament-short-url::default.confirm')) - ->icon('heroicon-o-check') - ->color('success') - ->action(function (Get $get, Set $set) { - $password = $get('new_password_input'); - $confirm = $get('new_password_confirmation_input'); - if (empty($password)) { - Notification::make() - ->title(__('filament-short-url::default.password_required_error') ?? 'Password is required.') - ->danger() - ->send(); - - return; - } - if ($password !== $confirm) { - Notification::make() - ->title(__('filament-short-url::default.password_mismatch_error') ?? 'Passwords do not match.') - ->danger() - ->send(); - - return; - } - $set('password', $password); - $set('password_active_flag', true); - $set('is_entering_password', false); - }), - - Action::make('cancel_password') - ->label(__('filament-short-url::default.cancel')) - ->icon('heroicon-o-x-mark') - ->color('gray') - ->action(function (Get $get, Set $set, ?ShortUrl $record) { - if ($record && ! empty($record->password)) { - $set('password_active_flag', true); - } else { - $set('password_active_flag', false); - } - $set('is_entering_password', false); - $set('new_password_input', null); - $set('new_password_confirmation_input', null); - }), - ]) - ->columnSpanFull(), - ]) - ->columnSpanFull() - ->visible(fn (Get $get): bool => ! $get('password_active_flag') && $get('is_entering_password')), - - Toggle::make('show_warning_page') - ->label(__('filament-short-url::default.show_warning_page')) - ->helperText(__('filament-short-url::default.show_warning_page_helper')) - ->default(false) - ->inline(false), - ])->columns(2), - - Section::make(__('filament-short-url::default.targeting_rules')) - ->compact() - ->schema([ - Repeater::make('targeting_rules') - ->label(__('filament-short-url::default.targeting_rules')) - ->hiddenLabel() - ->defaultItems(0) - ->reorderable(false) - ->collapsible() - ->collapsed() - ->itemLabel(fn (array $state): ?string => filled($state['url'] ?? null) ? __('filament-short-url::default.direct_to_url').': '.$state['url'] : null) - ->columns(12) - ->afterStateHydrated(function (Repeater $component, $state) { - if (! is_array($state)) { - $component->state([]); - - return; - } - - // If it is legacy format (has 'type' key) - if (isset($state['type'])) { - $type = $state['type']; - $newRules = []; - - if ($type === 'device') { - $devices = $state['device'] ?? []; - $mobileUrl = $devices['mobile'] ?? $devices['ios'] ?? null; - if ($mobileUrl) { - $newRules[] = [ - 'match' => 'or', - 'url' => $mobileUrl, - 'filters' => [ - [ - 'type' => 'device', - 'data' => ['devices' => ['mobile']], - ], - ], - ]; - } - $tabletUrl = $devices['tablet'] ?? $devices['android'] ?? null; - if ($tabletUrl) { - $newRules[] = [ - 'match' => 'or', - 'url' => $tabletUrl, - 'filters' => [ - [ - 'type' => 'device', - 'data' => ['devices' => ['tablet']], - ], - ], - ]; - } - $desktopUrl = $devices['desktop'] ?? null; - if ($desktopUrl) { - $newRules[] = [ - 'match' => 'or', - 'url' => $desktopUrl, - 'filters' => [ - [ - 'type' => 'device', - 'data' => ['devices' => ['desktop']], - ], - ], - ]; - } - } elseif ($type === 'geo') { - foreach ($state['geo'] ?? [] as $geoRule) { - if (! empty($geoRule['url']) && ! empty($geoRule['country_code'])) { - $newRules[] = [ - 'match' => 'or', - 'url' => $geoRule['url'], - 'filters' => [ - [ - 'type' => 'country', - 'data' => ['countries' => [strtoupper($geoRule['country_code'])]], - ], - ], - ]; - } - } - } elseif ($type === 'language') { - foreach ($state['language'] ?? [] as $langRule) { - if (! empty($langRule['url']) && ! empty($langRule['language_code'])) { - $newRules[] = [ - 'match' => 'or', - 'url' => $langRule['url'], - 'filters' => [ - [ - 'type' => 'language', - 'data' => ['languages' => [strtolower($langRule['language_code'])]], - ], - ], - ]; - } - } - } - - $component->state($newRules); - - return; - } - - if (! array_is_list($state)) { - $component->state([]); - - return; - } - - $component->state($state); - }) - ->schema([ - Select::make('match') - ->label(__('filament-short-url::default.match')) - ->options([ - 'or' => __('filament-short-url::default.match_or'), - 'and' => __('filament-short-url::default.match_and'), - ]) - ->default('or') - ->required() - ->columnSpan(3), - - TextInput::make('url') - ->label(__('filament-short-url::default.direct_to_url')) - ->url() - ->required() - ->maxLength(2048) - ->columnSpan(9), - - Builder::make('filters') - ->label(__('filament-short-url::default.add_filter')) - ->hiddenLabel() - ->addActionLabel(__('filament-short-url::default.add_filter')) - ->reorderable(false) - ->collapsible() - ->blockNumbers(false) - ->addBetweenAction(fn ($action) => $action->hidden()) - ->minItems(1) - ->blocks([ - Block::make('device') - ->label(fn (?array $state) => $state === null - ? __('filament-short-url::default.filter_device') - : new HtmlString(__('filament-short-url::default.select_devices').' *') - ) - ->icon('heroicon-o-device-phone-mobile') - ->schema([ - CheckboxList::make('devices') - ->label(__('filament-short-url::default.select_devices')) - ->hiddenLabel() - ->options([ - 'desktop' => __('filament-short-url::default.device_desktop_label'), - 'mobile' => __('filament-short-url::default.device_mobile_label'), - 'tablet' => __('filament-short-url::default.device_tablet_label'), - ]) - ->required() - ->columns(3) - ->columnSpanFull(), - ]) - ->maxItems(1), - Block::make('platform') - ->label(fn (?array $state) => $state === null - ? __('filament-short-url::default.filter_platform') - : new HtmlString(__('filament-short-url::default.select_platforms').' *') - ) - ->icon('heroicon-o-computer-desktop') - ->schema([ - CheckboxList::make('platforms') - ->label(__('filament-short-url::default.select_platforms')) - ->hiddenLabel() - ->options([ - 'android' => 'Android', - 'fire_os' => 'Fire OS', - 'ios' => 'iOS / iPadOS', - 'linux' => 'Linux', - 'mac' => 'macOS', - 'windows' => 'Windows', - ]) - ->required() - ->columns(3) - ->columnSpanFull(), - ]) - ->maxItems(1), - Block::make('country') - ->label(fn (?array $state) => $state === null - ? __('filament-short-url::default.filter_country') - : new HtmlString(__('filament-short-url::default.select_countries').' *') - ) - ->icon('heroicon-o-globe-alt') - ->schema([ - Select::make('countries') - ->label(__('filament-short-url::default.select_countries')) - ->hiddenLabel() - ->multiple() - ->searchable() - ->allowHtml() - ->options(function (): array { - $countries = __('filament-short-url::countries'); - if (is_array($countries)) { - asort($countries, SORT_LOCALE_STRING); - - $htmlOptions = []; - foreach ($countries as $code => $name) { - $lowerCode = strtolower($code); - $htmlOptions[$code] = "\"{$name}\"{$name}"; - } - - return $htmlOptions; - } - - return []; - }) - ->optionsLimit(300) - ->required() - ->columnSpanFull(), - ]) - ->maxItems(1), - Block::make('language') - ->label(fn (?array $state) => $state === null - ? __('filament-short-url::default.filter_language') - : new HtmlString(__('filament-short-url::default.select_languages').' *') - ) - ->icon('heroicon-o-language') - ->schema([ - Select::make('languages') - ->label(__('filament-short-url::default.select_languages')) - ->hiddenLabel() - ->multiple() - ->searchable() - ->options(function (): array { - $languages = __('filament-short-url::languages'); - if (is_array($languages)) { - asort($languages, SORT_LOCALE_STRING); - - return $languages; - } - - return []; - }) - ->required() - ->columnSpanFull(), - ]) - ->maxItems(1), - ]) - ->rules([ - function () { - return function (string $attribute, $value, \Closure $fail) { - if (! is_array($value)) { - return; - } - $types = collect($value)->pluck('type'); - if ($types->duplicates()->isNotEmpty()) { - $fail('Each filter type (Device, Platform, Country, Language) can only be added once.'); - } - }; - }, - ]) - ->columnSpanFull(), - ]) - ->columnSpanFull() - ->default([]), - ]), - ]); - } - - private static function marketingTab(): Tab - { - return Tab::make(__('filament-short-url::default.tab_marketing')) - ->icon('heroicon-o-megaphone') - ->schema([ - Section::make(__('filament-short-url::default.marketing_pixels_title')) - ->description(__('filament-short-url::default.marketing_pixels_desc')) - ->schema([ - Select::make('pixels') - ->label(__('filament-short-url::default.pixels_navigation_label') ?? 'Retargeting Pixels') - ->multiple() - ->relationship('pixels', 'name', modifyQueryUsing: fn ($query) => $query->where('is_active', true)) - ->preload() - ->searchable() - ->columnSpanFull(), - ]), - - Section::make(__('filament-short-url::default.marketing_webhooks_title')) - ->description(__('filament-short-url::default.marketing_webhooks_desc')) - ->schema([ - Placeholder::make('webhook_info') - ->hiddenLabel() - ->content(new HtmlString( - '
'. - '
'. - ''. - ''. - ''. - '
'. - '
'. - ''. - (__('filament-short-url::default.webhook_helper_alert') ?? 'Once configured, each visit to this short link triggers a real-time HTTP POST request to this URL. The payload contains detailed click metadata in JSON format (including URL key, IP address, country, browser, operating system, referrer, and UTM parameters).'). - ''. - '
'. - '
' - )) - ->columnSpanFull(), - - TextInput::make('webhook_url') - ->label(__('filament-short-url::default.webhook_url')) - ->placeholder('https://api.yourcrm.com/webhooks/clicks') - ->url() - ->maxLength(2048) - ->nullable() - ->columnSpanFull(), - ViewField::make('webhook_payload_example') - ->view('filament-short-url::webhook-payload-example') - ->columnSpanFull(), - ]), - ]); - } - - private static function appLinkingTab(): Tab - { - return Tab::make(__('filament-short-url::default.tab_app_linking') ?? 'App Linking') - ->icon('heroicon-o-device-phone-mobile') - ->schema([ - Section::make(__('filament-short-url::default.form_section_app_linking') ?? 'App Linking / Deep Links') - ->schema([ - Toggle::make('auto_open_app_mobile') - ->label(__('filament-short-url::default.auto_open_app_mobile') ?? 'Auto open app on mobile') - ->helperText(__('filament-short-url::default.auto_open_app_mobile_helper') ?? 'Enable this if you want your link to automatically open as an app when accessed on mobile.') - ->default(false) - ->inline(false) - ->live(), - - ViewField::make('app_linking_preview') - ->view('filament-short-url::app-linking-preview') - ->viewData(fn (Get $get) => [ - 'destinationUrl' => $get('destination_url'), - ]) - ->columnSpanFull() - ->visible(fn (Get $get): bool => (bool) $get('auto_open_app_mobile')), - ]), - ]); - } } diff --git a/src/Filament/Resources/ShortUrlResource/Schemas/Support/WeightBalancer.php b/src/Filament/Resources/ShortUrlResource/Schemas/Support/WeightBalancer.php new file mode 100644 index 0000000..92b5c40 --- /dev/null +++ b/src/Filament/Resources/ShortUrlResource/Schemas/Support/WeightBalancer.php @@ -0,0 +1,114 @@ + + * @copyright 2026 Bartek Janczak + * @license Custom Source-Available License (see LICENSE file) + */ + +namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Support; + +class WeightBalancer +{ + /** + * Balance the weights of variants dynamically to sum up to exactly 100%. + */ + public static function balanceWeights(mixed $component): void + { + $items = $component->getState() ?? []; + $m = count($items); + if ($m === 0) { + return; + } + + $sum = 0; + foreach ($items as $key => $item) { + $items[$key]['weight'] = (int) ($item['weight'] ?? 0); + $sum += $items[$key]['weight']; + } + + $diff = 100 - $sum; + if ($diff !== 0) { + $diffSteps = (int) ($diff / 5); + $changeSteps = (int) ($diffSteps / $m); + $remainderSteps = $diffSteps % $m; + + foreach ($items as $key => $item) { + $items[$key]['weight'] = max(0, min(100, $items[$key]['weight'] + $changeSteps * 5)); + } + + $step = $remainderSteps > 0 ? 5 : -5; + $count = abs($remainderSteps); + $keys = array_keys($items); + for ($i = 0; $i < $count; $i++) { + $k = $keys[$i % $m]; + $items[$k]['weight'] = max(0, min(100, $items[$k]['weight'] + $step)); + } + + // Ensure sum is exactly 100 by forcing the difference on the first eligible item + $finalSum = array_sum(array_column($items, 'weight')); + $finalDiff = 100 - $finalSum; + if ($finalDiff !== 0) { + foreach ($items as $key => $item) { + $newWeight = $items[$key]['weight'] + $finalDiff; + if ($newWeight >= 0 && $newWeight <= 100) { + $items[$key]['weight'] = $newWeight; + break; + } + } + } + + $component->state($items); + $component->getLivewire()->getErrorBag()->forget($component->getStatePath()); + } + } + + /** + * Balance the weights of variants equally (e.g. on add/delete). + */ + public static function balanceWeightsEqually(mixed $component): void + { + $items = $component->getState() ?? []; + $m = count($items); + if ($m === 0) { + return; + } + + $base = (int) (100 / $m); + $remainder = 100 % $m; + + foreach ($items as $key => $item) { + $items[$key]['weight'] = $base; + } + + $keys = array_keys($items); + for ($i = 0; $i < $remainder; $i++) { + $k = $keys[$i]; + $items[$k]['weight'] += 1; + } + + $component->state($items); + + $component->getLivewire()->getErrorBag()->forget($component->getStatePath()); + } + + /** + * Update validation errors on the repeater level when a slider is dragged. + */ + public static function updateRepeaterValidationError(mixed $component): void + { + $statePath = $component->getStatePath(); + $repeaterPath = (string) str($statePath)->beforeLast('.')->beforeLast('.'); + $livewire = $component->getLivewire(); + + $get = $component->makeGetUtility(); + $items = $get($repeaterPath, isAbsolute: true) ?? []; + $sum = array_sum(array_column($items, 'weight')); + + if ($sum !== 100) { + $livewire->addError($repeaterPath, __('filament-short-url::default.weights_sum_error', ['sum' => $sum])); + } else { + $livewire->getErrorBag()->forget($repeaterPath); + } + } +} diff --git a/src/Filament/Resources/ShortUrlResource/Schemas/Tabs/AppLinkingTab.php b/src/Filament/Resources/ShortUrlResource/Schemas/Tabs/AppLinkingTab.php new file mode 100644 index 0000000..cffd79c --- /dev/null +++ b/src/Filament/Resources/ShortUrlResource/Schemas/Tabs/AppLinkingTab.php @@ -0,0 +1,46 @@ + + * @copyright 2026 Bartek Janczak + * @license Custom Source-Available License (see LICENSE file) + */ + +namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs; + +use Filament\Forms\Components\Toggle; +use Filament\Forms\Components\ViewField; +use Filament\Schemas\Components\Section; +use Filament\Schemas\Components\Tabs\Tab; +use Filament\Schemas\Components\Utilities\Get; + +class AppLinkingTab +{ + /** + * Build the app linking details form tab. + */ + public static function make(): Tab + { + return Tab::make(__('filament-short-url::default.tab_app_linking')) + ->icon('heroicon-o-device-phone-mobile') + ->schema([ + Section::make(__('filament-short-url::default.form_section_app_linking')) + ->schema([ + Toggle::make('auto_open_app_mobile') + ->label(__('filament-short-url::default.auto_open_app_mobile')) + ->helperText(__('filament-short-url::default.auto_open_app_mobile_helper')) + ->default(false) + ->inline(false) + ->live(), + + ViewField::make('app_linking_preview') + ->view('filament-short-url::app-linking-preview') + ->viewData(fn (Get $get) => [ + 'destinationUrl' => $get('destination_url'), + ]) + ->columnSpanFull() + ->visible(fn (Get $get): bool => (bool) $get('auto_open_app_mobile')), + ]), + ]); + } +} diff --git a/src/Filament/Resources/ShortUrlResource/Schemas/Tabs/LinkTab.php b/src/Filament/Resources/ShortUrlResource/Schemas/Tabs/LinkTab.php new file mode 100644 index 0000000..1b787e2 --- /dev/null +++ b/src/Filament/Resources/ShortUrlResource/Schemas/Tabs/LinkTab.php @@ -0,0 +1,388 @@ + + * @copyright 2026 Bartek Janczak + * @license Custom Source-Available License (see LICENSE file) + */ + +namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs; + +use Bjanczak\FilamentShortUrl\Filament\Forms\Components\TrafficSplitter; +use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Support\WeightBalancer; +use Bjanczak\FilamentShortUrl\Models\ShortUrl; +use Bjanczak\FilamentShortUrl\Models\ShortUrlCustomDomain; +use Bjanczak\FilamentShortUrl\Rules\SafeUrl; +use Bjanczak\FilamentShortUrl\Services\ShortUrlService; +use Filament\Actions\Action; +use Filament\Forms\Components\DateTimePicker; +use Filament\Forms\Components\Hidden; +use Filament\Forms\Components\Repeater; +use Filament\Forms\Components\Repeater\TableColumn; +use Filament\Forms\Components\Select; +use Filament\Forms\Components\Textarea; +use Filament\Forms\Components\TextInput; +use Filament\Forms\Components\Toggle; +use Filament\Forms\Components\ToggleButtons; +use Filament\Schemas\Components\FusedGroup; +use Filament\Schemas\Components\Section; +use Filament\Schemas\Components\Tabs\Tab; +use Filament\Schemas\Components\Utilities\Get; +use Filament\Schemas\Components\Utilities\Set; +use Illuminate\Support\Str; + +class LinkTab +{ + /** + * Build the link details form tab. + */ + public static function make(): Tab + { + return Tab::make(__('filament-short-url::default.tab_link')) + ->icon('heroicon-o-link') + ->schema([ + Section::make()->schema([ + ToggleButtons::make('destination_type') + ->label(__('filament-short-url::default.destination_type')) + ->options([ + 'single' => __('filament-short-url::default.destination_type_single'), + 'split' => __('filament-short-url::default.destination_type_split'), + ]) + ->colors([ + 'single' => 'primary', + 'split' => 'warning', + ]) + ->icons([ + 'single' => 'heroicon-o-link', + 'split' => 'heroicon-o-arrow-path-rounded-square', + ]) + ->default('single') + ->live() + ->inline() + ->columnSpanFull() + ->afterStateUpdated(function ($state, Set $set, Get $get) { + if ($state === 'split' && empty($get('rotation_variants'))) { + $set('rotation_variants', [ + (string) Str::uuid() => ['label' => 'Variant A', 'url' => '', 'weight' => 50], + (string) Str::uuid() => ['label' => 'Variant B', 'url' => '', 'weight' => 50], + ]); + } + }), + + TextInput::make('destination_url') + ->label(__('filament-short-url::default.destination_url')) + ->helperText(__('filament-short-url::default.destination_url_helper')) + ->required(fn (Get $get): bool => $get('destination_type') === 'single' || ! $get('destination_type')) + ->visible(fn (Get $get): bool => $get('destination_type') === 'single' || ! $get('destination_type')) + ->url() + ->maxLength(2048) + ->rules([ + app(SafeUrl::class), + ]) + ->live(onBlur: true) + ->afterStateHydrated(function (TextInput $component, $state, Set $set) { + if (! $state) { + return; + } + $parts = parse_url($state); + if (isset($parts['query'])) { + parse_str($parts['query'], $query); + $set('utm_source', $query['utm_source'] ?? null); + $set('utm_medium', $query['utm_medium'] ?? null); + $set('utm_campaign', $query['utm_campaign'] ?? null); + $set('utm_term', $query['utm_term'] ?? null); + $set('utm_content', $query['utm_content'] ?? null); + } + }) + ->afterStateUpdated(function ($state, Set $set) { + if (! $state) { + return; + } + $parts = parse_url($state); + if (isset($parts['query'])) { + parse_str($parts['query'], $query); + $set('utm_source', $query['utm_source'] ?? null); + $set('utm_medium', $query['utm_medium'] ?? null); + $set('utm_campaign', $query['utm_campaign'] ?? null); + $set('utm_term', $query['utm_term'] ?? null); + $set('utm_content', $query['utm_content'] ?? null); + } else { + $set('utm_source', null); + $set('utm_medium', null); + $set('utm_campaign', null); + $set('utm_term', null); + $set('utm_content', null); + } + }) + ->columnSpanFull(), + + Repeater::make('rotation_variants') + ->hiddenLabel() + ->extraAttributes(['class' => 'ab-test-repeater']) + ->table([ + TableColumn::make(__('filament-short-url::default.variant_label')) + ->width('30%'), + TableColumn::make(__('filament-short-url::default.variant_url')) + ->width('70%'), + ]) + ->schema([ + TextInput::make('label') + ->hiddenLabel() + ->placeholder('e.g. Variant A') + ->required() + ->maxLength(100), + TextInput::make('url') + ->hiddenLabel() + ->url() + ->required() + ->maxLength(2048) + ->rules([ + app(SafeUrl::class), + ]), + Hidden::make('weight'), + ]) + ->defaultItems(2) + ->minItems(2) + ->maxItems(5) + ->reorderable(false) + ->live() + ->rules([ + function () { + return function (string $attribute, $value, \Closure $fail) { + if (! is_array($value)) { + return; + } + $sum = array_sum(array_column($value, 'weight')); + if ($sum !== 100) { + $fail(__('filament-short-url::default.weights_sum_error', ['sum' => $sum])); + } + }; + }, + ]) + ->deleteAction( + fn ($action) => $action + ->visible(fn (Get $get): bool => count($get('rotation_variants') ?? []) > 2) + ->after(fn ($component) => WeightBalancer::balanceWeightsEqually($component)) + ) + ->addAction( + fn ($action) => $action->after(fn ($component) => WeightBalancer::balanceWeightsEqually($component)) + ) + ->addActionLabel(__('filament-short-url::default.add_url')) + ->visible(fn (Get $get): bool => $get('destination_type') === 'split') + ->columnSpanFull(), + + TrafficSplitter::make('traffic_split') + ->label(__('filament-short-url::default.traffic_split')) + ->target('rotation_variants') + ->visible(fn (Get $get): bool => $get('destination_type') === 'split') + ->columnSpanFull(), + + FusedGroup::make([ + Select::make('custom_domain_id') + ->hiddenLabel() + ->options(function () { + $domains = ShortUrlCustomDomain::where('is_active', true) + ->where('is_verified', true) + ->pluck('domain', 'id'); + + if (! config('filament-short-url.disable_default_domain', false)) { + $defaultDomain = request()->getHost() ?: parse_url(config('app.url'), PHP_URL_HOST); + $domains = collect(['default' => $defaultDomain])->union($domains); + } + + return $domains; + }) + ->default(function () { + if (! config('filament-short-url.disable_default_domain', false)) { + return 'default'; + } + + $firstDomain = ShortUrlCustomDomain::where('is_active', true) + ->where('is_verified', true) + ->first(); + + return $firstDomain ? $firstDomain->id : null; + }) + ->afterStateHydrated(function (Select $component, $state) { + if ($state === null && ! config('filament-short-url.disable_default_domain', false)) { + $component->state('default'); + } + }) + ->dehydrateStateUsing(fn ($state) => $state === 'default' ? null : $state) + ->disabled(function (?ShortUrl $record) { + if ($record && $record->exists && config('filament-short-url.lock_url_key', false)) { + return true; + } + + $domainsCount = ShortUrlCustomDomain::where('is_active', true) + ->where('is_verified', true) + ->count(); + + $defaultEnabled = ! config('filament-short-url.disable_default_domain', false); + $totalOptionsCount = $domainsCount + ($defaultEnabled ? 1 : 0); + + return $totalOptionsCount <= 1; + }) + ->dehydrated() + ->required(fn () => (bool) config('filament-short-url.disable_default_domain', false)) + ->selectablePlaceholder(false) + ->nullable() + ->native(false), + + TextInput::make('url_key') + ->hiddenLabel() + ->helperText(__('filament-short-url::default.url_key_helper')) + ->alphaDash() + ->maxLength(32) + ->unique('short_urls', 'url_key', ignoreRecord: true) + ->disabled(fn (?ShortUrl $record) => $record && $record->exists && config('filament-short-url.lock_url_key', false)) + ->placeholder('auto-generated') + ->suffixAction( + Action::make('regenerate') + ->icon('heroicon-o-arrow-path') + ->tooltip('Generate new key') + ->action(function (Set $set, ShortUrlService $service): void { + $set('url_key', $service->generateKey()); + }) + ->visible(fn (?ShortUrl $record) => ! ($record && $record->exists && config('filament-short-url.lock_url_key', false))) + ), + ]) + ->extraAttributes(['class' => 'custom-fused']) + ->label(__('filament-short-url::default.short_link_label')) + ->columns(2) + ->columnSpanFull(), + + Select::make('redirect_status_code') + ->label(__('filament-short-url::default.redirect_code')) + ->options([ + 302 => __('filament-short-url::default.redirect_code_302'), + 301 => __('filament-short-url::default.redirect_code_301'), + ]) + ->default(fn () => config('filament-short-url.redirect_status_code', 302)) + ->required(), + ])->columns(2), + + Section::make(__('filament-short-url::default.form_section_options'))->schema([ + Toggle::make('is_enabled') + ->label(__('filament-short-url::default.status')) + ->default(true) + ->inline(false), + + Toggle::make('forward_query_params') + ->label(__('filament-short-url::default.forward_query_params')) + ->helperText(__('filament-short-url::default.forward_query_params_helper')) + ->default(false) + ->inline(false), + ])->columns(2), + + Section::make(__('filament-short-url::default.form_section_validity')) + ->schema([ + Toggle::make('use_date_validity') + ->label(__('filament-short-url::default.use_date_validity')) + ->dehydrated(false) + ->live() + ->afterStateHydrated(function (Toggle $component, $state, Get $get, Set $set) { + $set('use_date_validity', $get('activated_at') !== null || $get('expires_at') !== null); + }) + ->afterStateUpdated(function ($state, Set $set) { + if ($state) { + $set('activated_at', now()->startOfMinute()); + } else { + $set('activated_at', null); + $set('expires_at', null); + $set('expiration_redirect_url', null); + } + }) + ->columnSpanFull(), + + DateTimePicker::make('activated_at') + ->label(__('filament-short-url::default.activated_at')) + ->nullable() + ->native(false) + ->withoutSeconds() + ->live(onBlur: true) + ->required(fn (Get $get): bool => (bool) $get('use_date_validity')) + ->visible(fn (Get $get): bool => (bool) $get('use_date_validity')) + ->minDate(now()->startOfDay()) + ->maxDate(fn (Get $get) => $get('expires_at')), + + DateTimePicker::make('expires_at') + ->label(__('filament-short-url::default.expires_at')) + ->nullable() + ->native(false) + ->withoutSeconds() + ->live(onBlur: true) + ->visible(fn (Get $get): bool => (bool) $get('use_date_validity')) + ->minDate(fn (Get $get) => $get('activated_at') ?: now()->startOfDay()), + + TextInput::make('expiration_redirect_url') + ->label(__('filament-short-url::default.expiration_redirect_url')) + ->helperText(__('filament-short-url::default.expiration_redirect_url_helper')) + ->url() + ->maxLength(2048) + ->nullable() + ->visible(fn (Get $get): bool => (bool) $get('use_date_validity')) + ->columnSpanFull(), + + Toggle::make('single_use') + ->label(__('filament-short-url::default.single_use')) + ->helperText(__('filament-short-url::default.single_use_helper')) + ->default(false) + ->inline(false) + ->live(), + + TextInput::make('max_visits') + ->label(__('filament-short-url::default.max_visits')) + ->helperText(__('filament-short-url::default.max_visits_helper')) + ->numeric() + ->integer() + ->minValue(1) + ->nullable() + ->hidden(fn (Get $get): bool => (bool) $get('single_use')), + ])->columns(2), + + Section::make(__('filament-short-url::default.form_section_notes'))->schema([ + Textarea::make('notes') + ->label(__('filament-short-url::default.notes')) + ->rows(3) + ->columnSpanFull(), + ]), + ]); + } + + /** + * Synchronize campaign parameters into the query parameters of the destination URL. + */ + public static function syncUtmToDestination(Get $get, Set $set): void + { + $url = $get('destination_url'); + if (! $url) { + return; + } + + $parts = parse_url($url); + $query = []; + if (isset($parts['query'])) { + parse_str($parts['query'], $query); + } + + $utms = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content']; + foreach ($utms as $utm) { + $val = $get($utm); + if ($val !== null && $val !== '') { + $query[$utm] = $val; + } else { + unset($query[$utm]); + } + } + + $scheme = isset($parts['scheme']) ? $parts['scheme'].'://' : ''; + $host = $parts['host'] ?? ''; + $port = isset($parts['port']) ? ':'.$parts['port'] : ''; + $path = $parts['path'] ?? ''; + $queryString = ! empty($query) ? '?'.http_build_query($query) : ''; + $fragment = isset($parts['fragment']) ? '#'.$parts['fragment'] : ''; + + $set('destination_url', $scheme.$host.$port.$path.$queryString.$fragment); + } +} diff --git a/src/Filament/Resources/ShortUrlResource/Schemas/Tabs/MarketingTab.php b/src/Filament/Resources/ShortUrlResource/Schemas/Tabs/MarketingTab.php new file mode 100644 index 0000000..6c8da8e --- /dev/null +++ b/src/Filament/Resources/ShortUrlResource/Schemas/Tabs/MarketingTab.php @@ -0,0 +1,75 @@ + + * @copyright 2026 Bartek Janczak + * @license Custom Source-Available License (see LICENSE file) + */ + +namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs; + +use Filament\Forms\Components\Placeholder; +use Filament\Forms\Components\Select; +use Filament\Forms\Components\TextInput; +use Filament\Forms\Components\ViewField; +use Filament\Schemas\Components\Section; +use Filament\Schemas\Components\Tabs\Tab; +use Illuminate\Support\HtmlString; + +class MarketingTab +{ + /** + * Build the marketing details form tab. + */ + public static function make(): Tab + { + return Tab::make(__('filament-short-url::default.tab_marketing')) + ->icon('heroicon-o-megaphone') + ->schema([ + Section::make(__('filament-short-url::default.marketing_pixels_title')) + ->description(__('filament-short-url::default.marketing_pixels_desc')) + ->schema([ + Select::make('pixels') + ->label(__('filament-short-url::default.pixels_navigation_label')) + ->multiple() + ->relationship('pixels', 'name', modifyQueryUsing: fn ($query) => $query->where('is_active', true)) + ->preload() + ->searchable() + ->columnSpanFull(), + ]), + + Section::make(__('filament-short-url::default.marketing_webhooks_title')) + ->description(__('filament-short-url::default.marketing_webhooks_desc')) + ->schema([ + Placeholder::make('webhook_info') + ->hiddenLabel() + ->content(new HtmlString( + '
'. + '
'. + ''. + ''. + ''. + '
'. + '
'. + ''. + __('filament-short-url::default.webhook_helper_alert'). + ''. + '
'. + '
' + )) + ->columnSpanFull(), + + TextInput::make('webhook_url') + ->label(__('filament-short-url::default.webhook_url')) + ->placeholder('https://api.yourcrm.com/webhooks/clicks') + ->url() + ->maxLength(2048) + ->nullable() + ->columnSpanFull(), + ViewField::make('webhook_payload_example') + ->view('filament-short-url::webhook-payload-example') + ->columnSpanFull(), + ]), + ]); + } +} diff --git a/src/Filament/Resources/ShortUrlResource/Schemas/Tabs/QrDesignTab.php b/src/Filament/Resources/ShortUrlResource/Schemas/Tabs/QrDesignTab.php new file mode 100644 index 0000000..fca1890 --- /dev/null +++ b/src/Filament/Resources/ShortUrlResource/Schemas/Tabs/QrDesignTab.php @@ -0,0 +1,59 @@ + + * @copyright 2026 Bartek Janczak + * @license Custom Source-Available License (see LICENSE file) + */ + +namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs; + +use Filament\Forms\Components\TextInput; +use Filament\Forms\Components\ViewField; +use Filament\Schemas\Components\Group; +use Filament\Schemas\Components\Tabs\Tab; + +class QrDesignTab +{ + /** + * Build the QR code design form tab. + */ + public static function make(): Tab + { + return Tab::make(__('filament-short-url::default.tab_qr_design')) + ->icon('heroicon-o-qr-code') + ->schema([ + Group::make([ + TextInput::make('qr_options') + ->extraAttributes([ + 'style' => 'display: none !important;', + ]) + ->extraInputAttributes([ + 'id' => 'qr-options-json-input', + ]) + ->hiddenLabel() + ->dehydrateStateUsing(fn (?string $state): array => json_decode($state ?? '{}', true) ?: []) + ->afterStateHydrated(function (TextInput $component, mixed $state): void { + $component->state(is_array($state) ? json_encode($state) : ($state ?? '{}')); + }), + + TextInput::make('qr_logo') + ->extraAttributes([ + 'style' => 'display: none !important;', + ]) + ->extraInputAttributes([ + 'id' => 'qr-logo-path-input', + ]) + ->hiddenLabel() + ->nullable(), + ])->extraAttributes([ + 'style' => 'display: none !important;', + ]), + + ViewField::make('qr_designer') + ->view('filament-short-url::qr-designer') + ->columnSpanFull() + ->dehydrated(false), + ]); + } +} diff --git a/src/Filament/Resources/ShortUrlResource/Schemas/Tabs/TargetingTab.php b/src/Filament/Resources/ShortUrlResource/Schemas/Tabs/TargetingTab.php new file mode 100644 index 0000000..82a4870 --- /dev/null +++ b/src/Filament/Resources/ShortUrlResource/Schemas/Tabs/TargetingTab.php @@ -0,0 +1,548 @@ + + * @copyright 2026 Bartek Janczak + * @license Custom Source-Available License (see LICENSE file) + */ + +namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs; + +use Bjanczak\FilamentShortUrl\Filament\Forms\Components\TrafficSplitter; +use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Support\WeightBalancer; +use Bjanczak\FilamentShortUrl\Models\ShortUrl; +use Bjanczak\FilamentShortUrl\Rules\SafeUrl; +use Filament\Actions\Action; +use Filament\Forms\Components\Builder; +use Filament\Forms\Components\Builder\Block; +use Filament\Forms\Components\CheckboxList; +use Filament\Forms\Components\Hidden; +use Filament\Forms\Components\Placeholder; +use Filament\Forms\Components\Repeater; +use Filament\Forms\Components\Repeater\TableColumn; +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\Group; +use Filament\Schemas\Components\Section; +use Filament\Schemas\Components\Tabs\Tab; +use Filament\Schemas\Components\Utilities\Get; +use Filament\Schemas\Components\Utilities\Set; +use Illuminate\Support\HtmlString; +use Illuminate\Support\Str; + +class TargetingTab +{ + /** + * Build the targeting and security form tab. + */ + public static function make(): Tab + { + return Tab::make(__('filament-short-url::default.tab_targeting')) + ->icon('heroicon-o-shield-check') + ->schema([ + Section::make(__('filament-short-url::default.security_section_title')) + ->schema([ + Hidden::make('password'), + + Hidden::make('password_active_flag') + ->dehydrated(false) + ->afterStateHydrated(function (Hidden $component, $state, ?ShortUrl $record) { + $component->state($record && ! empty($record->password)); + }), + + Hidden::make('is_entering_password') + ->dehydrated(false) + ->default(false), + + // State 1: Password is active (password_active_flag is true) + Group::make([ + Placeholder::make('password_status') + ->label(__('filament-short-url::default.password')) + ->content(new HtmlString( + '
'. + ''. + ''.__('filament-short-url::default.password_status_active').''. + '
' + )) + ->columnSpan(1), + + Actions::make([ + Action::make('change_password') + ->label(__('filament-short-url::default.change_password')) + ->icon('heroicon-o-pencil') + ->color('primary') + ->action(function (Set $set) { + $set('password_active_flag', false); + $set('is_entering_password', true); + $set('new_password_input', null); + $set('new_password_confirmation_input', null); + }), + Action::make('remove_password') + ->label(__('filament-short-url::default.remove_password')) + ->icon('heroicon-o-trash') + ->color('danger') + ->requiresConfirmation() + ->action(function (Set $set, ?ShortUrl $record) { + $set('password', null); + $set('new_password_input', null); + $set('new_password_confirmation_input', null); + $set('password_active_flag', false); + $set('is_entering_password', false); + if ($record) { + $record->password = null; + } + }), + ]) + ->columnSpan(1), + ]) + ->columns(2) + ->columnSpanFull() + ->visible(fn (Get $get): bool => (bool) $get('password_active_flag')), + + // State 2: No Password, Setup Button Group (password_active_flag is false, is_entering_password is false) + Group::make([ + Actions::make([ + Action::make('setup_password') + ->label(__('filament-short-url::default.set_password')) + ->icon('heroicon-o-key') + ->color('primary') + ->action(function (Set $set) { + $set('is_entering_password', true); + }), + ]) + ->columnSpanFull(), + ]) + ->columnSpanFull() + ->visible(fn (Get $get): bool => ! $get('password_active_flag') && ! $get('is_entering_password')), + + // State 3: Entering Password Group (password_active_flag is false, is_entering_password is true) + Group::make([ + Group::make([ + TextInput::make('new_password_input') + ->label(__('filament-short-url::default.new_password')) + ->password() + ->revealable() + ->live() + ->maxLength(255) + ->dehydrated(false) + ->required(fn (Get $get): bool => ! $get('password_active_flag') && $get('is_entering_password')) + ->columnSpan(1), + + TextInput::make('new_password_confirmation_input') + ->label(__('filament-short-url::default.confirm_password')) + ->password() + ->revealable() + ->same('new_password_input') + ->maxLength(255) + ->dehydrated(false) + ->required(fn (Get $get): bool => ! empty($get('new_password_input'))) + ->columnSpan(1), + ]) + ->columns(2) + ->columnSpanFull(), + + Actions::make([ + Action::make('confirm_password') + ->label(__('filament-short-url::default.confirm')) + ->icon('heroicon-o-check') + ->color('success') + ->action(function (Get $get, Set $set) { + $password = $get('new_password_input'); + $confirm = $get('new_password_confirmation_input'); + if (empty($password)) { + Notification::make() + ->title(__('filament-short-url::default.password_required_error')) + ->danger() + ->send(); + + return; + } + if ($password !== $confirm) { + Notification::make() + ->title(__('filament-short-url::default.password_mismatch_error')) + ->danger() + ->send(); + + return; + } + $set('password', $password); + $set('password_active_flag', true); + $set('is_entering_password', false); + }), + + Action::make('cancel_password') + ->label(__('filament-short-url::default.cancel')) + ->icon('heroicon-o-x-mark') + ->color('gray') + ->action(function (Get $get, Set $set, ?ShortUrl $record) { + if ($record && ! empty($record->password)) { + $set('password_active_flag', true); + } else { + $set('password_active_flag', false); + } + $set('is_entering_password', false); + $set('new_password_input', null); + $set('new_password_confirmation_input', null); + }), + ]) + ->columnSpanFull(), + ]) + ->columnSpanFull() + ->visible(fn (Get $get): bool => ! $get('password_active_flag') && $get('is_entering_password')), + + Toggle::make('show_warning_page') + ->label(__('filament-short-url::default.show_warning_page')) + ->helperText(__('filament-short-url::default.show_warning_page_helper')) + ->default(false) + ->inline(false), + ])->columns(2), + + Section::make(__('filament-short-url::default.targeting_rules')) + ->compact() + ->schema([ + Repeater::make('targeting_rules') + ->label(__('filament-short-url::default.targeting_rules')) + ->hiddenLabel() + ->defaultItems(0) + ->maxItems(10) + ->reorderable(false) + ->collapsible() + ->collapsed() + ->columns(12) + ->afterStateHydrated(function (Repeater $component, $state) { + if (! is_array($state)) { + $component->state([]); + + return; + } + + // If it is legacy format (has 'type' key) + if (isset($state['type'])) { + $type = $state['type']; + $newRules = []; + + if ($type === 'device') { + $devices = $state['device'] ?? []; + $mobileUrl = $devices['mobile'] ?? $devices['ios'] ?? null; + if ($mobileUrl) { + $newRules[] = [ + 'match' => 'or', + 'url' => $mobileUrl, + 'filters' => [ + [ + 'type' => 'device', + 'data' => ['devices' => ['mobile']], + ], + ], + ]; + } + $tabletUrl = $devices['tablet'] ?? $devices['android'] ?? null; + if ($tabletUrl) { + $newRules[] = [ + 'match' => 'or', + 'url' => $tabletUrl, + 'filters' => [ + [ + 'type' => 'device', + 'data' => ['devices' => ['tablet']], + ], + ], + ]; + } + $desktopUrl = $devices['desktop'] ?? null; + if ($desktopUrl) { + $newRules[] = [ + 'match' => 'or', + 'url' => $desktopUrl, + 'filters' => [ + [ + 'type' => 'device', + 'data' => ['devices' => ['desktop']], + ], + ], + ]; + } + } elseif ($type === 'geo') { + foreach ($state['geo'] ?? [] as $geoRule) { + if (! empty($geoRule['url']) && ! empty($geoRule['country_code'])) { + $newRules[] = [ + 'match' => 'or', + 'url' => $geoRule['url'], + 'filters' => [ + [ + 'type' => 'country', + 'data' => ['countries' => [strtoupper($geoRule['country_code'])]], + ], + ], + ]; + } + } + } elseif ($type === 'language') { + foreach ($state['language'] ?? [] as $langRule) { + if (! empty($langRule['url']) && ! empty($langRule['language_code'])) { + $newRules[] = [ + 'match' => 'or', + 'url' => $langRule['url'], + 'filters' => [ + [ + 'type' => 'language', + 'data' => ['languages' => [strtolower($langRule['language_code'])]], + ], + ], + ]; + } + } + } + + $component->state($newRules); + + return; + } + + if (! array_is_list($state)) { + $component->state([]); + + return; + } + + $component->state($state); + }) + ->schema([ + Select::make('match') + ->label(__('filament-short-url::default.match')) + ->options([ + 'or' => __('filament-short-url::default.match_or'), + 'and' => __('filament-short-url::default.match_and'), + ]) + ->default('or') + ->required() + ->columnSpan(3), + + Select::make('destination_type') + ->label(__('filament-short-url::default.destination_type')) + ->options([ + 'single' => __('filament-short-url::default.destination_type_single'), + 'split' => __('filament-short-url::default.destination_type_split'), + ]) + ->default('single') + ->live() + ->required() + ->columnSpan(3) + ->afterStateUpdated(function ($state, Set $set, Get $get) { + if ($state === 'split' && empty($get('variants'))) { + $set('variants', [ + (string) Str::uuid() => ['label' => 'Variant A', 'url' => '', 'weight' => 50], + (string) Str::uuid() => ['label' => 'Variant B', 'url' => '', 'weight' => 50], + ]); + } + }), + + TextInput::make('url') + ->label(__('filament-short-url::default.direct_to_url')) + ->url() + ->required(fn (Get $get): bool => $get('destination_type') === 'single' || ! $get('destination_type')) + ->visible(fn (Get $get): bool => $get('destination_type') === 'single' || ! $get('destination_type')) + ->maxLength(2048) + ->rules([ + app(SafeUrl::class), + ]) + ->columnSpan(6), + + Repeater::make('variants') + ->hiddenLabel() + ->extraAttributes(['class' => 'ab-test-repeater']) + ->table([ + TableColumn::make(__('filament-short-url::default.variant_label')) + ->width('30%'), + TableColumn::make(__('filament-short-url::default.variant_url')) + ->width('70%'), + ]) + ->schema([ + TextInput::make('label') + ->hiddenLabel() + ->placeholder('e.g. Variant A') + ->required() + ->maxLength(100), + TextInput::make('url') + ->hiddenLabel() + ->url() + ->required() + ->maxLength(2048) + ->rules([ + app(SafeUrl::class), + ]), + Hidden::make('weight'), + ]) + ->defaultItems(2) + ->minItems(2) + ->maxItems(5) + ->reorderable(false) + ->live() + ->rules([ + function () { + return function (string $attribute, $value, \Closure $fail) { + if (! is_array($value)) { + return; + } + $sum = array_sum(array_column($value, 'weight')); + if ($sum !== 100) { + $fail(__('filament-short-url::default.weights_sum_error', ['sum' => $sum])); + } + }; + }, + ]) + ->deleteAction( + fn ($action) => $action + ->visible(fn (Get $get): bool => count($get('variants') ?? []) > 2) + ->after(fn ($component) => WeightBalancer::balanceWeightsEqually($component)) + ) + ->addAction( + fn ($action) => $action->after(fn ($component) => WeightBalancer::balanceWeightsEqually($component)) + ) + ->visible(fn (Get $get): bool => $get('destination_type') === 'split') + ->columnSpanFull(), + + TrafficSplitter::make('traffic_split') + ->label(__('filament-short-url::default.traffic_split')) + ->target('variants') + ->visible(fn (Get $get): bool => $get('destination_type') === 'split') + ->columnSpanFull(), + + Builder::make('filters') + ->label(__('filament-short-url::default.add_filter')) + ->hiddenLabel() + ->addActionLabel(__('filament-short-url::default.add_filter')) + ->reorderable(false) + ->collapsible() + ->blockNumbers(false) + ->addBetweenAction(fn ($action) => $action->hidden()) + ->minItems(1) + ->blocks([ + Block::make('device') + ->label(fn (?array $state) => $state === null + ? __('filament-short-url::default.filter_device') + : new HtmlString(__('filament-short-url::default.select_devices').' *') + ) + ->icon('heroicon-o-device-phone-mobile') + ->schema([ + CheckboxList::make('devices') + ->label(__('filament-short-url::default.select_devices')) + ->hiddenLabel() + ->options([ + 'desktop' => __('filament-short-url::default.device_desktop_label'), + 'mobile' => __('filament-short-url::default.device_mobile_label'), + 'tablet' => __('filament-short-url::default.device_tablet_label'), + ]) + ->required() + ->columns(3) + ->columnSpanFull(), + ]) + ->maxItems(1), + Block::make('platform') + ->label(fn (?array $state) => $state === null + ? __('filament-short-url::default.filter_platform') + : new HtmlString(__('filament-short-url::default.select_platforms').' *') + ) + ->icon('heroicon-o-computer-desktop') + ->schema([ + CheckboxList::make('platforms') + ->label(__('filament-short-url::default.select_platforms')) + ->hiddenLabel() + ->options([ + 'android' => 'Android', + 'fire_os' => 'Fire OS', + 'ios' => 'iOS / iPadOS', + 'linux' => 'Linux', + 'mac' => 'macOS', + 'windows' => 'Windows', + ]) + ->required() + ->columns(3) + ->columnSpanFull(), + ]) + ->maxItems(1), + Block::make('country') + ->label(fn (?array $state) => $state === null + ? __('filament-short-url::default.filter_country') + : new HtmlString(__('filament-short-url::default.select_countries').' *') + ) + ->icon('heroicon-o-globe-alt') + ->schema([ + Select::make('countries') + ->label(__('filament-short-url::default.select_countries')) + ->hiddenLabel() + ->multiple() + ->searchable() + ->allowHtml() + ->options(function (): array { + $countries = __('filament-short-url::countries'); + if (is_array($countries)) { + asort($countries, SORT_LOCALE_STRING); + + $htmlOptions = []; + foreach ($countries as $code => $name) { + $lowerCode = strtolower($code); + $htmlOptions[$code] = "\"{$name}\"{$name}"; + } + + return $htmlOptions; + } + + return []; + }) + ->optionsLimit(300) + ->required() + ->columnSpanFull(), + ]) + ->maxItems(1), + Block::make('language') + ->label(fn (?array $state) => $state === null + ? __('filament-short-url::default.filter_language') + : new HtmlString(__('filament-short-url::default.select_languages').' *') + ) + ->icon('heroicon-o-language') + ->schema([ + Select::make('languages') + ->label(__('filament-short-url::default.select_languages')) + ->hiddenLabel() + ->multiple() + ->searchable() + ->options(function (): array { + $languages = __('filament-short-url::languages'); + if (is_array($languages)) { + asort($languages, SORT_LOCALE_STRING); + + return $languages; + } + + return []; + }) + ->required() + ->columnSpanFull(), + ]) + ->maxItems(1), + ]) + ->rules([ + function () { + return function (string $attribute, $value, \Closure $fail) { + if (! is_array($value)) { + return; + } + $types = collect($value)->pluck('type'); + if ($types->duplicates()->isNotEmpty()) { + $fail('Each filter type (Device, Platform, Country, Language) can only be added once.'); + } + }; + }, + ]) + ->columnSpanFull(), + ]) + ->columnSpanFull() + ->default([]), + ]), + ]); + } +} diff --git a/src/Filament/Resources/ShortUrlResource/Schemas/Tabs/TrackingTab.php b/src/Filament/Resources/ShortUrlResource/Schemas/Tabs/TrackingTab.php new file mode 100644 index 0000000..e59ba04 --- /dev/null +++ b/src/Filament/Resources/ShortUrlResource/Schemas/Tabs/TrackingTab.php @@ -0,0 +1,145 @@ + + * @copyright 2026 Bartek Janczak + * @license Custom Source-Available License (see LICENSE file) + */ + +namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs; + +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 Filament\Schemas\Components\Utilities\Set; + +class TrackingTab +{ + /** + * Build the tracking details form tab. + */ + public static function make(): Tab + { + return Tab::make(__('filament-short-url::default.tab_tracking')) + ->icon('heroicon-o-chart-bar') + ->schema([ + Section::make(__('filament-short-url::default.form_section_tracking')) + ->schema([ + Toggle::make('track_visits') + ->label(__('filament-short-url::default.track_visits')) + ->default(fn () => config('filament-short-url.tracking.enabled', true)) + ->live() + ->inline(false) + ->columnSpanFull(), + ])->columns(1), + + Section::make(__('filament-short-url::default.form_section_tracked_fields')) + ->schema([ + Toggle::make('track_ip_address') + ->label(__('filament-short-url::default.track_ip')) + ->default(fn () => config('filament-short-url.tracking.fields.ip_address', true)) + ->inline(false) + ->disabled(fn (Get $get): bool => ! $get('track_visits')), + + Toggle::make('track_browser') + ->label(__('filament-short-url::default.track_browser')) + ->default(fn () => config('filament-short-url.tracking.fields.browser', true)) + ->inline(false) + ->disabled(fn (Get $get): bool => ! $get('track_visits')), + + Toggle::make('track_browser_version') + ->label(__('filament-short-url::default.track_browser_version')) + ->default(fn () => config('filament-short-url.tracking.fields.browser_version', true)) + ->inline(false) + ->disabled(fn (Get $get): bool => ! $get('track_visits')), + + Toggle::make('track_operating_system') + ->label(__('filament-short-url::default.track_os')) + ->default(fn () => config('filament-short-url.tracking.fields.operating_system', true)) + ->inline(false) + ->disabled(fn (Get $get): bool => ! $get('track_visits')), + + Toggle::make('track_operating_system_version') + ->label(__('filament-short-url::default.track_os_version')) + ->default(fn () => config('filament-short-url.tracking.fields.operating_system_version', true)) + ->inline(false) + ->disabled(fn (Get $get): bool => ! $get('track_visits')), + + Toggle::make('track_device_type') + ->label(__('filament-short-url::default.track_device_type')) + ->default(fn () => config('filament-short-url.tracking.fields.device_type', true)) + ->inline(false) + ->disabled(fn (Get $get): bool => ! $get('track_visits')), + + Toggle::make('track_referer_url') + ->label(__('filament-short-url::default.track_referer')) + ->default(fn () => config('filament-short-url.tracking.fields.referer_url', true)) + ->inline(false) + ->disabled(fn (Get $get): bool => ! $get('track_visits')), + + Toggle::make('track_browser_language') + ->label(__('filament-short-url::default.track_browser_language')) + ->default(fn () => config('filament-short-url.tracking.fields.browser_language', true)) + ->inline(false) + ->disabled(fn (Get $get): bool => ! $get('track_visits')), + ]) + ->columns(4) + ->hidden(fn (Get $get): bool => ! $get('track_visits')), + + Section::make(__('filament-short-url::default.utm_builder')) + ->description(__('filament-short-url::default.utm_builder_helper')) + ->collapsible() + ->collapsed() + ->schema([ + TextInput::make('utm_source') + ->label(__('filament-short-url::default.utm_source')) + ->placeholder(__('filament-short-url::default.utm_source_placeholder')) + ->dehydrated(false) + ->live(onBlur: true) + ->afterStateUpdated(fn (Get $get, Set $set) => LinkTab::syncUtmToDestination($get, $set)), + + TextInput::make('utm_medium') + ->label(__('filament-short-url::default.utm_medium')) + ->placeholder(__('filament-short-url::default.utm_medium_placeholder')) + ->dehydrated(false) + ->live(onBlur: true) + ->afterStateUpdated(fn (Get $get, Set $set) => LinkTab::syncUtmToDestination($get, $set)), + + TextInput::make('utm_campaign') + ->label(__('filament-short-url::default.utm_campaign')) + ->placeholder(__('filament-short-url::default.utm_campaign_placeholder')) + ->dehydrated(false) + ->live(onBlur: true) + ->afterStateUpdated(fn (Get $get, Set $set) => LinkTab::syncUtmToDestination($get, $set)), + + TextInput::make('utm_term') + ->label(__('filament-short-url::default.utm_term')) + ->placeholder(__('filament-short-url::default.utm_term_placeholder')) + ->dehydrated(false) + ->live(onBlur: true) + ->afterStateUpdated(fn (Get $get, Set $set) => LinkTab::syncUtmToDestination($get, $set)), + + TextInput::make('utm_content') + ->label(__('filament-short-url::default.utm_content')) + ->placeholder(__('filament-short-url::default.utm_content_placeholder')) + ->dehydrated(false) + ->live(onBlur: true) + ->columnSpanFull() + ->afterStateUpdated(fn (Get $get, Set $set) => LinkTab::syncUtmToDestination($get, $set)), + ]) + ->columns(2), + + Section::make(__('filament-short-url::default.form_section_analytics')) + ->schema([ + TextInput::make('ga_tracking_id') + ->label(__('filament-short-url::default.ga_tracking_id')) + ->helperText(__('filament-short-url::default.ga_tracking_id_helper')) + ->placeholder('G-XXXXXXXXXX') + ->regex('/^G-[A-Z0-9]+$/') + ->nullable(), + ]), + ]); + } +} diff --git a/src/Filament/Resources/ShortUrlResource/Tables/ShortUrlsTable.php b/src/Filament/Resources/ShortUrlResource/Tables/ShortUrlsTable.php index f61f629..32a8ea7 100644 --- a/src/Filament/Resources/ShortUrlResource/Tables/ShortUrlsTable.php +++ b/src/Filament/Resources/ShortUrlResource/Tables/ShortUrlsTable.php @@ -5,6 +5,7 @@ namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Tables; use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource; use Bjanczak\FilamentShortUrl\Models\ShortUrl; use Filament\Actions\Action; +use Filament\Actions\ActionGroup; use Filament\Actions\BulkAction; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteAction; @@ -28,151 +29,18 @@ class ShortUrlsTable // ROW 1: Favicon + Short URL with copy capability on click of text or icon (vertically centered!) TextColumn::make('url_key') ->label(__('filament-short-url::default.col_short_url')) - ->getStateUsing(static function (ShortUrl $record): HtmlString { - $shortUrl = e($record->getShortUrl()); - $copiedMsg = e(__('filament-short-url::default.qr_copied')); - $tooltipCopy = e(__('filament-short-url::default.action_copy')); - $destHost = e(parse_url($record->destination_url, PHP_URL_HOST)); - - return new HtmlString(<< -
- -
- - {$shortUrl} - - - - - - -
-HTML - ); - }) + ->view('filament-short-url::table.url-key-column') ->searchable(query: fn ($query, string $search) => $query->where('url_key', 'like', "%{$search}%")), // ROW 2: Sub-arrow + Destination URL TextColumn::make('destination_url') ->label(__('filament-short-url::default.col_destination_url')) - ->getStateUsing(fn ($record): HtmlString => new HtmlString(' - - ')) + ->view('filament-short-url::table.destination-column') ->searchable(), // ROW 3: Bottom Metadata (Total clicks / Unique clicks / Date added / Expiry / Redirect code) TextColumn::make('metadata_badges') - ->getStateUsing(fn ($record): HtmlString => new HtmlString(' -
- - - - - - - '.$record->total_visits.' '.__('filament-short-url::default.badge_clicks').' - - - - - - - - '.$record->unique_visits.' '.__('filament-short-url::default.badge_unique').' - - - - - - - - - '.$record->qr_scans.' '.__('filament-short-url::default.badge_qr_scans').' - - - - - - - - '.$record->created_at->translatedFormat('M d, Y').' - - - - - '.($record->expires_at ? ' - - - - '.__('filament-short-url::default.badge_expires', ['date' => $record->expires_at->translatedFormat('M d, Y')]).' - ' : ' - - - - '.__('filament-short-url::default.badge_no_expiry').' - ').' - - - - - - - - '.__('filament-short-url::default.badge_redirect', ['code' => $record->redirect_status_code]).' - -
- ')), + ->view('filament-short-url::table.metadata-badges'), ]), ]) ->filters([ @@ -193,413 +61,173 @@ HTML '1' => 'Single-use', ]), ]) - ->recordClasses(fn (): string => 'short-url-card') + ->recordClasses(fn (): string => 'short-url-card group/card') ->actions([ - Action::make('share') - ->label(__('filament-short-url::default.action_share')) - ->icon('heroicon-o-share') - ->color('gray') - ->iconButton() - ->tooltip(__('filament-short-url::default.action_share')) - ->modalHeading(__('filament-short-url::default.share_title')) - ->modalWidth('md') - ->modalSubmitAction(false) - ->modalCancelAction(false) - ->form([ - Forms\Components\Placeholder::make('platforms') - ->label(__('filament-short-url::default.share_description')) - ->content(fn (ShortUrl $record): HtmlString => new HtmlString(' - - ')), + ActionGroup::make([ + EditAction::make() + ->icon('heroicon-o-pencil-square') + ->label(fn () => new HtmlString('
'.__('filament-short-url::default.action_edit').'E
')) + ->modalAutofocus(false) + ->closeModalByClickingAway(false) + ->modalSubmitAction(false) + ->extraModalFooterActions(static fn (EditAction $action): array => [ + Action::make('save_changes') + ->label(__('filament-actions::edit.single.modal.actions.save.label') ?: 'Save changes') + ->color('primary') + ->modal(static function () use ($action): bool { + /** @var ShortUrl $record */ + $record = $action->getRecord(); + $data = $action->getRawData(); + $newKey = $data['url_key'] ?? null; - Forms\Components\Placeholder::make('copy_field') - ->label('') - ->content(fn (ShortUrl $record): HtmlString => new HtmlString(' -
- - - -
- ')), - ]), + return $newKey && $newKey !== $record->url_key; + }) + ->requiresConfirmation(static function () use ($action): bool { + /** @var ShortUrl $record */ + $record = $action->getRecord(); + $data = $action->getRawData(); + $newKey = $data['url_key'] ?? null; - Action::make('qrCode') - ->label(__('filament-short-url::default.action_qr') ?? 'QR Code') - ->icon('heroicon-o-qr-code') - ->color('gray') - ->iconButton() - ->tooltip(__('filament-short-url::default.action_qr') ?? 'QR Code') - ->modalWidth('md') - ->modalSubmitAction(false) - ->modalCancelAction(false) - ->form(function (ShortUrl $record): array { - $shortUrl = $record->getShortUrl(); - $qrTargetUrl = $shortUrl.'?source=qr'; - $destHost = parse_url($record->destination_url, PHP_URL_HOST) ?? ''; - $urlKey = $record->url_key; - $eid = 'fsu_'.substr(md5($shortUrl), 0, 8); + return $newKey && $newKey !== $record->url_key; + }) + ->modalHeading(__('filament-short-url::default.url_key_change_confirmation_heading') ?: 'Replace link?') + ->modalDescription(__('filament-short-url::default.url_key_change_confirmation') ?: 'You have modified the short key of this link. Saving these changes will make the original short URL stop working.') + ->action(static function () use ($action) { + /** @var ShortUrl $record */ + $record = $action->getRecord(); + $livewire = $action->getLivewire(); + $state = $livewire->getMountedTableActionForm()->getState(); - $qrHelperText = __('filament-short-url::default.qr_modal_helper') ?? 'Scan, copy, or download your custom QR code.'; - $downloadSvgText = __('filament-short-url::default.qr_download_svg') ?? 'Download SVG'; - $downloadPngText = __('filament-short-url::default.qr_download_png') ?? 'Download PNG'; - $closeButtonText = __('filament-short-url::default.close_button') ?? 'Close'; - $copyLinkText = __('filament-short-url::default.action_copy') ?? 'Copy link'; - $openLinkText = __('filament-short-url::default.open_link') ?? 'Open link'; + $action->process(function () use ($record, $state) { + $record->update($state); + }); - $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'); + $action->success(); + $action->sendSuccessNotification(); - $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, - ]; + $livewire->mountedActions = []; + }), + ]), - $mainColor = $isGrad ? ($qrDefaults['gradient_from'] ?? '#4f46e5') : $fgColor; - - $eyeConfigEnabled = $qrDefaults['eye_config_enabled'] ?? false; - $eyeSquareStyle = $qrDefaults['eye_square_style'] ?? ($dotStyle === 'dots' ? 'dot' : 'square'); - $eyeDotStyle = $qrDefaults['eye_dot_style'] ?? ($dotStyle === 'dots' ? 'dot' : 'square'); - $eyeColor = $qrDefaults['eye_color'] ?? $mainColor; - - $cornersSquareOptions = $eyeConfigEnabled ? [ - 'type' => $eyeSquareStyle, - 'color' => $eyeColor, - ] : [ - 'type' => $dotStyle === 'dots' ? 'dot' : 'square', - 'color' => $mainColor, - ]; - - $cornersDotOptions = $eyeConfigEnabled ? [ - 'type' => $eyeDotStyle, - 'color' => $eyeColor, - ] : [ - 'type' => $dotStyle === 'dots' ? 'dot' : 'square', - 'color' => $mainColor, - ]; - - $logo = $qrDefaults['logo'] ?? null; - $logoSize = $qrDefaults['logo_size'] ?? 0.3; - $logoMargin = $qrDefaults['logo_margin'] ?? 9; - $logoHideBackground = $qrDefaults['logo_hide_background'] ?? true; - $logoShape = $qrDefaults['logo_shape'] ?? 'square'; - - $qrOptionsJson = json_encode([ - 'type' => 'svg', - 'width' => 200, - 'height' => 200, - 'margin' => $qrDefaults['margin'] ?? 1, - 'dotsOptions' => $dotsOptions, - 'backgroundOptions' => ['color' => $bgColor], - 'cornersSquareOptions' => $cornersSquareOptions, - 'cornersDotOptions' => $cornersDotOptions, - 'image' => $logo ?: null, - 'imageOptions' => [ - 'crossOrigin' => 'anonymous', - 'hideBackgroundDots' => $logoHideBackground, - 'imageSize' => $logoSize, - 'margin' => $logoMargin, - 'logoShape' => $logoShape, - ], - 'qrOptions' => ['errorCorrectionLevel' => $logo ? 'H' : 'M'], - ]); - - $escapedQrOptions = e($qrOptionsJson); - - $html = << - - - - -
-

{$qrHelperText}

-
- - -
-
- -
- {$shortUrl} -
- - - - -
-
- - -
-
-
-
-
- - -
-
- -
-HTML; - - return [ + Action::make('qrCode') + ->label(fn () => new HtmlString('
'.__('filament-short-url::default.action_qr').'Q
')) + ->icon('heroicon-o-qr-code') + ->modalWidth('md') + ->modalSubmitAction(false) + ->modalCancelAction(false) + ->form(fn (ShortUrl $record): array => [ Forms\Components\Placeholder::make('qr_modal_content') ->hiddenLabel() - ->content(new HtmlString($html)), - ]; - }), + ->content(view('filament-short-url::table.qr-code-modal', ['record' => $record])), + ]), - Action::make('stats') - ->label(__('filament-short-url::default.action_stats')) - ->icon('heroicon-o-chart-bar') + Action::make('share') + ->label(fn () => new HtmlString('
'.__('filament-short-url::default.action_share').'I
')) + ->icon('heroicon-o-document-duplicate') + ->modalHeading(__('filament-short-url::default.share_title')) + ->modalWidth('md') + ->modalSubmitAction(false) + ->modalCancelAction(false) + ->form([ + Forms\Components\Placeholder::make('platforms') + ->label(__('filament-short-url::default.share_description')) + ->content(fn (ShortUrl $record) => view('filament-short-url::table.share-platforms', ['record' => $record])), + + Forms\Components\Placeholder::make('copy_field') + ->label('') + ->content(fn (ShortUrl $record) => view('filament-short-url::table.share-copy-field', ['record' => $record])), + ]), + + Action::make('stats') + ->label(fn () => new HtmlString('
'.__('filament-short-url::default.action_stats').'S
')) + ->icon('heroicon-o-chart-bar') + ->url(fn (ShortUrl $record): string => ShortUrlResource::getUrl('stats', ['record' => $record])), + + ActionGroup::make([ + DeleteAction::make() + ->label(fn () => new HtmlString('
'.__('filament-short-url::default.action_delete').'X
')) + ->icon('heroicon-o-trash') + ->color('danger') + ->modalHeading(__('filament-short-url::default.action_delete')) + ->modalDescription(__('filament-short-url::default.delete_confirmation_desc')) + ->modalSubmitActionLabel(__('filament-short-url::default.action_delete')) + ->form(fn (ShortUrl $record): array => [ + Forms\Components\Placeholder::make('link_preview') + ->hiddenLabel() + ->content(function () use ($record) { + $shortUrl = $record->getShortUrl(); + $shortUrlDisplay = str($shortUrl)->after('://')->toString(); + + $destHost = null; + if ($record->destination_type === 'split') { + $variants = $record->rotation_variants ?? []; + if (! empty($variants)) { + $firstVariant = reset($variants); + $destHost = parse_url($firstVariant['url'] ?? '', PHP_URL_HOST); + } + } + if (! $destHost) { + $destHost = parse_url($record->destination_url ?? '', PHP_URL_HOST); + } + $destHostEncoded = e($destHost); + + $destUrl = $record->destination_url; + if ($record->destination_type === 'split') { + $destUrl = __('filament-short-url::default.destination_type_split'); + } + $destUrlEncoded = e($destUrl); + + return new HtmlString(" +
+
+ +
+
+
{$shortUrlDisplay}
+
+ + + + {$destUrlEncoded} +
+
+
+ "); + }), + Forms\Components\TextInput::make('verification') + ->label(fn () => new HtmlString(__('filament-short-url::default.delete_verification_label', [ + 'short_url' => str($record->getShortUrl())->after('://')->toString(), + ]))) + ->required() + ->rules([ + function () use ($record) { + return function (string $attribute, $value, \Closure $fail) use ($record) { + $expected = str($record->getShortUrl())->after('://')->toString(); + if ($value !== $expected) { + $fail(__('filament-short-url::default.delete_verification_error')); + } + }; + }, + ]), + ]), + ])->dropdown(false), + ]) + ->icon('heroicon-m-ellipsis-vertical') ->color('gray') ->iconButton() - ->tooltip(__('filament-short-url::default.action_stats')) - ->url(fn (ShortUrl $record): string => ShortUrlResource::getUrl('stats', ['record' => $record])), - - EditAction::make() - ->icon('heroicon-o-pencil-square') - ->color('gray') - ->iconButton() - ->tooltip(__('filament-short-url::default.action_edit')), - - DeleteAction::make() - ->icon('heroicon-o-trash') - ->color('gray') - ->iconButton() - ->tooltip(__('filament-short-url::default.action_delete')), + ->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(''), ]) ->contentGrid([ 'md' => 1, @@ -622,6 +250,9 @@ HTML; DeleteBulkAction::make(), ]), ]) - ->defaultSort('created_at', 'desc'); + ->defaultSort('created_at', 'desc') + ->modifyQueryUsing(fn ($query) => $query->with(['user', 'customDomain'])) + ->recordUrl(fn (ShortUrl $record): string => ShortUrlResource::getUrl('stats', ['record' => $record])) + ->emptyState(view('filament-short-url::table.empty-state')); } } diff --git a/src/Filament/Resources/ShortUrlResource/Widgets/Concerns/HasStatsFilters.php b/src/Filament/Resources/ShortUrlResource/Widgets/Concerns/HasStatsFilters.php new file mode 100644 index 0000000..f2e8918 --- /dev/null +++ b/src/Filament/Resources/ShortUrlResource/Widgets/Concerns/HasStatsFilters.php @@ -0,0 +1,11 @@ + + */ + public array $filters = []; +} diff --git a/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlDeviceBreakdownWidget.php b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlDeviceBreakdownWidget.php new file mode 100644 index 0000000..52e4005 --- /dev/null +++ b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlDeviceBreakdownWidget.php @@ -0,0 +1,75 @@ + true]; + + protected string $view = 'filament-short-url::widgets.device-breakdown'; + + public function setActiveTab(string $tab): void + { + $this->activeTab = $tab; + $this->loadedTabs[$tab] = true; + } + + protected function getViewData(): array + { + if (! $this->record) { + return [ + 'activeTab' => $this->activeTab, + 'visitsByDevice' => [], + 'visitsByBrowser' => [], + 'visitsByOs' => [], + 'visitsByBrowserVersion' => [], + 'visitsByOsVersion' => [], + 'totalVisits' => 0, + 'browserIcons' => [], + 'osIcons' => [], + ]; + } + + $stats = $this->record->getCachedStats($this->dateFrom, $this->dateTo, $this->filters); + + return [ + 'activeTab' => $this->activeTab, + 'visitsByDevice' => isset($this->loadedTabs['devices']) ? ($stats['visitsByDevice'] ?? []) : [], + 'visitsByBrowser' => isset($this->loadedTabs['browsers']) ? ($stats['visitsByBrowser'] ?? []) : [], + 'visitsByOs' => isset($this->loadedTabs['os']) ? ($stats['visitsByOs'] ?? []) : [], + 'visitsByBrowserVersion' => isset($this->loadedTabs['browsers']) ? ($stats['visitsByBrowserVersion'] ?? []) : [], + 'visitsByOsVersion' => isset($this->loadedTabs['os']) ? ($stats['visitsByOsVersion'] ?? []) : [], + 'totalVisits' => $stats['totalVisits'] ?? 0, + 'browserIcons' => [ + 'Chrome' => 'heroicon-m-globe-alt', + 'Firefox' => 'heroicon-m-globe-alt', + 'Safari' => 'heroicon-m-globe-alt', + 'Edge' => 'heroicon-m-globe-alt', + 'Opera' => 'heroicon-m-globe-alt', + 'Internet Explorer' => 'heroicon-m-globe-alt', + ], + 'osIcons' => [ + 'Windows' => 'heroicon-m-cpu-chip', + 'OS X' => 'heroicon-m-cpu-chip', + 'Android' => 'heroicon-m-cpu-chip', + 'iOS' => 'heroicon-m-cpu-chip', + 'Ubuntu' => 'heroicon-m-cpu-chip', + 'Linux' => 'heroicon-m-cpu-chip', + ], + ]; + } +} diff --git a/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlGeoBreakdownWidget.php b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlGeoBreakdownWidget.php new file mode 100644 index 0000000..32e6c64 --- /dev/null +++ b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlGeoBreakdownWidget.php @@ -0,0 +1,53 @@ + true]; + + protected string $view = 'filament-short-url::widgets.geo-breakdown'; + + public function setActiveTab(string $tab): void + { + $this->activeTab = $tab; + $this->loadedTabs[$tab] = true; + } + + protected function getViewData(): array + { + if (! $this->record) { + return [ + 'activeTab' => $this->activeTab, + 'visitsByCountry' => [], + 'visitsByCity' => [], + 'visitsByLanguage' => [], + 'totalVisits' => 0, + ]; + } + + $stats = $this->record->getCachedStats($this->dateFrom, $this->dateTo, $this->filters); + + return [ + 'activeTab' => $this->activeTab, + 'visitsByCountry' => isset($this->loadedTabs['countries']) ? ($stats['visitsByCountry'] ?? []) : [], + 'visitsByCity' => isset($this->loadedTabs['cities']) ? ($stats['visitsByCity'] ?? []) : [], + 'visitsByLanguage' => isset($this->loadedTabs['languages']) ? ($stats['visitsByLanguage'] ?? []) : [], + 'totalVisits' => $stats['totalVisits'] ?? 0, + ]; + } +} diff --git a/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlGlobalOverview.php b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlGlobalOverview.php index 23e79aa..fd4b0fc 100644 --- a/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlGlobalOverview.php +++ b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlGlobalOverview.php @@ -45,12 +45,14 @@ class ShortUrlGlobalOverview extends BaseWidget Stat::make(__('filament-short-url::default.stats_card_total'), number_format($clickData['totalVisits'])) ->description(abs($clicksTrend).'% '.($clicksTrend >= 0 ? 'up' : 'down').' vs last 7 days') ->descriptionIcon($clicksTrend >= 0 ? 'heroicon-m-arrow-trending-up' : 'heroicon-m-arrow-trending-down') + ->chart($clickData['clicksChart'] ?? []) ->icon('heroicon-o-cursor-arrow-rays') ->color($clicksTrend >= 0 ? 'success' : 'danger'), Stat::make(__('filament-short-url::default.stats_card_unique'), number_format($clickData['uniqueVisits'])) ->description(abs($uniquesTrend).'% '.($uniquesTrend >= 0 ? 'up' : 'down').' vs last 7 days') ->descriptionIcon($uniquesTrend >= 0 ? 'heroicon-m-arrow-trending-up' : 'heroicon-m-arrow-trending-down') + ->chart($clickData['uniquesChart'] ?? []) ->icon('heroicon-o-user-group') ->color($uniquesTrend >= 0 ? 'success' : 'danger'), ]; @@ -112,6 +114,34 @@ class ShortUrlGlobalOverview extends BaseWidget ->distinct('ip_hash') ->count('ip_hash'); + // Fetch last 7 days chart data via database-level aggregation to avoid loading raw models into memory + $driver = DB::connection()->getDriverName(); + $dateExpression = match ($driver) { + 'sqlite' => "strftime('%Y-%m-%d', visited_at)", + 'pgsql' => "to_char(visited_at, 'YYYY-MM-DD')", + default => "DATE_FORMAT(visited_at, '%Y-%m-%d')", + }; + + $dailyClicks = ShortUrlVisit::select(DB::raw("{$dateExpression} as date"), DB::raw('COUNT(*) as count')) + ->where('visited_at', '>=', now()->subDays(6)->startOfDay()) + ->groupBy('date') + ->pluck('count', 'date') + ->toArray(); + + $dailyUniques = ShortUrlVisit::select(DB::raw("{$dateExpression} as date"), DB::raw('COUNT(DISTINCT ip_hash) as count')) + ->where('visited_at', '>=', now()->subDays(6)->startOfDay()) + ->groupBy('date') + ->pluck('count', 'date') + ->toArray(); + + $clicksChart = []; + $uniquesChart = []; + for ($i = 6; $i >= 0; $i--) { + $targetDate = now()->subDays($i)->toDateString(); + $clicksChart[] = (int) ($dailyClicks[$targetDate] ?? 0); + $uniquesChart[] = (int) ($dailyUniques[$targetDate] ?? 0); + } + return [ 'totalVisits' => (int) ($agg->total_visits ?? 0), 'uniqueVisits' => (int) ($agg->unique_visits ?? 0), @@ -119,6 +149,8 @@ class ShortUrlGlobalOverview extends BaseWidget 'prev7Clicks' => $prev7Clicks, 'last7Uniques' => $last7Uniques, 'prev7Uniques' => $prev7Uniques, + 'clicksChart' => $clicksChart, + 'uniquesChart' => $uniquesChart, ]; }); } diff --git a/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlSecurityBreakdownWidget.php b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlSecurityBreakdownWidget.php new file mode 100644 index 0000000..dcbf6c7 --- /dev/null +++ b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlSecurityBreakdownWidget.php @@ -0,0 +1,105 @@ + true]; + + protected string $view = 'filament-short-url::widgets.security-breakdown'; + + public function setActiveTab(string $tab): void + { + $this->activeTab = $tab; + $this->loadedTabs[$tab] = true; + } + + protected function getViewData(): array + { + if (! $this->record) { + return [ + 'activeTab' => $this->activeTab, + 'totalClicks' => 0, + 'humanClicks' => 0, + 'botClicks' => 0, + 'humanPercentage' => 0, + 'botPercentage' => 0, + 'proxyClicks' => 0, + 'proxyPercentage' => 0, + ]; + } + + $totalClicks = 0; + $humanClicks = 0; + $botClicks = 0; + $humanPercentage = 0; + $botPercentage = 0; + $proxyClicks = 0; + $proxyPercentage = 0; + + // Perform total count query only if we load bots or vpn + if (! empty($this->loadedTabs)) { + $totalQuery = $this->record->visits() + ->whereBetween('visited_at', [ + ($this->dateFrom ? $this->dateFrom.' 00:00:00' : '1970-01-01 00:00:00'), + ($this->dateTo ? $this->dateTo.' 23:59:59' : now()->toDateTimeString()), + ]); + $this->record->applyStatsFilters($totalQuery, $this->filters); + $totalClicks = $totalQuery->count(); + } + + // Calculate Bot ratio + if (isset($this->loadedTabs['bots'])) { + $botQuery = $this->record->visits() + ->where('is_bot', true) + ->whereBetween('visited_at', [ + ($this->dateFrom ? $this->dateFrom.' 00:00:00' : '1970-01-01 00:00:00'), + ($this->dateTo ? $this->dateTo.' 23:59:59' : now()->toDateTimeString()), + ]); + $this->record->applyStatsFilters($botQuery, $this->filters); + $botClicks = $botQuery->count(); + + $humanClicks = max(0, $totalClicks - $botClicks); + $humanPercentage = $totalClicks > 0 ? round(($humanClicks / $totalClicks) * 100, 1) : 0; + $botPercentage = $totalClicks > 0 ? round(($botClicks / $totalClicks) * 100, 1) : 0; + } + + // Calculate VPN / Proxy ratio + if (isset($this->loadedTabs['vpn'])) { + $proxyQuery = $this->record->visits() + ->where('is_proxy', true) + ->whereBetween('visited_at', [ + ($this->dateFrom ? $this->dateFrom.' 00:00:00' : '1970-01-01 00:00:00'), + ($this->dateTo ? $this->dateTo.' 23:59:59' : now()->toDateTimeString()), + ]); + $this->record->applyStatsFilters($proxyQuery, $this->filters); + $proxyClicks = $proxyQuery->count(); + $proxyPercentage = $totalClicks > 0 ? round(($proxyClicks / $totalClicks) * 100, 1) : 0; + } + + return [ + 'activeTab' => $this->activeTab, + 'totalClicks' => $totalClicks, + 'humanClicks' => $humanClicks, + 'botClicks' => $botClicks, + 'humanPercentage' => $humanPercentage, + 'botPercentage' => $botPercentage, + 'proxyClicks' => $proxyClicks, + 'proxyPercentage' => $proxyPercentage, + ]; + } +} diff --git a/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlSourcesBreakdownWidget.php b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlSourcesBreakdownWidget.php new file mode 100644 index 0000000..f6bc4fa --- /dev/null +++ b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlSourcesBreakdownWidget.php @@ -0,0 +1,73 @@ + true]; + + protected string $view = 'filament-short-url::widgets.sources-breakdown'; + + public function setActiveTab(string $tab): void + { + $this->activeTab = $tab; + if ($tab === 'referrers') { + $this->loadedTabs['referrers'] = true; + } elseif ($tab === 'utm') { + $this->loadedTabs['utm_'.$this->activeSubTab] = true; + } + } + + public function setActiveSubTab(string $subTab): void + { + $this->activeSubTab = $subTab; + $this->loadedTabs['utm_'.$subTab] = true; + } + + protected function getViewData(): array + { + if (! $this->record) { + return [ + 'activeTab' => $this->activeTab, + 'activeSubTab' => $this->activeSubTab, + 'visitsByReferer' => [], + 'utmSources' => [], + 'utmMediums' => [], + 'utmCampaigns' => [], + 'utmTerms' => [], + 'utmContents' => [], + 'totalVisits' => 0, + ]; + } + + $stats = $this->record->getCachedStats($this->dateFrom, $this->dateTo, $this->filters); + + return [ + 'activeTab' => $this->activeTab, + 'activeSubTab' => $this->activeSubTab, + 'visitsByReferer' => isset($this->loadedTabs['referrers']) ? ($stats['visitsByReferer'] ?? []) : [], + 'utmSources' => isset($this->loadedTabs['utm_sources']) ? ($stats['utmSources'] ?? []) : [], + 'utmMediums' => isset($this->loadedTabs['utm_mediums']) ? ($stats['utmMediums'] ?? []) : [], + 'utmCampaigns' => isset($this->loadedTabs['utm_campaigns']) ? ($stats['utmCampaigns'] ?? []) : [], + 'utmTerms' => isset($this->loadedTabs['utm_terms']) ? ($stats['utmTerms'] ?? []) : [], + 'utmContents' => isset($this->loadedTabs['utm_contents']) ? ($stats['utmContents'] ?? []) : [], + 'totalVisits' => $stats['totalVisits'] ?? 0, + ]; + } +} diff --git a/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlStatsOverview.php b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlStatsOverview.php index 3b93d78..5d14db7 100644 --- a/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlStatsOverview.php +++ b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlStatsOverview.php @@ -2,12 +2,16 @@ namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets; +use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\Concerns\HasStatsFilters; use Bjanczak\FilamentShortUrl\Models\ShortUrl; use Filament\Widgets\StatsOverviewWidget as BaseWidget; use Filament\Widgets\StatsOverviewWidget\Stat; +use Illuminate\Support\Carbon; class ShortUrlStatsOverview extends BaseWidget { + use HasStatsFilters; + public ?ShortUrl $record = null; public ?string $dateFrom = null; @@ -20,7 +24,25 @@ class ShortUrlStatsOverview extends BaseWidget return []; } - $stats = $this->record->getCachedStats($this->dateFrom, $this->dateTo); + $stats = $this->record->getCachedStats($this->dateFrom, $this->dateTo, $this->filters); + $totalVisits = (int) ($stats['totalVisits'] ?? 0); + + // Calculate previous period for PoP comparison + $prevStats = null; + if ($this->dateFrom && $this->dateTo) { + try { + $from = Carbon::parse($this->dateFrom); + $to = Carbon::parse($this->dateTo); + $diffInDays = $from->diffInDays($to) + 1; + + $prevFrom = $from->copy()->subDays($diffInDays)->toDateString(); + $prevTo = $from->copy()->subDay()->toDateString(); + + $prevStats = $this->record->getCachedStats($prevFrom, $prevTo, $this->filters); + } catch (\Throwable $e) { + // Fail gracefully if date parsing fails + } + } $topSource = 'Direct'; if (! empty($stats['utmSources'])) { @@ -32,18 +54,34 @@ class ShortUrlStatsOverview extends BaseWidget $topCountry = array_key_first($stats['visitsByCountry']); } + $trend = ! empty($stats['visitsByDay']) ? array_values($stats['visitsByDay']) : []; + return [ - Stat::make(__('filament-short-url::default.stats_card_total'), number_format($stats['totalVisits'] ?? 0)) - ->icon('heroicon-o-cursor-arrow-rays') - ->color('primary'), + $this->buildStatWithTrend( + __('filament-short-url::default.stats_card_total'), + (int) ($stats['totalVisits'] ?? 0), + $prevStats ? (int) ($prevStats['totalVisits'] ?? 0) : null, + 'heroicon-o-cursor-arrow-rays', + 'primary', + $trend + ), - Stat::make(__('filament-short-url::default.stats_card_unique'), number_format($stats['uniqueVisits'] ?? 0)) - ->icon('heroicon-o-user-group') - ->color('info'), + $this->buildStatWithTrend( + __('filament-short-url::default.stats_card_unique'), + (int) ($stats['uniqueVisits'] ?? 0), + $prevStats ? (int) ($prevStats['uniqueVisits'] ?? 0) : null, + 'heroicon-o-user-group', + 'info', + $trend + ), - Stat::make(__('filament-short-url::default.stats_card_today_clicks'), number_format($stats['visitsToday'] ?? 0)) - ->icon('heroicon-o-clock') - ->color('success'), + $this->buildStatWithTrend( + __('filament-short-url::default.stats_card_today_clicks'), + (int) ($stats['visitsToday'] ?? 0), + $prevStats ? (int) ($prevStats['visitsToday'] ?? 0) : null, + 'heroicon-o-clock', + 'success' + ), Stat::make(__('filament-short-url::default.stats_card_top_source'), $topSource) ->icon('heroicon-o-megaphone') @@ -53,9 +91,92 @@ class ShortUrlStatsOverview extends BaseWidget ->icon('heroicon-o-globe-alt') ->color('success'), - Stat::make(__('filament-short-url::default.stats_card_qr_scans'), number_format($stats['qrScans'] ?? 0)) - ->icon('heroicon-o-qr-code') - ->color('info'), + $this->buildStatWithTrend( + __('filament-short-url::default.stats_card_qr_scans'), + (int) ($stats['qrScans'] ?? 0), + $prevStats ? (int) ($prevStats['qrScans'] ?? 0) : null, + 'heroicon-o-qr-code', + 'info' + ), + + Stat::make(__('filament-short-url::default.stats_card_qr_rate'), number_format($totalVisits > 0 ? (($stats['qrScans'] ?? 0) / $totalVisits) * 100 : 0, 1).'%') + ->icon('heroicon-o-arrow-trending-up') + ->color('indigo') + ->description((function () use ($stats, $totalVisits, $prevStats) { + $currentRate = $totalVisits > 0 ? (($stats['qrScans'] ?? 0) / $totalVisits) * 100 : 0; + $prevRate = null; + if ($prevStats) { + $prevTotal = (int) ($prevStats['totalVisits'] ?? 0); + $prevQr = (int) ($prevStats['qrScans'] ?? 0); + $prevRate = $prevTotal > 0 ? ($prevQr / $prevTotal) * 100 : 0; + } + if ($prevRate !== null) { + $delta = $currentRate - $prevRate; + + return ($delta >= 0 ? '+' : '').number_format($delta, 1).'% vs '.__('filament-short-url::default.stats_prev_period'); + } + + return 'β€” vs '.__('filament-short-url::default.stats_prev_period'); + })()) + ->descriptionIcon((function () use ($stats, $totalVisits, $prevStats) { + $currentRate = $totalVisits > 0 ? (($stats['qrScans'] ?? 0) / $totalVisits) * 100 : 0; + $prevRate = null; + if ($prevStats) { + $prevTotal = (int) ($prevStats['totalVisits'] ?? 0); + $prevQr = (int) ($prevStats['qrScans'] ?? 0); + $prevRate = $prevTotal > 0 ? ($prevQr / $prevTotal) * 100 : 0; + } + if ($prevRate !== null) { + return ($currentRate - $prevRate) >= 0 ? 'heroicon-m-arrow-trending-up' : 'heroicon-m-arrow-trending-down'; + } + + return null; + })()) + ->descriptionColor((function () use ($stats, $totalVisits, $prevStats) { + $currentRate = $totalVisits > 0 ? (($stats['qrScans'] ?? 0) / $totalVisits) * 100 : 0; + $prevRate = null; + if ($prevStats) { + $prevTotal = (int) ($prevStats['totalVisits'] ?? 0); + $prevQr = (int) ($prevStats['qrScans'] ?? 0); + $prevRate = $prevTotal > 0 ? ($prevQr / $prevTotal) * 100 : 0; + } + if ($prevRate !== null) { + return ($currentRate - $prevRate) >= 0 ? 'success' : 'danger'; + } + + return 'gray'; + })()), ]; } + + /** + * Build a stat widget with a calculated percentage trend delta and sparkline chart. + */ + protected function buildStatWithTrend(string $label, int $current, ?int $previous, string $icon, string $color, array $trend = []): Stat + { + $stat = Stat::make($label, number_format($current)) + ->icon($icon) + ->color($color); + + if (! empty($trend)) { + $stat->chart($trend); + } + + if ($previous !== null && $previous > 0) { + $delta = (($current - $previous) / $previous) * 100; + $deltaStr = ($delta >= 0 ? '+' : '').number_format($delta, 1).'%'; + $stat->description($deltaStr.' vs '.__('filament-short-url::default.stats_prev_period')) + ->descriptionIcon($delta >= 0 ? 'heroicon-m-arrow-trending-up' : 'heroicon-m-arrow-trending-down') + ->descriptionColor($delta >= 0 ? 'success' : 'danger'); + } elseif ($previous === 0 && $current > 0) { + $stat->description('+100.0% vs '.__('filament-short-url::default.stats_prev_period')) + ->descriptionIcon('heroicon-m-arrow-trending-up') + ->descriptionColor('success'); + } else { + $stat->description('β€” vs '.__('filament-short-url::default.stats_prev_period')) + ->descriptionColor('gray'); + } + + return $stat; + } } diff --git a/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVariantsWidget.php b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVariantsWidget.php new file mode 100644 index 0000000..f65d153 --- /dev/null +++ b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVariantsWidget.php @@ -0,0 +1,53 @@ +record) { + return [ + 'visitsByVariant' => [], + 'totalVisits' => 0, + ]; + } + + $stats = $this->record->getCachedStats($this->dateFrom, $this->dateTo, $this->filters); + + return [ + 'visitsByVariant' => $stats['visitsByVariant'] ?? [], + 'totalVisits' => $stats['totalVisits'] ?? 0, + 'rotationVariants' => $this->record->rotation_variants ?? [], + ]; + } + + public function render(): View + { + if (! $this->record) { + return view('filament-short-url::widgets.empty'); + } + + $stats = $this->record->getCachedStats($this->dateFrom, $this->dateTo, $this->filters); + if (empty($stats['visitsByVariant'])) { + return view('filament-short-url::widgets.empty'); + } + + return parent::render(); + } +} diff --git a/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsBottomBreakdown.php b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsBottomBreakdown.php deleted file mode 100644 index af2eedf..0000000 --- a/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsBottomBreakdown.php +++ /dev/null @@ -1,58 +0,0 @@ -record = $record; - } - - /** - * @return array - */ - protected function getViewData(): array - { - if (! $this->record) { - return [ - 'visitsByDevice' => [], - 'visitsByBrowser' => [], - 'visitsByOs' => [], - 'visitsByReferer' => [], - 'visitsByCity' => [], - 'utmSources' => [], - 'utmMediums' => [], - 'utmCampaigns' => [], - 'totalVisits' => 0, - ]; - } - - $stats = $this->record->getCachedStats($this->dateFrom, $this->dateTo); - - return [ - 'visitsByDevice' => $stats['visitsByDevice'] ?? [], - 'visitsByBrowser' => $stats['visitsByBrowser'] ?? [], - 'visitsByOs' => $stats['visitsByOs'] ?? [], - 'visitsByReferer' => $stats['visitsByReferer'] ?? [], - 'visitsByCity' => $stats['visitsByCity'] ?? [], - 'utmSources' => $stats['utmSources'] ?? [], - 'utmMediums' => $stats['utmMediums'] ?? [], - 'utmCampaigns' => $stats['utmCampaigns'] ?? [], - 'totalVisits' => $stats['totalVisits'] ?? 0, - ]; - } -} diff --git a/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsChart.php b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsChart.php index 343896f..5ff8043 100644 --- a/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsChart.php +++ b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsChart.php @@ -2,12 +2,16 @@ namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets; +use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\Concerns\HasStatsFilters; use Bjanczak\FilamentShortUrl\Models\ShortUrl; use Filament\Widgets\ChartWidget; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\DB; class ShortUrlVisitsChart extends ChartWidget { + use HasStatsFilters; + public ?ShortUrl $record = null; public ?string $dateFrom = null; @@ -18,11 +22,227 @@ class ShortUrlVisitsChart extends ChartWidget protected int|string|array $columnSpan = 'full'; + public ?string $filter = null; + + public string $activeMetric = 'total'; + + protected string $view = 'filament-short-url::widgets.visits-chart'; + + public function mount(): void + { + $this->setDefaultFilter(); + } + + protected function setDefaultFilter(): void + { + $start = $this->dateFrom ? Carbon::parse($this->dateFrom) : now()->subDays(29); + $end = $this->dateTo ? Carbon::parse($this->dateTo) : now(); + $daysDiff = (int) $start->diffInDays($end); + + if ($daysDiff <= 1) { + $this->filter = 'hourly'; + } elseif ($daysDiff <= 30) { + $this->filter = 'daily'; + } elseif ($daysDiff <= 90) { + $this->filter = 'weekly'; + } else { + $this->filter = 'monthly'; + } + } + public function getHeading(): string { return __('filament-short-url::default.stats_chart_title'); } + protected function getFilters(): ?array + { + return [ + 'hourly' => __('filament-short-url::default.stats_granularity_hourly'), + 'daily' => __('filament-short-url::default.stats_granularity_daily'), + 'weekly' => __('filament-short-url::default.stats_granularity_weekly'), + 'monthly' => __('filament-short-url::default.stats_granularity_monthly'), + ]; + } + + public function getTimelineData(Carbon $start, Carbon $end, string $metric, string $granularity, array $filters): array + { + $dateFromClean = $start->toDateString(); + $dateToClean = $end->toDateString(); + $today = Carbon::today()->toDateString(); + $driver = DB::connection()->getDriverName(); + + $buckets = []; + $labels = []; + + if ($granularity === 'hourly') { + $hoursDiff = (int) $start->diffInHours($end); + if ($hoursDiff > 168) { + $granularity = 'daily'; + } else { + $current = $start->copy()->startOfHour(); + $endHour = $end->copy()->endOfHour(); + while ($current->lte($endHour)) { + $bucketKey = $current->format('Y-m-d H:00'); + $buckets[$bucketKey] = 0; + $labels[$bucketKey] = $current->format('d.m H:i'); + $current->addHour(); + } + } + } + + if ($granularity === 'weekly') { + $current = $start->copy()->startOfWeek(); + while ($current->lte($end)) { + $bucketKey = $current->format('o-W'); + $buckets[$bucketKey] = 0; + $labels[$bucketKey] = 'W'.$current->format('W').' ('.$current->format('d.m').')'; + $current->addWeek(); + } + } elseif ($granularity === 'monthly') { + $current = $start->copy()->startOfMonth(); + while ($current->lte($end)) { + $bucketKey = $current->format('Y-m'); + $buckets[$bucketKey] = 0; + $labels[$bucketKey] = $current->format('m.Y'); + $current->addMonth(); + } + } elseif ($granularity === 'daily') { + $daysDiff = (int) $start->diffInDays($end); + for ($i = $daysDiff; $i >= 0; $i--) { + $d = $end->copy()->subDays($i)->format('Y-m-d'); + $buckets[$d] = 0; + $labels[$d] = Carbon::parse($d)->format('d.m'); + } + } + + if (empty($filters) && $granularity !== 'hourly') { + $dailyStatsQuery = DB::table('short_url_daily_stats') + ->where('short_url_id', $this->record->id) + ->where('date', '>=', $dateFromClean) + ->where('date', '<=', $dateToClean) + ->orderBy('date', 'asc'); + + $col = match ($metric) { + 'unique' => 'unique_visits_count', + 'qr' => 'qr_visits_count', + default => 'visits_count', + }; + + $dailyRows = $dailyStatsQuery->pluck($col, 'date')->toArray(); + + foreach ($dailyRows as $date => $count) { + $carbonDate = Carbon::parse($date); + if ($granularity === 'weekly') { + $bucketKey = $carbonDate->format('o-W'); + } elseif ($granularity === 'monthly') { + $bucketKey = $carbonDate->format('Y-m'); + } else { + $bucketKey = $date; + } + + if (array_key_exists($bucketKey, $buckets)) { + $buckets[$bucketKey] += (int) $count; + } + } + + if ($dateToClean >= $today) { + $todayRawQuery = DB::table('short_url_visits') + ->where('short_url_id', $this->record->id) + ->where('visited_at', '>=', $today.' 00:00:00') + ->where('is_bot', false) + ->where('is_proxy', false); + + $aggregateExpression = match ($metric) { + 'unique' => 'COUNT(DISTINCT ip_hash)', + 'qr' => 'SUM(CASE WHEN is_qr_scan = 1 THEN 1 ELSE 0 END)', + default => 'COUNT(*)', + }; + + $todayCount = (int) $todayRawQuery->selectRaw("{$aggregateExpression} as cnt")->value('cnt'); + + $carbonToday = Carbon::parse($today); + if ($granularity === 'weekly') { + $todayBucket = $carbonToday->format('o-W'); + } elseif ($granularity === 'monthly') { + $todayBucket = $carbonToday->format('Y-m'); + } else { + $todayBucket = $today; + } + + if (array_key_exists($todayBucket, $buckets)) { + $buckets[$todayBucket] += $todayCount; + } + } + } else { + $query = DB::table('short_url_visits') + ->where('short_url_id', $this->record->id) + ->where('visited_at', '>=', $dateFromClean.' 00:00:00') + ->where('visited_at', '<=', $dateToClean.' 23:59:59') + ->where('is_bot', false) + ->where('is_proxy', false); + + $this->record->applyStatsFilters($query, $filters); + + $dateExpression = ''; + if ($granularity === 'hourly') { + $dateExpression = match ($driver) { + 'sqlite' => "strftime('%Y-%m-%d %H:00', visited_at)", + 'pgsql' => "to_char(visited_at, 'YYYY-MM-DD HH24:00')", + default => "DATE_FORMAT(visited_at, '%Y-%m-%d %H:00')", + }; + } elseif ($granularity === 'weekly') { + $dateExpression = match ($driver) { + 'sqlite' => "strftime('%Y-%W', visited_at)", + 'pgsql' => "to_char(visited_at, 'IYYY-IW')", + default => 'YEARWEEK(visited_at, 3)', + }; + } elseif ($granularity === 'monthly') { + $dateExpression = match ($driver) { + 'sqlite' => "strftime('%Y-%m', visited_at)", + 'pgsql' => "to_char(visited_at, 'YYYY-MM')", + default => "DATE_FORMAT(visited_at, '%Y-%m')", + }; + } else { + $dateExpression = match ($driver) { + 'sqlite' => "strftime('%Y-%m-%d', visited_at)", + 'pgsql' => "to_char(visited_at, 'YYYY-MM-DD')", + default => "DATE_FORMAT(visited_at, '%Y-%m-%d')", + }; + } + + $aggregateExpression = match ($metric) { + 'unique' => 'COUNT(DISTINCT ip_hash)', + 'qr' => 'SUM(CASE WHEN is_qr_scan = 1 THEN 1 ELSE 0 END)', + default => 'COUNT(*)', + }; + + $rows = $query->select(DB::raw("{$dateExpression} as time_bucket"), DB::raw("{$aggregateExpression} as count")) + ->groupBy('time_bucket') + ->pluck('count', 'time_bucket') + ->toArray(); + + foreach ($rows as $bucket => $count) { + if ($granularity === 'weekly' && $driver !== 'sqlite' && $driver !== 'pgsql') { + if (strlen($bucket) === 6) { + $year = substr($bucket, 0, 4); + $week = substr($bucket, 4, 2); + $bucket = "{$year}-{$week}"; + } + } + + if (array_key_exists($bucket, $buckets)) { + $buckets[$bucket] = (int) $count; + } + } + } + + return [ + 'data' => array_values($buckets), + 'labels' => array_values($labels), + ]; + } + protected function getData(): array { if (! $this->record) { @@ -32,14 +252,56 @@ class ShortUrlVisitsChart extends ChartWidget ]; } - $stats = $this->record->getCachedStats($this->dateFrom, $this->dateTo); - $visitsByDay = $stats['visitsByDay'] ?? []; + $start = $this->dateFrom ? Carbon::parse($this->dateFrom)->startOfDay() : now()->subDays(29)->startOfDay(); + $end = $this->dateTo ? Carbon::parse($this->dateTo)->endOfDay() : now()->endOfDay(); + $granularity = $this->filter ?: 'daily'; + $filtersHash = md5(json_encode($this->filters)); + + $cacheTtl = (int) config('filament-short-url.geo_ip.stats_cache_ttl', 300); + $cacheKey = "short_url_chart_data_{$this->record->id}_".$start->toDateString().'_'.$end->toDateString().'_'.$granularity.'_'.$filtersHash.'_'.$this->activeMetric; + $this->record->registerCacheKey($cacheKey); + + $chartData = cache()->remember($cacheKey, $cacheTtl, function () use ($start, $end, $granularity) { + return $this->getTimelineData($start, $end, $this->activeMetric, $granularity, $this->filters); + }); + + $currentValues = $chartData['data'] ?? []; + $labels = $chartData['labels'] ?? []; + + // Dual-line Chart data generation: Query stats for previous period + $diffInDays = $start->diffInDays($end) + 1; + $prevStart = $start->copy()->subDays($diffInDays); + $prevEnd = $start->copy()->subDay(); + + $prevCacheKey = "short_url_chart_data_{$this->record->id}_".$prevStart->toDateString().'_'.$prevEnd->toDateString().'_'.$granularity.'_'.$filtersHash.'_'.$this->activeMetric; + $this->record->registerCacheKey($prevCacheKey); + + $prevChartData = cache()->remember($prevCacheKey, $cacheTtl, function () use ($prevStart, $prevEnd, $granularity) { + return $this->getTimelineData($prevStart, $prevEnd, $this->activeMetric, $granularity, $this->filters); + }); + + $prevValues = $prevChartData['data'] ?? []; + + // Align/pad previous data structure to match current labels length exactly + $countCurrent = count($currentValues); + $countPrev = count($prevValues); + if ($countPrev < $countCurrent) { + $prevValues = array_pad($prevValues, $countCurrent, 0); + } elseif ($countPrev > $countCurrent) { + $prevValues = array_slice($prevValues, 0, $countCurrent); + } + + $metricLabel = match ($this->activeMetric) { + 'unique' => __('filament-short-url::default.stats_card_unique'), + 'qr' => __('filament-short-url::default.stats_card_qr_scans'), + default => __('filament-short-url::default.qr_chart_visits_label'), + }; return [ 'datasets' => [ [ - 'label' => __('filament-short-url::default.qr_chart_visits_label'), - 'data' => array_values($visitsByDay), + 'label' => $metricLabel, + 'data' => $currentValues, 'borderColor' => 'rgb(99, 102, 241)', 'backgroundColor' => 'rgba(99, 102, 241, 0.08)', 'borderWidth' => 2, @@ -48,19 +310,19 @@ class ShortUrlVisitsChart extends ChartWidget 'tension' => 0.4, 'fill' => true, ], + [ + 'label' => $metricLabel.' ('.__('filament-short-url::default.stats_prev_period').')', + 'data' => $prevValues, + 'borderColor' => 'rgba(99, 102, 241, 0.35)', + 'backgroundColor' => 'transparent', + 'borderWidth' => 2, + 'borderDash' => [5, 5], + 'pointRadius' => 0, + 'tension' => 0.4, + 'fill' => false, + ], ], - 'labels' => array_map(function (string $date) { - try { - $carbon = Carbon::parse($date); - if (strlen($date) === 7) { // Y-m - return $carbon->format('m.Y'); - } - - return $carbon->format('d.m'); - } catch (\Throwable $e) { - return $date; - } - }, array_keys($visitsByDay)), + 'labels' => $labels, ]; } diff --git a/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsRightBreakdown.php b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsRightBreakdown.php deleted file mode 100644 index 7af12ce..0000000 --- a/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsRightBreakdown.php +++ /dev/null @@ -1,129 +0,0 @@ -record = $record; - } - - /** - * @return array - */ - protected function getViewData(): array - { - $languageNames = __('filament-short-url::languages'); - if (! is_array($languageNames)) { - $languageNames = []; - } - - if (! $this->record) { - return [ - 'visitsByCountry' => [], - 'visitsByLanguage' => [], - 'languageNames' => $languageNames, - 'totalVisits' => 0, - ]; - } - - $stats = $this->record->getCachedStats($this->dateFrom, $this->dateTo); - - return [ - 'visitsByCountry' => $stats['visitsByCountry'] ?? [], - 'visitsByLanguage' => $stats['visitsByLanguage'] ?? [], - 'languageNames' => $languageNames, - 'totalVisits' => $stats['totalVisits'] ?? 0, - ]; - } - - /** - * Dynamically translate an ISO language code to the current application locale. - */ - public static function getLanguageTranslation(string $langCode): string - { - $langCode = strtolower(trim($langCode)); - - // 1. Try loading from languages translation file (languages.php) - $translatedLanguages = __('filament-short-url::languages'); - if (is_array($translatedLanguages) && isset($translatedLanguages[$langCode])) { - return $translatedLanguages[$langCode]; - } - - // 2. Try PHP Locale extension - if (class_exists(\Locale::class)) { - try { - $name = \Locale::getDisplayLanguage($langCode, app()->getLocale()); - if ($name && $name !== $langCode) { - return mb_convert_case($name, MB_CASE_TITLE, 'UTF-8'); - } - } catch (\Throwable) { - // Fallback - } - } - - // 3. Simple fallback - return strtoupper($langCode); - } - - /** - * Dynamically translate an English country name to the current application locale. - */ - public static function getCountryTranslation(string $englishName): string - { - $englishName = trim($englishName); - - try { - $enCountries = trans('filament-short-url::countries', [], 'en'); - if (is_array($enCountries)) { - $flipped = array_change_key_case(array_flip($enCountries), CASE_LOWER); - $lookupKey = strtolower($englishName); - $code = $flipped[$lookupKey] ?? null; - - if ($code) { - $translated = __('filament-short-url::countries.'.strtoupper($code)); - if ($translated && $translated !== 'filament-short-url::countries.'.strtoupper($code)) { - return $translated; - } - } - } - } catch (\Throwable) { - // Fallback - } - - return $englishName; - } - - public static function getCountryCode(string $englishName): ?string - { - $englishName = trim($englishName); - - try { - $enCountries = trans('filament-short-url::countries', [], 'en'); - if (is_array($enCountries)) { - $flipped = array_change_key_case(array_flip($enCountries), CASE_LOWER); - $lookupKey = strtolower($englishName); - - return $flipped[$lookupKey] ?? null; - } - } catch (\Throwable) { - // Ignore - } - - return null; - } -} diff --git a/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlWorldMapWidget.php b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlWorldMapWidget.php index 90c1bd9..bde533d 100644 --- a/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlWorldMapWidget.php +++ b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlWorldMapWidget.php @@ -2,6 +2,7 @@ namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets; +use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\Concerns\HasStatsFilters; use Bjanczak\FilamentShortUrl\Models\ShortUrl; use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit; use Filament\Widgets\Widget; @@ -10,6 +11,8 @@ use Illuminate\Support\Facades\DB; class ShortUrlWorldMapWidget extends Widget { + use HasStatsFilters; + public ?ShortUrl $record = null; public ?string $dateFrom = null; @@ -40,8 +43,9 @@ class ShortUrlWorldMapWidget extends Widget $dateToClean = $this->dateTo ? Carbon::parse($this->dateTo)->toDateString() : null; $today = Carbon::today()->toDateString(); + $filtersHash = md5(json_encode($this->filters)); $cacheTtl = (int) config('filament-short-url.geo_ip.stats_cache_ttl', 300); - $cacheKey = "short_url_world_map_{$this->record->id}_".($dateFromClean ?: 'all').'_'.($dateToClean ?: 'all'); + $cacheKey = "short_url_world_map_{$this->record->id}_".($dateFromClean ?: 'all').'_'.($dateToClean ?: 'all').'_'.$filtersHash; return cache()->remember($cacheKey, $cacheTtl, function () use ($dateFromClean, $dateToClean) { $counts = []; @@ -61,6 +65,8 @@ class ShortUrlWorldMapWidget extends Widget $query->whereDate('visited_at', '<=', $dateToClean); } + $this->record->applyStatsFilters($query, $this->filters); + foreach ($query->groupBy('country_code')->get() as $row) { $code = strtoupper(trim($row->country_code)); if ($code) { diff --git a/src/FilamentShortUrlPlugin.php b/src/FilamentShortUrlPlugin.php index df520c3..1001c3f 100644 --- a/src/FilamentShortUrlPlugin.php +++ b/src/FilamentShortUrlPlugin.php @@ -8,6 +8,7 @@ namespace Bjanczak\FilamentShortUrl; +use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlCustomDomainResource; use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlPixelResource; use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource; use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\ShortUrlSettingsPage; @@ -53,6 +54,7 @@ class FilamentShortUrlPlugin implements Plugin ->resources([ ShortUrlResource::class, ShortUrlPixelResource::class, + ShortUrlCustomDomainResource::class, ]) ->pages([ ShortUrlSettingsPage::class, diff --git a/src/FilamentShortUrlServiceProvider.php b/src/FilamentShortUrlServiceProvider.php index 0fad5f1..088c986 100644 --- a/src/FilamentShortUrlServiceProvider.php +++ b/src/FilamentShortUrlServiceProvider.php @@ -8,6 +8,7 @@ namespace Bjanczak\FilamentShortUrl; +use Bjanczak\FilamentShortUrl\Assets\ShortUrlCss; use Bjanczak\FilamentShortUrl\Console\Commands\SyncBufferedCountersCommand; use Bjanczak\FilamentShortUrl\Services\GeoIpService; use Bjanczak\FilamentShortUrl\Services\ProxyDetectionService; @@ -16,7 +17,6 @@ use Bjanczak\FilamentShortUrl\Services\ShortUrlService; use Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager; use Bjanczak\FilamentShortUrl\Services\ShortUrlTracker; use Bjanczak\FilamentShortUrl\Services\UserAgentParser; -use Filament\Support\Assets\Css; use Filament\Support\Facades\FilamentAsset; use Illuminate\Console\Scheduling\Schedule; use Spatie\LaravelPackageTools\Package; @@ -34,20 +34,14 @@ class FilamentShortUrlServiceProvider extends PackageServiceProvider ->hasViews('filament-short-url') ->hasTranslations() ->hasMigrations([ - '2024_01_01_000001_create_short_urls_table', - '2024_01_01_000002_create_short_url_visits_table', - '2026_06_01_000003_add_utm_city_referer_to_short_url_visits_table', - '2026_06_01_000004_add_targeting_and_security_to_short_urls_table', - '2026_06_01_000005_create_short_url_daily_stats_table', - '2026_06_02_000006_add_max_visits_and_expiration_redirect_to_short_urls_table', - '2026_06_02_000007_add_retargeting_pixels_and_webhooks_to_short_urls_table', - '2026_06_02_000008_add_bot_and_proxy_to_short_url_visits_table', - '2026_06_02_224250_add_qr_logo_to_short_urls_table', - '2026_06_03_110000_add_qr_and_language_to_visits_and_daily_stats', - '2026_06_03_120000_add_track_browser_language_to_short_urls_table', - '2026_06_03_150000_create_short_url_pixels_table', - '2026_06_03_160000_add_auto_open_app_mobile_to_short_urls_table', - '2026_06_04_000000_create_short_url_settings_table', + '2024_01_01_000001_create_short_url_settings_table', + '2024_01_01_000002_create_short_url_pixels_table', + '2024_01_01_000003_create_short_urls_table', + '2024_01_01_000004_create_short_url_visits_table', + '2024_01_01_000005_create_short_url_daily_stats_table', + '2026_06_05_000001_add_user_id_to_short_urls_table', + '2026_06_05_000002_create_short_url_custom_domains_table', + '2026_06_05_000003_add_custom_domain_id_to_short_urls_table', ]) ->hasCommands([ SyncBufferedCountersCommand::class, @@ -59,7 +53,6 @@ class FilamentShortUrlServiceProvider extends PackageServiceProvider public function packageRegistered(): void { $this->app->singleton(ShortUrlSettingsManager::class); - $this->app->make(ShortUrlSettingsManager::class)->applyConfigOverrides(); // Bind services as singletons for efficient reuse $this->app->singleton(UserAgentParser::class); @@ -72,8 +65,10 @@ class FilamentShortUrlServiceProvider extends PackageServiceProvider public function packageBooted(): void { + $this->app->make(ShortUrlSettingsManager::class)->applyConfigOverrides(); + FilamentAsset::register([ - Css::make('filament-short-url', __DIR__.'/../resources/dist/filament-short-url.css'), + ShortUrlCss::make('filament-short-url', __DIR__.'/../resources/dist/filament-short-url.css'), ], package: 'janczakb/filament-short-url'); // Automatically register scheduled tasks in the application scheduler diff --git a/src/Http/Controllers/ShortUrlApiController.php b/src/Http/Controllers/ShortUrlApiController.php index ccb0d87..7ca51ff 100644 --- a/src/Http/Controllers/ShortUrlApiController.php +++ b/src/Http/Controllers/ShortUrlApiController.php @@ -1,9 +1,19 @@ + * @copyright 2026 Bartek Janczak + * @license Custom Source-Available License (see LICENSE file) + */ + namespace Bjanczak\FilamentShortUrl\Http\Controllers; +use Bjanczak\FilamentShortUrl\Http\Requests\StoreShortUrlRequest; +use Bjanczak\FilamentShortUrl\Http\Requests\UpdateShortUrlRequest; +use Bjanczak\FilamentShortUrl\Http\Resources\ShortUrlResource; use Bjanczak\FilamentShortUrl\Jobs\SendWebhookJob; use Bjanczak\FilamentShortUrl\Models\ShortUrl; +use Bjanczak\FilamentShortUrl\Services\SafeBrowsingService; use Bjanczak\FilamentShortUrl\Services\ShortUrlService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -13,7 +23,8 @@ use Illuminate\Support\Facades\Log; class ShortUrlApiController extends Controller { public function __construct( - private readonly ShortUrlService $service + private readonly ShortUrlService $service, + private readonly SafeBrowsingService $safeBrowsing, ) {} /** @@ -21,27 +32,17 @@ class ShortUrlApiController extends Controller */ public function index(): JsonResponse { - $links = ShortUrl::orderBy('id', 'desc')->paginate(30); + $links = ShortUrl::with(['pixels', 'customDomain'])->orderBy('id', 'desc')->paginate(30); - $transformed = $links->getCollection()->map(fn ($link) => $this->transformLink($link)); - - return response()->json([ - 'data' => $transformed, - 'meta' => [ - 'current_page' => $links->currentPage(), - 'last_page' => $links->lastPage(), - 'per_page' => $links->perPage(), - 'total' => $links->total(), - ], - ]); + return ShortUrlResource::collection($links)->response(); } /** * Store a newly created short URL. */ - public function store(Request $request): JsonResponse + public function store(StoreShortUrlRequest $request): JsonResponse { - $validated = $request->validate($this->getValidationRules($request)); + $validated = $request->validated(); $pixelIds = $validated['pixels'] ?? []; @@ -57,10 +58,10 @@ class ShortUrlApiController extends Controller // Fire 'created' webhook if active $this->dispatchCreatedWebhook($shortUrl); - return response()->json([ - 'message' => 'Short URL created successfully.', - 'data' => $this->transformLink($shortUrl), - ], 201); + return (new ShortUrlResource($shortUrl)) + ->additional(['message' => 'Short URL created successfully.']) + ->response() + ->setStatusCode(201); } /** @@ -70,9 +71,7 @@ class ShortUrlApiController extends Controller { $shortUrl = $this->findLink($idOrKey); - return response()->json([ - 'data' => $this->transformLink($shortUrl), - ]); + return (new ShortUrlResource($shortUrl))->response(); } /** @@ -80,6 +79,11 @@ class ShortUrlApiController extends Controller */ public function stats(Request $request, string|int $idOrKey): JsonResponse { + $request->validate([ + 'date_from' => 'nullable|date_format:Y-m-d', + 'date_to' => 'nullable|date_format:Y-m-d|after_or_equal:date_from', + ]); + $shortUrl = $this->findLink($idOrKey); $stats = $shortUrl->getCachedStats( @@ -108,19 +112,11 @@ class ShortUrlApiController extends Controller /** * Update the specified short URL. */ - public function update(Request $request, string|int $idOrKey): JsonResponse + public function update(UpdateShortUrlRequest $request, string|int $idOrKey): JsonResponse { - $shortUrl = $this->findLink($idOrKey); + $shortUrl = $request->getModel(); - // Merge existing dates for validation if only one of them is sent - if ($request->has('expires_at') && ! $request->has('activated_at')) { - $request->merge(['activated_at' => $shortUrl->activated_at?->toIso8601String()]); - } - if ($request->has('activated_at') && ! $request->has('expires_at')) { - $request->merge(['expires_at' => $shortUrl->expires_at?->toIso8601String()]); - } - - $validated = $request->validate($this->getValidationRules($request, $shortUrl)); + $validated = $request->validated(); $pixelIds = $validated['pixels'] ?? null; unset($validated['pixels']); @@ -131,10 +127,9 @@ class ShortUrlApiController extends Controller $shortUrl->pixels()->sync($pixelIds); } - return response()->json([ - 'message' => 'Short URL updated successfully.', - 'data' => $this->transformLink($shortUrl->fresh()), - ]); + return (new ShortUrlResource($shortUrl->fresh())) + ->additional(['message' => 'Short URL updated successfully.']) + ->response(); } /** @@ -155,249 +150,6 @@ class ShortUrlApiController extends Controller return $link; } - /** - * Get the validation rules for creating or updating a short URL. - * - * @return array - */ - private function getValidationRules(Request $request, ?ShortUrl $model = null): array - { - $countries = __('filament-short-url::countries'); - $countryRule = is_array($countries) && ! empty($countries) - ? 'in:'.implode(',', array_merge(array_keys($countries), array_map('strtolower', array_keys($countries)))) - : 'string|max:10'; - - $languages = __('filament-short-url::languages'); - $languageRule = is_array($languages) && ! empty($languages) - ? 'in:'.implode(',', array_merge(array_keys($languages), array_map('strtoupper', array_keys($languages)))) - : 'string|max:10'; - - $isUpdate = $model !== null; - - // Apply after_or_equal:today only if the activated_at date is actually being changed - $activatedAtRule = 'nullable|date'; - if ($isUpdate) { - if ($request->has('activated_at') && $request->input('activated_at') !== $model->activated_at?->toIso8601String() && $request->input('activated_at') !== $model->activated_at?->toDateTimeString()) { - $activatedAtRule .= '|after_or_equal:today'; - } - } else { - $activatedAtRule .= '|after_or_equal:today'; - } - - $uniqueKeyRule = 'unique:short_urls,url_key'; - if ($isUpdate) { - $uniqueKeyRule .= ','.$model->id; - } - - $isLegacyRules = is_array($request->input('targeting_rules')) && isset($request->input('targeting_rules')['type']); - - $targetingRules = []; - if ($isLegacyRules) { - $targetingRules = [ - 'targeting_rules' => 'nullable|array', - 'targeting_rules.type' => 'required_with:targeting_rules|string|in:none,device,geo,language,rotation', - 'targeting_rules.device' => 'nullable|array', - 'targeting_rules.device.mobile' => 'nullable|url|max:2048', - 'targeting_rules.device.tablet' => 'nullable|url|max:2048', - 'targeting_rules.device.desktop' => 'nullable|url|max:2048', - 'targeting_rules.device.ios' => 'nullable|url|max:2048', - 'targeting_rules.device.android' => 'nullable|url|max:2048', - 'targeting_rules.geo' => 'nullable|array', - 'targeting_rules.geo.*.country_code' => 'required_with:targeting_rules.geo|distinct:ignore_case|'.$countryRule, - 'targeting_rules.geo.*.url' => 'required_with:targeting_rules.geo|url|max:2048', - 'targeting_rules.language' => 'nullable|array', - 'targeting_rules.language.*.language_code' => 'required_with:targeting_rules.language|distinct:ignore_case|'.$languageRule, - 'targeting_rules.language.*.url' => 'required_with:targeting_rules.language|url|max:2048', - 'targeting_rules.rotation' => 'nullable|array', - 'targeting_rules.rotation.*.url' => 'required_with:targeting_rules.rotation|url|max:2048', - 'targeting_rules.rotation.*.weight' => 'required_with:targeting_rules.rotation|integer|min:1|max:1000', - ]; - } else { - $targetingRules = [ - 'targeting_rules' => [ - 'nullable', - 'array', - function (string $attribute, $value, \Closure $fail) { - if (! is_array($value)) { - return; - } - foreach ($value as $index => $rule) { - if (! is_array($rule)) { - $fail("Targeting rule at index {$index} must be an array."); - - continue; - } - $allowedKeys = ['match', 'url', 'filters']; - $invalidKeys = array_diff(array_keys($rule), $allowedKeys); - if (! empty($invalidKeys)) { - $fail("Invalid keys in targeting rule at index {$index}: ".implode(', ', $invalidKeys)); - } - } - }, - ], - 'targeting_rules.*.match' => 'required_with:targeting_rules|string|in:or,and', - 'targeting_rules.*.url' => 'required_with:targeting_rules|url|max:2048', - 'targeting_rules.*.filters' => [ - 'required_with:targeting_rules', - 'array', - 'min:1', - function (string $attribute, $value, \Closure $fail) { - if (! is_array($value)) { - return; - } - $types = collect($value)->pluck('type'); - if ($types->duplicates()->isNotEmpty()) { - $fail('Each filter type (device, platform, country, language) can only be added once.'); - } - - foreach ($value as $index => $filter) { - if (! is_array($filter)) { - $fail("Filter at index {$index} must be an array."); - - continue; - } - - $allowedFilterKeys = ['type', 'data']; - $invalidFilterKeys = array_diff(array_keys($filter), $allowedFilterKeys); - if (! empty($invalidFilterKeys)) { - $fail("Invalid keys in filter at index {$index}: ".implode(', ', $invalidFilterKeys)); - - continue; - } - - $type = $filter['type'] ?? null; - $data = $filter['data'] ?? null; - - if (! in_array($type, ['device', 'platform', 'country', 'language'])) { - continue; - } - - if (! is_array($data)) { - $fail("Filter data for type '{$type}' must be an array."); - - continue; - } - - $allowedKeys = match ($type) { - 'device' => ['devices'], - 'platform' => ['platforms'], - 'country' => ['countries'], - 'language' => ['languages'], - }; - - $invalidKeys = array_diff(array_keys($data), $allowedKeys); - if (! empty($invalidKeys)) { - $fail("Invalid keys in data for filter '{$type}': ".implode(', ', $invalidKeys)); - - continue; - } - - $mainKey = $allowedKeys[0]; - if (! isset($data[$mainKey]) || ! is_array($data[$mainKey]) || empty($data[$mainKey])) { - $fail("Filter '{$type}' requires a non-empty array named '{$mainKey}'."); - - continue; - } - } - }, - ], - 'targeting_rules.*.filters.*.type' => 'required_with:targeting_rules|string|in:device,platform,country,language', - 'targeting_rules.*.filters.*.data' => 'required_with:targeting_rules|array', - 'targeting_rules.*.filters.*.data.devices' => 'nullable|array', - 'targeting_rules.*.filters.*.data.devices.*' => 'string|in:desktop,mobile,tablet', - 'targeting_rules.*.filters.*.data.platforms' => 'nullable|array', - 'targeting_rules.*.filters.*.data.platforms.*' => 'string|in:android,fire_os,ios,linux,mac,windows', - 'targeting_rules.*.filters.*.data.countries' => 'nullable|array', - 'targeting_rules.*.filters.*.data.countries.*' => 'string|'.$countryRule, - 'targeting_rules.*.filters.*.data.languages' => 'nullable|array', - 'targeting_rules.*.filters.*.data.languages.*' => 'string|'.$languageRule, - ]; - } - - $rules = [ - 'destination_url' => ($isUpdate ? 'sometimes|' : '').'required|url|max:2048', - 'url_key' => ($isUpdate ? 'sometimes|' : '').'nullable|string|alpha_dash|max:32|'.$uniqueKeyRule, - 'notes' => 'nullable|string|max:1000', - 'is_enabled' => 'nullable|boolean', - 'redirect_status_code' => 'nullable|integer|in:301,302', - 'single_use' => 'nullable|boolean', - 'forward_query_params' => 'nullable|boolean', - 'max_visits' => 'nullable|integer|min:1', - 'expiration_redirect_url' => 'nullable|url|max:2048', - 'activated_at' => $activatedAtRule, - 'expires_at' => 'nullable|date|after_or_equal:activated_at', - 'webhook_url' => 'nullable|url|max:2048', - ]; - - $rules = array_merge($rules, $targetingRules); - - $rules = array_merge($rules, [ - 'password' => 'nullable|string|max:255', - 'show_warning_page' => 'nullable|boolean', - 'auto_open_app_mobile' => 'nullable|boolean', - 'ga_tracking_id' => 'nullable|string|regex:/^G-[A-Z0-9]+$/', - 'track_visits' => 'nullable|boolean', - 'track_ip_address' => 'nullable|boolean', - 'track_browser' => 'nullable|boolean', - 'track_browser_version' => 'nullable|boolean', - 'track_operating_system' => 'nullable|boolean', - 'track_operating_system_version' => 'nullable|boolean', - 'track_device_type' => 'nullable|boolean', - 'track_referer_url' => 'nullable|boolean', - 'track_browser_language' => 'nullable|boolean', - 'pixels' => 'nullable|array', - 'pixels.*' => 'integer|exists:short_url_pixels,id', - ]); - - return $rules; - } - - /** - * Transform a ShortUrl model to API response array. - */ - private function transformLink(ShortUrl $link): array - { - $pixels = $link->relationLoaded('pixels') ? $link->pixels : $link->pixels()->get(); - - return [ - 'id' => $link->id, - 'destination_url' => $link->destination_url, - 'url_key' => $link->url_key, - 'short_url' => $link->getShortUrl(), - 'is_enabled' => (bool) $link->is_enabled, - 'redirect_status_code' => (int) $link->redirect_status_code, - 'total_visits' => (int) $link->total_visits, - 'unique_visits' => (int) $link->unique_visits, - 'max_visits' => $link->max_visits ? (int) $link->max_visits : null, - 'activated_at' => $link->activated_at ? $link->activated_at->toIso8601String() : null, - 'expires_at' => $link->expires_at ? $link->expires_at->toIso8601String() : null, - 'webhook_url' => $link->webhook_url, - 'targeting_rules' => $link->targeting_rules, - 'password' => $link->password, - 'show_warning_page' => (bool) $link->show_warning_page, - 'auto_open_app_mobile' => (bool) $link->auto_open_app_mobile, - 'ga_tracking_id' => $link->ga_tracking_id, - 'track_visits' => (bool) $link->track_visits, - 'track_ip_address' => (bool) $link->track_ip_address, - 'track_browser' => (bool) $link->track_browser, - 'track_browser_version' => (bool) $link->track_browser_version, - 'track_operating_system' => (bool) $link->track_operating_system, - 'track_operating_system_version' => (bool) $link->track_operating_system_version, - 'track_device_type' => (bool) $link->track_device_type, - 'track_referer_url' => (bool) $link->track_referer_url, - 'track_browser_language' => (bool) $link->track_browser_language, - 'pixels' => $pixels->map(fn ($p) => [ - 'id' => $p->id, - 'name' => $p->name, - 'type' => $p->type, - 'pixel_id' => $p->pixel_id, - 'is_active' => (bool) $p->is_active, - ])->toArray(), - 'notes' => $link->notes, - 'created_at' => $link->created_at->toIso8601String(), - ]; - } - /** * Dispatch webhook if global or per-link webhook is active for 'created' event. */ @@ -414,15 +166,24 @@ class ShortUrlApiController extends Controller if (! empty($targetUrl)) { try { $connection = config('filament-short-url.queue_connection', 'sync'); - dispatch(new SendWebhookJob( + + $job = new SendWebhookJob( url: $targetUrl, event: 'created', payload: [ 'event' => 'created', 'timestamp' => now()->toIso8601String(), - 'short_url' => $this->transformLink($shortUrl), + 'short_url' => (new ShortUrlResource($shortUrl))->resolve(), ] - )->onConnection($connection ?: 'sync')); + ); + + if ($connection) { + $job->onConnection($connection); + } else { + $job->onConnection('sync'); + } + + dispatch($job); } catch (\Throwable $e) { Log::error('[FilamentShortUrl] Created webhook dispatch failed', [ 'url_key' => $shortUrl->url_key, diff --git a/src/Http/Controllers/ShortUrlLogoController.php b/src/Http/Controllers/ShortUrlLogoController.php index dbcfa78..86f3c97 100644 --- a/src/Http/Controllers/ShortUrlLogoController.php +++ b/src/Http/Controllers/ShortUrlLogoController.php @@ -8,10 +8,12 @@ namespace Bjanczak\FilamentShortUrl\Http\Controllers; +use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource; +use Bjanczak\FilamentShortUrl\Models\ShortUrl; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Routing\Controller; -use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Symfony\Component\HttpFoundation\BinaryFileResponse; @@ -24,17 +26,21 @@ class ShortUrlLogoController extends Controller */ public function uploadLogo(Request $request): JsonResponse { - Log::info('ShortUrlLogoController::uploadLogo called', [ - 'auth_check' => auth()->check(), - 'user_id' => auth()->id(), - 'has_file' => $request->hasFile('logo'), - 'all_files' => array_keys($request->allFiles()), - ]); - if (! auth()->check()) { return response()->json(['error' => 'Unauthorized'], 401); } + // Authorize access using Filament's resource check or direct Gate check + if (class_exists(ShortUrlResource::class)) { + if (! ShortUrlResource::canCreate()) { + return response()->json(['error' => 'Forbidden'], 403); + } + } else { + if (Gate::denies('create', ShortUrl::class)) { + return response()->json(['error' => 'Forbidden'], 403); + } + } + $request->validate([ 'logo' => 'required|image|max:10240', ]); @@ -203,7 +209,7 @@ class ShortUrlLogoController extends Controller imagedestroy($src); imagedestroy($dst); - if ($saved && $webpData !== false) { + if ($saved && ! empty($webpData)) { return $disk->put($targetPath, $webpData); } diff --git a/src/Http/Controllers/ShortUrlRedirectController.php b/src/Http/Controllers/ShortUrlRedirectController.php index 85234ae..a7dbcf5 100644 --- a/src/Http/Controllers/ShortUrlRedirectController.php +++ b/src/Http/Controllers/ShortUrlRedirectController.php @@ -4,6 +4,7 @@ namespace Bjanczak\FilamentShortUrl\Http\Controllers; use Bjanczak\FilamentShortUrl\Jobs\TrackShortUrlVisitJob; use Bjanczak\FilamentShortUrl\Models\ShortUrl; +use Bjanczak\FilamentShortUrl\Models\ShortUrlCustomDomain; use Bjanczak\FilamentShortUrl\Services\AppLinkingEngine; use Bjanczak\FilamentShortUrl\Services\ClientIpExtractor; use Bjanczak\FilamentShortUrl\Services\ProxyDetectionService; @@ -20,11 +21,46 @@ class ShortUrlRedirectController extends Controller { public function __construct( private readonly ShortUrlService $service, + private readonly ProxyDetectionService $proxyDetector, + private readonly UserAgentParser $uaParser, ) {} - public function __invoke(Request $request, string $key): Response + public function __invoke(Request $request, ?string $key = null): Response { - $shortUrl = ShortUrl::findByKey($key); + if (empty($key)) { + $key = $request->path(); + } + + $host = $request->getHost(); + $mainDomain = parse_url(config('app.url'), PHP_URL_HOST); + $isCustomDomain = $host && strcasecmp($host, $mainDomain) !== 0; + + if ($isCustomDomain) { + // Use a short-lived cache shared with ShortUrl::findByKey() to avoid two + // separate DB round-trips for the same custom domain on a single request. + $customDomain = cache()->remember( + "filament-short-url:custom-domain:{$host}", + 300, // 5 minutes β€” invalidated by ShortUrlCustomDomain model events + fn () => ShortUrlCustomDomain::where('domain', $host) + ->where('is_active', true) + ->first() + ); + + if (! $customDomain) { + abort(404); + } + } else { + $prefix = config('filament-short-url.route_prefix'); + if (! empty($prefix) && ! $request->route()?->named('short-url.redirect')) { + abort(404); + } + } + + if (str_contains($key, '/')) { + abort(404); + } + + $shortUrl = ShortUrl::findByKey($key, $host); // 404 if not found if (! $shortUrl) { @@ -33,7 +69,7 @@ class ShortUrlRedirectController extends Controller // Redirect to custom expiration URL if defined, otherwise 410 Gone if disabled or expired if (! $shortUrl->isActive()) { - if ($shortUrl->expiration_redirect_url) { + if ($shortUrl->expiration_redirect_url && $shortUrl->is_enabled) { return redirect()->away($shortUrl->expiration_redirect_url, 302); } @@ -45,8 +81,7 @@ class ShortUrlRedirectController extends Controller // 1. VPN/Proxy & Bot Blocking Check if (config('filament-short-url.vpn_detection.enabled', false) && config('filament-short-url.vpn_detection.block_action') === 'block_with_403') { $ipAddress = ClientIpExtractor::getIp($request); - $proxyDetector = app(ProxyDetectionService::class); - $detection = $proxyDetector->detect($ipAddress); + $detection = $this->proxyDetector->detect($ipAddress); if ($detection['is_proxy'] || $detection['is_bot']) { abort(403, 'Access denied. VPN, Proxy, or automated scraping connection detected.'); } @@ -95,7 +130,7 @@ class ShortUrlRedirectController extends Controller RateLimiter::hit($passwordLimiterKey, 60); // 1 minute decay $errors = new MessageBag([ - 'password' => __('filament-short-url::default.password_error') ?? 'Incorrect password.', + 'password' => __('filament-short-url::default.password_error'), ]); return response(view('filament-short-url::password-prompt', ['errors' => $errors])) @@ -112,8 +147,7 @@ class ShortUrlRedirectController extends Controller // App Linking / Deep Links Auto-Open Check if ($shortUrl->auto_open_app_mobile) { - $uaParser = app(UserAgentParser::class); - $deviceType = $uaParser->getDeviceType($request->userAgent() ?? ''); + $deviceType = $this->uaParser->getDeviceType($request->userAgent() ?? ''); if ($deviceType === 'mobile' || $deviceType === 'tablet') { $matchedApp = AppLinkingEngine::matchApp($destination); @@ -156,6 +190,8 @@ class ShortUrlRedirectController extends Controller } } + $selectedVariant = app()->bound('resolved_ab_variant') ? app('resolved_ab_variant') : null; + $job = new TrackShortUrlVisitJob( shortUrl: $shortUrl, ipAddress: $ipAddress, @@ -170,6 +206,7 @@ class ShortUrlRedirectController extends Controller utmContent: $request->query('utm_content'), isQrScan: $isQrScan, browserLanguage: $browserLanguage, + selectedVariant: $selectedVariant, ); if ($connection) { @@ -199,8 +236,24 @@ class ShortUrlRedirectController extends Controller ]), 410)->header('Content-Type', 'text/html'); } - // Manually forget cache since DB-level update does not trigger Eloquent events - cache()->forget("filament-short-url:{$shortUrl->url_key}"); + // Clear ALL cache key variants for this url_key. + // The redirect cache uses host-suffixed keys (e.g. "filament-short-url:{key}:{host}"). + // We must clear every variant to prevent a stale is_enabled=true cache entry + // on a different host from allowing the link to be re-used after it is disabled. + // This mirrors the logic in ShortUrl::saved(). + $appHost = parse_url(config('app.url'), PHP_URL_HOST); + $hostsToForget = array_unique(array_filter([ + 'default', + $appHost, + $request->getHost(), + $shortUrl->custom_domain_id && $shortUrl->customDomain + ? $shortUrl->customDomain->domain + : null, + ])); + + foreach ($hostsToForget as $h) { + cache()->forget("filament-short-url:{$shortUrl->url_key}:{$h}"); + } } $activePixels = $shortUrl->pixels->where('is_active', true); diff --git a/src/Http/Middleware/AuthenticateShortUrlApi.php b/src/Http/Middleware/AuthenticateShortUrlApi.php index 08fb6ba..ba85ab4 100644 --- a/src/Http/Middleware/AuthenticateShortUrlApi.php +++ b/src/Http/Middleware/AuthenticateShortUrlApi.php @@ -9,6 +9,10 @@ use Symfony\Component\HttpFoundation\Response; class AuthenticateShortUrlApi { + public function __construct( + private readonly ShortUrlSettingsManager $mgr, + ) {} + /** * Handle an incoming request. */ @@ -34,8 +38,7 @@ class AuthenticateShortUrlApi ], 401); } - $mgr = app(ShortUrlSettingsManager::class); - $keys = $mgr->get('api_keys', []); + $keys = $this->mgr->get('api_keys', []); $valid = false; $hashedInput = hash('sha256', $apiKey); diff --git a/src/Http/Requests/StoreShortUrlRequest.php b/src/Http/Requests/StoreShortUrlRequest.php new file mode 100644 index 0000000..f1619f3 --- /dev/null +++ b/src/Http/Requests/StoreShortUrlRequest.php @@ -0,0 +1,380 @@ + + * @copyright 2026 Bartek Janczak + * @license Custom Source-Available License (see LICENSE file) + */ + +namespace Bjanczak\FilamentShortUrl\Http\Requests; + +use Bjanczak\FilamentShortUrl\Models\ShortUrlCustomDomain; +use Bjanczak\FilamentShortUrl\Services\SafeBrowsingService; +use Illuminate\Foundation\Http\FormRequest; + +class StoreShortUrlRequest extends FormRequest +{ + /** + * Determine if the user is authorized to make this request. + */ + public function authorize(): bool + { + return true; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array + */ + public function rules(): array + { + $safeBrowsing = app(SafeBrowsingService::class); + + $countries = __('filament-short-url::countries'); + $countryRule = is_array($countries) && ! empty($countries) + ? 'in:'.implode(',', array_merge(array_keys($countries), array_map('strtolower', array_keys($countries)))) + : 'string|max:10'; + + $languages = __('filament-short-url::languages'); + $languageRule = is_array($languages) && ! empty($languages) + ? 'in:'.implode(',', array_merge(array_keys($languages), array_map('strtoupper', array_keys($languages)))) + : 'string|max:10'; + + $safeBrowsingRule = function (string $attribute, $value, \Closure $fail) use ($safeBrowsing) { + if (empty($value)) { + return; + } + if (! $safeBrowsing->isSafe($value)) { + $fail(__('filament-short-url::default.safe_browsing_error')); + } + }; + + $isLegacyRules = is_array($this->input('targeting_rules')) && isset($this->input('targeting_rules')['type']); + + $targetingRules = []; + if ($isLegacyRules) { + $targetingRules = [ + 'targeting_rules' => 'nullable|array', + 'targeting_rules.type' => 'required_with:targeting_rules|string|in:none,device,geo,language,rotation', + 'targeting_rules.device' => 'nullable|array', + 'targeting_rules.device.mobile' => ['nullable', 'url', 'max:2048', $safeBrowsingRule], + 'targeting_rules.device.tablet' => ['nullable', 'url', 'max:2048', $safeBrowsingRule], + 'targeting_rules.device.desktop' => ['nullable', 'url', 'max:2048', $safeBrowsingRule], + 'targeting_rules.device.ios' => ['nullable', 'url', 'max:2048', $safeBrowsingRule], + 'targeting_rules.device.android' => ['nullable', 'url', 'max:2048', $safeBrowsingRule], + 'targeting_rules.geo' => 'nullable|array', + 'targeting_rules.geo.*.country_code' => 'required_with:targeting_rules.geo|distinct:ignore_case|'.$countryRule, + 'targeting_rules.geo.*.url' => ['required_with:targeting_rules.geo', 'url', 'max:2048', $safeBrowsingRule], + 'targeting_rules.language' => 'nullable|array', + 'targeting_rules.language.*.language_code' => 'required_with:targeting_rules.language|distinct:ignore_case|'.$languageRule, + 'targeting_rules.language.*.url' => ['required_with:targeting_rules.language', 'url', 'max:2048', $safeBrowsingRule], + 'targeting_rules.rotation' => 'nullable|array', + 'targeting_rules.rotation.*.url' => ['required_with:targeting_rules.rotation', 'url', 'max:2048', $safeBrowsingRule], + 'targeting_rules.rotation.*.weight' => 'required_with:targeting_rules.rotation|integer|min:1|max:1000', + ]; + } else { + $targetingRules = [ + 'targeting_rules' => [ + 'nullable', + 'array', + 'max:10', + function (string $attribute, $value, \Closure $fail) use ($safeBrowsing) { + if (! is_array($value)) { + return; + } + foreach ($value as $index => $rule) { + if (! is_array($rule)) { + $fail("Targeting rule at index {$index} must be an array."); + + continue; + } + $allowedKeys = ['match', 'destination_type', 'url', 'variants', 'filters']; + $invalidKeys = array_diff(array_keys($rule), $allowedKeys); + if (! empty($invalidKeys)) { + $fail("Invalid keys in targeting rule at index {$index}: ".implode(', ', $invalidKeys)); + } + + $destType = $rule['destination_type'] ?? 'single'; + if ($destType === 'split') { + if (empty($rule['variants']) || ! is_array($rule['variants'])) { + $fail("Targeting rule at index {$index} requires a non-empty 'variants' array for split destination type."); + } else { + $variants = $rule['variants']; + $variantsCount = count($variants); + if ($variantsCount < 2 || $variantsCount > 5) { + $fail("Targeting rule at index {$index} must have between 2 and 5 variants."); + + continue; + } + + $variantSum = 0; + foreach ($variants as $vIndex => $variant) { + if (! is_array($variant)) { + $fail("Variant at index {$vIndex} of targeting rule {$index} must be an array."); + + continue; + } + + // Guard against random keys in variant + $allowedVariantKeys = ['label', 'url', 'weight']; + $invalidVariantKeys = array_diff(array_keys($variant), $allowedVariantKeys); + if (! empty($invalidVariantKeys)) { + $fail("Invalid keys in variant at index {$vIndex} of targeting rule {$index}: ".implode(', ', $invalidVariantKeys)); + } + + if (empty($variant['url']) || ! filter_var($variant['url'], FILTER_VALIDATE_URL)) { + $fail("Variant at index {$vIndex} of targeting rule {$index} requires a valid 'url'."); + } else { + if (! $safeBrowsing->isSafe($variant['url'])) { + $fail("Variant at index {$vIndex} of targeting rule {$index} URL has been flagged by Google Safe Browsing as unsafe."); + } + } + if (empty($variant['label']) || ! is_string($variant['label'])) { + $fail("Variant at index {$vIndex} of targeting rule {$index} requires a string 'label'."); + } + if (! isset($variant['weight']) || ! is_numeric($variant['weight'])) { + $fail("Variant at index {$vIndex} of targeting rule {$index} requires a numeric 'weight'."); + } else { + $weight = $variant['weight']; + if (floor($weight) != $weight) { + $fail("Variant at index {$vIndex} of targeting rule {$index} weight must be an integer."); + } else { + $weight = (int) $weight; + if ($weight < 0 || $weight > 100) { + $fail("Variant at index {$vIndex} of targeting rule {$index} weight must be between 0 and 100."); + } + $variantSum += $weight; + } + } + } + + if ($variantSum !== 100) { + $fail("Suma udziaΕ‚Γ³w w ruchu dla targeting rule {$index} musi wynosiΔ‡ dokΕ‚adnie 100%. Obecna suma: {$variantSum}%."); + } + } + } else { + if (empty($rule['url']) || ! filter_var($rule['url'], FILTER_VALIDATE_URL)) { + $fail("Targeting rule at index {$index} requires a valid 'url' for single destination type."); + } else { + if (! $safeBrowsing->isSafe($rule['url'])) { + $fail("Targeting rule at index {$index} URL has been flagged by Google Safe Browsing as unsafe."); + } + } + if (! empty($rule['variants'])) { + $fail("Targeting rule at index {$index} must not have 'variants' for single destination type."); + } + } + } + }, + ], + 'targeting_rules.*.match' => 'required_with:targeting_rules|string|in:or,and', + 'targeting_rules.*.destination_type' => 'nullable|string|in:single,split', + 'targeting_rules.*.url' => 'nullable|url|max:2048', + 'targeting_rules.*.variants' => 'nullable|array', + 'targeting_rules.*.filters' => [ + 'required_with:targeting_rules', + 'array', + 'min:1', + function (string $attribute, $value, \Closure $fail) { + if (! is_array($value)) { + return; + } + $types = collect($value)->pluck('type'); + if ($types->duplicates()->isNotEmpty()) { + $fail('Each filter type (device, platform, country, language) can only be added once.'); + } + + foreach ($value as $index => $filter) { + if (! is_array($filter)) { + $fail("Filter at index {$index} must be an array."); + + continue; + } + + $allowedFilterKeys = ['type', 'data']; + $invalidFilterKeys = array_diff(array_keys($filter), $allowedFilterKeys); + if (! empty($invalidFilterKeys)) { + $fail("Invalid keys in filter at index {$index}: ".implode(', ', $invalidFilterKeys)); + + continue; + } + + $type = $filter['type'] ?? null; + $data = $filter['data'] ?? null; + + if (! in_array($type, ['device', 'platform', 'country', 'language'])) { + continue; + } + + if (! is_array($data)) { + $fail("Filter data for type '{$type}' must be an array."); + + continue; + } + + $allowedKeys = match ($type) { + 'device' => ['devices'], + 'platform' => ['platforms'], + 'country' => ['countries'], + 'language' => ['languages'], + }; + + $invalidKeys = array_diff(array_keys($data), $allowedKeys); + if (! empty($invalidKeys)) { + $fail("Invalid keys in data for filter '{$type}': ".implode(', ', $invalidKeys)); + + continue; + } + + $mainKey = $allowedKeys[0]; + if (! isset($data[$mainKey]) || ! is_array($data[$mainKey]) || empty($data[$mainKey])) { + $fail("Filter '{$type}' requires a non-empty array named '{$mainKey}'."); + + continue; + } + } + }, + ], + 'targeting_rules.*.filters.*.type' => 'required_with:targeting_rules|string|in:device,platform,country,language', + 'targeting_rules.*.filters.*.data' => 'required_with:targeting_rules|array', + 'targeting_rules.*.filters.*.data.devices' => 'nullable|array', + 'targeting_rules.*.filters.*.data.devices.*' => 'string|in:desktop,mobile,tablet', + 'targeting_rules.*.filters.*.data.platforms' => 'nullable|array', + 'targeting_rules.*.filters.*.data.platforms.*' => 'string|in:android,fire_os,ios,linux,mac,windows', + 'targeting_rules.*.filters.*.data.countries' => 'nullable|array', + 'targeting_rules.*.filters.*.data.countries.*' => 'string|'.$countryRule, + 'targeting_rules.*.filters.*.data.languages' => 'nullable|array', + 'targeting_rules.*.filters.*.data.languages.*' => 'string|'.$languageRule, + ]; + } + + $destType = $this->input('destination_type', 'single'); + + $rotationVariantsRules = ['array']; + if ($destType === 'split') { + $rotationVariantsRules[] = 'required'; + $rotationVariantsRules[] = 'min:2'; + $rotationVariantsRules[] = 'max:5'; + } else { + $rotationVariantsRules[] = 'nullable'; + $rotationVariantsRules[] = 'max:0'; + } + + $rotationVariantsRules[] = function (string $attribute, $value, \Closure $fail) use ($destType, $safeBrowsing) { + if (! is_array($value)) { + return; + } + if ($destType === 'single') { + if (! empty($value)) { + $fail('Rotation variants are not allowed for single destination type.'); + } + + return; + } + + $sum = 0; + foreach ($value as $index => $variant) { + if (! is_array($variant)) { + $fail("Variant at index {$index} must be an array."); + + continue; + } + + $allowedKeys = ['label', 'url', 'weight']; + $invalidKeys = array_diff(array_keys($variant), $allowedKeys); + if (! empty($invalidKeys)) { + $fail("Invalid keys in variant at index {$index}: ".implode(', ', $invalidKeys)); + } + + $weight = $variant['weight'] ?? null; + if ($weight === null || ! is_numeric($weight)) { + continue; + } + + if (floor($weight) != $weight) { + $fail("Variant at index {$index} weight must be an integer."); + } else { + $weight = (int) $weight; + if ($weight < 0 || $weight > 100) { + $fail("Variant at index {$index} weight must be between 0 and 100."); + } + $sum += $weight; + } + + $url = $variant['url'] ?? null; + if ($url && filter_var($url, FILTER_VALIDATE_URL)) { + if (! $safeBrowsing->isSafe($url)) { + $fail("Variant at index {$index} URL has been flagged by Google Safe Browsing as unsafe."); + } + } + } + + if ($sum !== 100) { + $fail('Suma udziaΕ‚Γ³w w ruchu musi wynosiΔ‡ dokΕ‚adnie 100%. Obecna suma: '.$sum.'%.'); + } + }; + + $domainsCount = ShortUrlCustomDomain::where('is_active', true) + ->where('is_verified', true) + ->count(); + $defaultDisabled = (bool) config('filament-short-url.disable_default_domain', false); + + $isRequired = $defaultDisabled && $domainsCount > 1; + $customDomainRule = [ + $isRequired ? 'required' : 'nullable', + 'integer', + 'exists:short_url_custom_domains,id,is_active,1,is_verified,1', + ]; + + $rules = [ + 'destination_type' => 'sometimes|required|string|in:single,split', + 'destination_url' => array_merge( + $destType === 'single' ? ['required'] : ['nullable'], + ['url', 'max:2048', $safeBrowsingRule] + ), + 'rotation_variants' => $rotationVariantsRules, + 'rotation_variants.*.label' => 'required_with:rotation_variants|string|max:100', + 'rotation_variants.*.url' => 'required_with:rotation_variants|url|max:2048', + 'rotation_variants.*.weight' => 'required_with:rotation_variants|integer|min:0|max:100', + 'custom_domain_id' => $customDomainRule, + 'url_key' => [ + 'nullable', + 'string', + 'alpha_dash', + 'max:32', + 'unique:short_urls,url_key', + ], + 'notes' => 'nullable|string|max:255', + 'is_enabled' => 'sometimes|required|boolean', + 'redirect_status_code' => 'sometimes|required|integer|in:301,302', + 'single_use' => 'sometimes|required|boolean', + 'forward_query_params' => 'sometimes|required|boolean', + 'max_visits' => 'nullable|integer|min:1', + 'expiration_redirect_url' => 'nullable|url|max:255', + 'activated_at' => 'nullable|date|after_or_equal:today', + 'expires_at' => 'nullable|date|after_or_equal:activated_at', + 'webhook_url' => 'nullable|url|max:2048', + ]; + + $rules = array_merge($rules, $targetingRules); + + return array_merge($rules, [ + 'password' => 'nullable|string|max:255', + 'show_warning_page' => 'sometimes|required|boolean', + 'auto_open_app_mobile' => 'sometimes|required|boolean', + 'ga_tracking_id' => 'nullable|string|max:50|regex:/^G-[A-Z0-9]+$/', + 'track_visits' => 'sometimes|required|boolean', + 'track_ip_address' => 'sometimes|required|boolean', + 'track_browser' => 'sometimes|required|boolean', + 'track_browser_version' => 'sometimes|required|boolean', + 'track_operating_system' => 'sometimes|required|boolean', + 'track_operating_system_version' => 'sometimes|required|boolean', + 'track_device_type' => 'sometimes|required|boolean', + 'track_referer_url' => 'sometimes|required|boolean', + 'track_browser_language' => 'sometimes|required|boolean', + 'pixels' => 'nullable|array', + 'pixels.*' => 'integer|exists:short_url_pixels,id', + ]); + } +} diff --git a/src/Http/Requests/UpdateShortUrlRequest.php b/src/Http/Requests/UpdateShortUrlRequest.php new file mode 100644 index 0000000..8136c81 --- /dev/null +++ b/src/Http/Requests/UpdateShortUrlRequest.php @@ -0,0 +1,459 @@ + + * @copyright 2026 Bartek Janczak + * @license Custom Source-Available License (see LICENSE file) + */ + +namespace Bjanczak\FilamentShortUrl\Http\Requests; + +use Bjanczak\FilamentShortUrl\Models\ShortUrl; +use Bjanczak\FilamentShortUrl\Services\SafeBrowsingService; +use Illuminate\Foundation\Http\FormRequest; + +class UpdateShortUrlRequest extends FormRequest +{ + /** + * Cache for the resolved ShortUrl model. + */ + protected ?ShortUrl $shortUrlModel = null; + + /** + * Determine if the user is authorized to make this request. + */ + public function authorize(): bool + { + return true; + } + + /** + * Get the resolved ShortUrl model for this request. + */ + public function getModel(): ShortUrl + { + if ($this->shortUrlModel !== null) { + return $this->shortUrlModel; + } + + $idOrKey = $this->route('idOrKey'); + if (is_numeric($idOrKey)) { + $this->shortUrlModel = ShortUrl::findOrFail((int) $idOrKey); + } else { + $link = ShortUrl::where('url_key', $idOrKey)->first(); + + if (! $link) { + abort(404, 'Short URL not found.'); + } + + $this->shortUrlModel = $link; + } + + return $this->shortUrlModel; + } + + /** + * Prepare the data for validation. + */ + protected function prepareForValidation(): void + { + $model = $this->getModel(); + + if ($this->has('expires_at') && ! $this->has('activated_at')) { + $this->merge([ + 'activated_at' => $model->activated_at?->toIso8601String(), + ]); + } + if ($this->has('activated_at') && ! $this->has('expires_at')) { + $this->merge([ + 'expires_at' => $model->expires_at?->toIso8601String(), + ]); + } + } + + /** + * Get the validation rules that apply to the request. + * + * @return array + */ + public function rules(): array + { + $model = $this->getModel(); + $safeBrowsing = app(SafeBrowsingService::class); + + $countries = __('filament-short-url::countries'); + $countryRule = is_array($countries) && ! empty($countries) + ? 'in:'.implode(',', array_merge(array_keys($countries), array_map('strtolower', array_keys($countries)))) + : 'string|max:10'; + + $languages = __('filament-short-url::languages'); + $languageRule = is_array($languages) && ! empty($languages) + ? 'in:'.implode(',', array_merge(array_keys($languages), array_map('strtoupper', array_keys($languages)))) + : 'string|max:10'; + + $safeBrowsingRule = function (string $attribute, $value, \Closure $fail) use ($safeBrowsing) { + if (empty($value)) { + return; + } + if (! $safeBrowsing->isSafe($value)) { + $fail(__('filament-short-url::default.safe_browsing_error')); + } + }; + + $isLegacyRules = is_array($this->input('targeting_rules')) && isset($this->input('targeting_rules')['type']); + + $targetingRules = []; + if ($isLegacyRules) { + $targetingRules = [ + 'targeting_rules' => 'nullable|array', + 'targeting_rules.type' => 'required_with:targeting_rules|string|in:none,device,geo,language,rotation', + 'targeting_rules.device' => 'nullable|array', + 'targeting_rules.device.mobile' => ['nullable', 'url', 'max:2048', $safeBrowsingRule], + 'targeting_rules.device.tablet' => ['nullable', 'url', 'max:2048', $safeBrowsingRule], + 'targeting_rules.device.desktop' => ['nullable', 'url', 'max:2048', $safeBrowsingRule], + 'targeting_rules.device.ios' => ['nullable', 'url', 'max:2048', $safeBrowsingRule], + 'targeting_rules.device.android' => ['nullable', 'url', 'max:2048', $safeBrowsingRule], + 'targeting_rules.geo' => 'nullable|array', + 'targeting_rules.geo.*.country_code' => 'required_with:targeting_rules.geo|distinct:ignore_case|'.$countryRule, + 'targeting_rules.geo.*.url' => ['required_with:targeting_rules.geo', 'url', 'max:2048', $safeBrowsingRule], + 'targeting_rules.language' => 'nullable|array', + 'targeting_rules.language.*.language_code' => 'required_with:targeting_rules.language|distinct:ignore_case|'.$languageRule, + 'targeting_rules.language.*.url' => ['required_with:targeting_rules.language', 'url', 'max:2048', $safeBrowsingRule], + 'targeting_rules.rotation' => 'nullable|array', + 'targeting_rules.rotation.*.url' => ['required_with:targeting_rules.rotation', 'url', 'max:2048', $safeBrowsingRule], + 'targeting_rules.rotation.*.weight' => 'required_with:targeting_rules.rotation|integer|min:1|max:1000', + ]; + } else { + $targetingRules = [ + 'targeting_rules' => [ + 'nullable', + 'array', + 'max:10', + function (string $attribute, $value, \Closure $fail) use ($safeBrowsing) { + if (! is_array($value)) { + return; + } + foreach ($value as $index => $rule) { + if (! is_array($rule)) { + $fail("Targeting rule at index {$index} must be an array."); + + continue; + } + $allowedKeys = ['match', 'destination_type', 'url', 'variants', 'filters']; + $invalidKeys = array_diff(array_keys($rule), $allowedKeys); + if (! empty($invalidKeys)) { + $fail("Invalid keys in targeting rule at index {$index}: ".implode(', ', $invalidKeys)); + } + + $destType = $rule['destination_type'] ?? 'single'; + if ($destType === 'split') { + if (empty($rule['variants']) || ! is_array($rule['variants'])) { + $fail("Targeting rule at index {$index} requires a non-empty 'variants' array for split destination type."); + } else { + $variants = $rule['variants']; + $variantsCount = count($variants); + if ($variantsCount < 2 || $variantsCount > 5) { + $fail("Targeting rule at index {$index} must have between 2 and 5 variants."); + + continue; + } + + $variantSum = 0; + foreach ($variants as $vIndex => $variant) { + if (! is_array($variant)) { + $fail("Variant at index {$vIndex} of targeting rule {$index} must be an array."); + + continue; + } + + // Guard against random keys in variant + $allowedVariantKeys = ['label', 'url', 'weight']; + $invalidVariantKeys = array_diff(array_keys($variant), $allowedVariantKeys); + if (! empty($invalidVariantKeys)) { + $fail("Invalid keys in variant at index {$vIndex} of targeting rule {$index}: ".implode(', ', $invalidVariantKeys)); + } + + if (empty($variant['url']) || ! filter_var($variant['url'], FILTER_VALIDATE_URL)) { + $fail("Variant at index {$vIndex} of targeting rule {$index} requires a valid 'url'."); + } else { + if (! $safeBrowsing->isSafe($variant['url'])) { + $fail("Variant at index {$vIndex} of targeting rule {$index} URL has been flagged by Google Safe Browsing as unsafe."); + } + } + if (empty($variant['label']) || ! is_string($variant['label'])) { + $fail("Variant at index {$vIndex} of targeting rule {$index} requires a string 'label'."); + } + if (! isset($variant['weight']) || ! is_numeric($variant['weight'])) { + $fail("Variant at index {$vIndex} of targeting rule {$index} requires a numeric 'weight'."); + } else { + $weight = $variant['weight']; + if (floor($weight) != $weight) { + $fail("Variant at index {$vIndex} of targeting rule {$index} weight must be an integer."); + } else { + $weight = (int) $weight; + if ($weight < 0 || $weight > 100) { + $fail("Variant at index {$vIndex} of targeting rule {$index} weight must be between 0 and 100."); + } + $variantSum += $weight; + } + } + } + + if ($variantSum !== 100) { + $fail("Suma udziaΕ‚Γ³w w ruchu dla targeting rule {$index} musi wynosiΔ‡ dokΕ‚adnie 100%. Obecna suma: {$variantSum}%."); + } + } + } else { + if (empty($rule['url']) || ! filter_var($rule['url'], FILTER_VALIDATE_URL)) { + $fail("Targeting rule at index {$index} requires a valid 'url' for single destination type."); + } else { + if (! $safeBrowsing->isSafe($rule['url'])) { + $fail("Targeting rule at index {$index} URL has been flagged by Google Safe Browsing as unsafe."); + } + } + if (! empty($rule['variants'])) { + $fail("Targeting rule at index {$index} must not have 'variants' for single destination type."); + } + } + } + }, + ], + 'targeting_rules.*.match' => 'required_with:targeting_rules|string|in:or,and', + 'targeting_rules.*.destination_type' => 'nullable|string|in:single,split', + 'targeting_rules.*.url' => 'nullable|url|max:2048', + 'targeting_rules.*.variants' => 'nullable|array', + 'targeting_rules.*.filters' => [ + 'required_with:targeting_rules', + 'array', + 'min:1', + function (string $attribute, $value, \Closure $fail) { + if (! is_array($value)) { + return; + } + $types = collect($value)->pluck('type'); + if ($types->duplicates()->isNotEmpty()) { + $fail('Each filter type (device, platform, country, language) can only be added once.'); + } + + foreach ($value as $index => $filter) { + if (! is_array($filter)) { + $fail("Filter at index {$index} must be an array."); + + continue; + } + + $allowedFilterKeys = ['type', 'data']; + $invalidFilterKeys = array_diff(array_keys($filter), $allowedFilterKeys); + if (! empty($invalidFilterKeys)) { + $fail("Invalid keys in filter at index {$index}: ".implode(', ', $invalidFilterKeys)); + + continue; + } + + $type = $filter['type'] ?? null; + $data = $filter['data'] ?? null; + + if (! in_array($type, ['device', 'platform', 'country', 'language'])) { + continue; + } + + if (! is_array($data)) { + $fail("Filter data for type '{$type}' must be an array."); + + continue; + } + + $allowedKeys = match ($type) { + 'device' => ['devices'], + 'platform' => ['platforms'], + 'country' => ['countries'], + 'language' => ['languages'], + }; + + $invalidKeys = array_diff(array_keys($data), $allowedKeys); + if (! empty($invalidKeys)) { + $fail("Invalid keys in data for filter '{$type}': ".implode(', ', $invalidKeys)); + + continue; + } + + $mainKey = $allowedKeys[0]; + if (! isset($data[$mainKey]) || ! is_array($data[$mainKey]) || empty($data[$mainKey])) { + $fail("Filter '{$type}' requires a non-empty array named '{$mainKey}'."); + + continue; + } + } + }, + ], + 'targeting_rules.*.filters.*.type' => 'required_with:targeting_rules|string|in:device,platform,country,language', + 'targeting_rules.*.filters.*.data' => 'required_with:targeting_rules|array', + 'targeting_rules.*.filters.*.data.devices' => 'nullable|array', + 'targeting_rules.*.filters.*.data.devices.*' => 'string|in:desktop,mobile,tablet', + 'targeting_rules.*.filters.*.data.platforms' => 'nullable|array', + 'targeting_rules.*.filters.*.data.platforms.*' => 'string|in:android,fire_os,ios,linux,mac,windows', + 'targeting_rules.*.filters.*.data.countries' => 'nullable|array', + 'targeting_rules.*.filters.*.data.countries.*' => 'string|'.$countryRule, + 'targeting_rules.*.filters.*.data.languages' => 'nullable|array', + 'targeting_rules.*.filters.*.data.languages.*' => 'string|'.$languageRule, + ]; + } + + $destType = $this->input('destination_type', $model->destination_type); + + $isRequired = false; + if ($this->has('destination_type') && $this->input('destination_type') === 'split') { + $isRequired = true; + } + + $rotationVariantsRules = ['array']; + if ($destType === 'split') { + $rotationVariantsRules[] = $isRequired ? 'required' : 'sometimes|required'; + $rotationVariantsRules[] = 'min:2'; + $rotationVariantsRules[] = 'max:5'; + } else { + $rotationVariantsRules[] = 'nullable'; + $rotationVariantsRules[] = 'max:0'; + } + + $rotationVariantsRules[] = function (string $attribute, $value, \Closure $fail) use ($destType, $safeBrowsing) { + if (! is_array($value)) { + return; + } + if ($destType === 'single') { + if (! empty($value)) { + $fail('Rotation variants are not allowed for single destination type.'); + } + + return; + } + + $sum = 0; + foreach ($value as $index => $variant) { + if (! is_array($variant)) { + $fail("Variant at index {$index} must be an array."); + + continue; + } + + $allowedKeys = ['label', 'url', 'weight']; + $invalidKeys = array_diff(array_keys($variant), $allowedKeys); + if (! empty($invalidKeys)) { + $fail("Invalid keys in variant at index {$index}: ".implode(', ', $invalidKeys)); + } + + $weight = $variant['weight'] ?? null; + if ($weight === null || ! is_numeric($weight)) { + continue; + } + + if (floor($weight) != $weight) { + $fail("Variant at index {$index} weight must be an integer."); + } else { + $weight = (int) $weight; + if ($weight < 0 || $weight > 100) { + $fail("Variant at index {$index} weight must be between 0 and 100."); + } + $sum += $weight; + } + + $url = $variant['url'] ?? null; + if ($url && filter_var($url, FILTER_VALIDATE_URL)) { + if (! $safeBrowsing->isSafe($url)) { + $fail("Variant at index {$index} URL has been flagged by Google Safe Browsing as unsafe."); + } + } + } + + if ($sum !== 100) { + $fail('Suma udziaΕ‚Γ³w w ruchu musi wynosiΔ‡ dokΕ‚adnie 100%. Obecna suma: '.$sum.'%.'); + } + }; + + $activatedAtRule = 'nullable|date'; + if ($this->has('activated_at') && $this->input('activated_at') !== $model->activated_at?->toIso8601String() && $this->input('activated_at') !== $model->activated_at?->toDateTimeString()) { + $activatedAtRule .= '|after_or_equal:today'; + } + + $defaultDisabled = (bool) config('filament-short-url.disable_default_domain', false); + + $customDomainRule = [ + 'sometimes', + $defaultDisabled ? 'required' : 'nullable', + 'integer', + 'exists:short_url_custom_domains,id,is_active,1,is_verified,1', + ]; + + if (config('filament-short-url.lock_url_key', false)) { + $customDomainRule[] = function (string $attribute, $value, \Closure $fail) use ($model) { + $original = $model->custom_domain_id !== null ? (int) $model->custom_domain_id : null; + $newVal = $value !== null && $value !== '' ? (int) $value : null; + + if ($newVal !== $original) { + $fail(__('filament-short-url::default.custom_domain_locked_error')); + } + }; + } + + $urlKeyRule = [ + 'sometimes', + 'required', + 'string', + 'alpha_dash', + 'max:32', + 'unique:short_urls,url_key,'.$model->id, + ]; + + if (config('filament-short-url.lock_url_key', false)) { + $urlKeyRule[] = function (string $attribute, $value, \Closure $fail) use ($model) { + if ($value !== $model->url_key) { + $fail(__('filament-short-url::default.url_key_locked_error')); + } + }; + } + + $rules = [ + 'destination_type' => 'sometimes|required|string|in:single,split', + 'destination_url' => array_merge( + $destType === 'single' ? ['sometimes', 'required'] : ['nullable'], + ['url', 'max:2048', $safeBrowsingRule] + ), + 'rotation_variants' => $rotationVariantsRules, + 'rotation_variants.*.label' => 'required_with:rotation_variants|string|max:100', + 'rotation_variants.*.url' => 'required_with:rotation_variants|url|max:2048', + 'rotation_variants.*.weight' => 'required_with:rotation_variants|integer|min:0|max:100', + 'custom_domain_id' => $customDomainRule, + 'url_key' => $urlKeyRule, + 'notes' => 'nullable|string|max:255', + 'is_enabled' => 'sometimes|required|boolean', + 'redirect_status_code' => 'sometimes|required|integer|in:301,302', + 'single_use' => 'sometimes|required|boolean', + 'forward_query_params' => 'sometimes|required|boolean', + 'max_visits' => 'nullable|integer|min:1', + 'expiration_redirect_url' => 'nullable|url|max:255', + 'activated_at' => $activatedAtRule, + 'expires_at' => 'nullable|date|after_or_equal:activated_at', + 'webhook_url' => 'nullable|url|max:2048', + ]; + + $rules = array_merge($rules, $targetingRules); + + return array_merge($rules, [ + 'password' => 'nullable|string|max:255', + 'show_warning_page' => 'sometimes|required|boolean', + 'auto_open_app_mobile' => 'sometimes|required|boolean', + 'ga_tracking_id' => 'nullable|string|max:50|regex:/^G-[A-Z0-9]+$/', + 'track_visits' => 'sometimes|required|boolean', + 'track_ip_address' => 'sometimes|required|boolean', + 'track_browser' => 'sometimes|required|boolean', + 'track_browser_version' => 'sometimes|required|boolean', + 'track_operating_system' => 'sometimes|required|boolean', + 'track_operating_system_version' => 'sometimes|required|boolean', + 'track_device_type' => 'sometimes|required|boolean', + 'track_referer_url' => 'sometimes|required|boolean', + 'track_browser_language' => 'sometimes|required|boolean', + 'pixels' => 'nullable|array', + 'pixels.*' => 'integer|exists:short_url_pixels,id', + ]); + } +} diff --git a/src/Http/Resources/ShortUrlResource.php b/src/Http/Resources/ShortUrlResource.php new file mode 100644 index 0000000..6328481 --- /dev/null +++ b/src/Http/Resources/ShortUrlResource.php @@ -0,0 +1,66 @@ + + * @copyright 2026 Bartek Janczak + * @license Custom Source-Available License (see LICENSE file) + */ + +namespace Bjanczak\FilamentShortUrl\Http\Resources; + +use Illuminate\Http\Request; +use Illuminate\Http\Resources\Json\JsonResource; + +class ShortUrlResource extends JsonResource +{ + /** + * Transform the resource into an array. + * + * @return array + */ + public function toArray(Request $request): array + { + $pixels = $this->relationLoaded('pixels') ? $this->pixels : $this->pixels()->get(); + + return [ + 'id' => $this->id, + 'destination_type' => $this->destination_type, + 'destination_url' => $this->destination_url, + 'rotation_variants' => $this->rotation_variants, + 'custom_domain_id' => $this->custom_domain_id, + 'url_key' => $this->url_key, + 'short_url' => $this->getShortUrl(), + 'is_enabled' => (bool) $this->is_enabled, + 'redirect_status_code' => (int) $this->redirect_status_code, + 'total_visits' => (int) $this->total_visits, + 'unique_visits' => (int) $this->unique_visits, + 'max_visits' => $this->max_visits ? (int) $this->max_visits : null, + 'activated_at' => $this->activated_at ? $this->activated_at->toIso8601String() : null, + 'expires_at' => $this->expires_at ? $this->expires_at->toIso8601String() : null, + 'webhook_url' => $this->webhook_url, + 'targeting_rules' => $this->targeting_rules, + 'password' => $this->password, + 'show_warning_page' => (bool) $this->show_warning_page, + 'auto_open_app_mobile' => (bool) $this->auto_open_app_mobile, + 'ga_tracking_id' => $this->ga_tracking_id, + 'track_visits' => (bool) $this->track_visits, + 'track_ip_address' => (bool) $this->track_ip_address, + 'track_browser' => (bool) $this->track_browser, + 'track_browser_version' => (bool) $this->track_browser_version, + 'track_operating_system' => (bool) $this->track_operating_system, + 'track_operating_system_version' => (bool) $this->track_operating_system_version, + 'track_device_type' => (bool) $this->track_device_type, + 'track_referer_url' => (bool) $this->track_referer_url, + 'track_browser_language' => (bool) $this->track_browser_language, + 'pixels' => $pixels->map(fn ($p) => [ + 'id' => $p->id, + 'name' => $p->name, + 'type' => $p->type, + 'pixel_id' => $p->pixel_id, + 'is_active' => (bool) $p->is_active, + ])->toArray(), + 'notes' => $this->notes, + 'created_at' => $this->created_at->toIso8601String(), + ]; + } +} diff --git a/src/Jobs/TrackShortUrlVisitJob.php b/src/Jobs/TrackShortUrlVisitJob.php index 7c1ae58..71eca01 100644 --- a/src/Jobs/TrackShortUrlVisitJob.php +++ b/src/Jobs/TrackShortUrlVisitJob.php @@ -48,6 +48,7 @@ class TrackShortUrlVisitJob implements ShouldQueue public readonly ?string $utmContent = null, public readonly bool $isQrScan = false, public readonly ?string $browserLanguage = null, + public readonly ?string $selectedVariant = null, ) { $this->onQueue(config('filament-short-url.queue_name', 'default')); } @@ -68,8 +69,8 @@ class TrackShortUrlVisitJob implements ShouldQueue 'HTTP_REFERER' => $this->refererUrl, ]); - $countryCode = isset($this->countryCode) ? $this->countryCode : null; - $city = isset($this->city) ? $this->city : null; + $countryCode = $this->countryCode; + $city = $this->city; $visit = $tracker->record( shortUrl: $shortUrl, @@ -83,13 +84,21 @@ class TrackShortUrlVisitJob implements ShouldQueue utmContent: $this->utmContent, isQrScan: $this->isQrScan, browserLanguage: $this->browserLanguage, + selectedVariant: $this->selectedVariant, ); - // Null means bot/crawler β€” nothing to dispatch or report + // Null means the tracker detected a bot/crawler β€” nothing to dispatch. if ($visit === null) { return; } + // Skip webhooks and GA4 for proxy/VPN visits. The visit is still persisted + // to the database for audit purposes, but external integrations should only + // receive events for legitimate human traffic β€” matching what the stats UI shows. + if ($visit->is_bot || $visit->is_proxy) { + return; + } + // Fire event for user listeners ShortUrlVisited::dispatch($shortUrl, $visit); diff --git a/src/Models/Concerns/HasStats.php b/src/Models/Concerns/HasStats.php new file mode 100644 index 0000000..e2919a2 --- /dev/null +++ b/src/Models/Concerns/HasStats.php @@ -0,0 +1,980 @@ +> + */ + protected array $memoizedStats = []; + + /** + * Cache properties to hold preloaded buffered visits for the current request. + */ + protected static ?array $bufferedTotalVisits = null; + + protected static ?array $bufferedUniqueVisits = null; + + protected static ?array $bufferedQrScans = null; + + /** + * Get the real-time total visits count. + */ + public function getRealTimeTotalVisits(): int + { + if (config('filament-short-url.counter_buffering.enabled', false)) { + $prefix = config('filament-short-url.counter_buffering.cache_key_prefix', 'filament-short-url:buffer:'); + $buffered = (int) cache()->get("{$prefix}total:{$this->id}", 0); + + return $this->total_visits + $buffered; + } + + // Use real-time visit count in cache to keep the cached model instance updated + $cacheKey = "filament-short-url:visits:{$this->id}"; + + return (int) cache()->remember($cacheKey, 3600, fn () => $this->total_visits); + } + + /** + * Atomically increment visit counters β€” single query when unique, + * to avoid race conditions and two round-trips. Supports write-back caching. + */ + public function incrementVisits(bool $isUnique = false, bool $isQrScan = false): void + { + if (config('filament-short-url.counter_buffering.enabled', false)) { + $prefix = config('filament-short-url.counter_buffering.cache_key_prefix', 'filament-short-url:buffer:'); + try { + // Increment atomically in cache (works on Redis, Memcached, Database, File, etc.) + cache()->increment("{$prefix}total:{$this->id}"); + + if ($isUnique) { + cache()->increment("{$prefix}unique:{$this->id}"); + } + + if ($isQrScan) { + cache()->increment("{$prefix}qr:{$this->id}"); + } + + // Add to dirty IDs list β€” choose the best strategy for the active store. + // instanceof check is reliable across named stores, Predis, Relay, and Redis Sentinel. + $store = cache()->store()->getStore(); + if ($store instanceof RedisStore) { + // O(1) set add β€” atomic, memory-efficient, no lock needed + $store->connection()->sadd("{$prefix}dirty_ids", $this->id); + } else { + // For non-Redis stores: acquire an exclusive lock before modifying the + // shared dirty_ids array. If the lock cannot be acquired within 2 seconds + // (high contention), we fall through to a direct DB increment so no click + // is ever silently lost. + $lock = cache()->lock("{$prefix}dirty_ids_lock", 2); + $registered = $lock->get(function () use ($prefix): bool { + $dirtyIds = cache()->get("{$prefix}dirty_ids", []); + if (! is_array($dirtyIds)) { + $dirtyIds = []; + } + + // Safety cap: prevent unbounded memory growth on non-Redis stores. + // On Redis the SADD path above is always used instead. + if (count($dirtyIds) >= 50000) { + return false; // Signal overflow β€” caller will fall back to DB write + } + + if (! in_array($this->id, $dirtyIds, true)) { + $dirtyIds[] = $this->id; + cache()->forever("{$prefix}dirty_ids", $dirtyIds); + } + + return true; + }); + + // $registered is null (lock not acquired) or false (cap overflow) β€” fall back. + if (! $registered) { + $this->newQuery()->where('id', $this->id)->increment('total_visits', 1, + array_filter([ + 'unique_visits' => $isUnique ? DB::raw('unique_visits + 1') : null, + 'qr_scans' => $isQrScan ? DB::raw('qr_scans + 1') : null, + ]) + ); + + return; + } + } + + return; + } catch (\Throwable) { + // Cache backend failed β€” fall through to the queued DB job below + } + + // Safe fallback: dispatch an async job so no click is silently lost on cache failure. + $connection = config('filament-short-url.queue_connection', 'sync'); + dispatch((new IncrementVisitJob($this->id, $isUnique, $isQrScan))->onConnection($connection ?: 'sync')); + + return; + } + + $updates = []; + if ($isUnique) { + $updates['unique_visits'] = DB::raw('unique_visits + 1'); + } + if ($isQrScan) { + $updates['qr_scans'] = DB::raw('qr_scans + 1'); + } + + $this->newQuery() + ->where('id', $this->id) + ->increment('total_visits', 1, $updates); + + // Keep the real-time cache count incremented. + // We use add() + increment() instead of has() + increment() to avoid a TOCTOU + // race where two processes both see the key missing and both try to initialize it. + $cacheKey = "filament-short-url:visits:{$this->id}"; + try { + // add() only writes if the key does not exist yet (atomic on all stores) + if (! cache()->add($cacheKey, $this->total_visits + 1, 3600)) { + // Key already existed β€” just increment it + cache()->increment($cacheKey); + } + } catch (\Throwable) { + // Never let cache errors disrupt redirection or DB counters + } + } + + /** + * Get cached statistics for this short URL. + * + * @return array + */ + public function getCachedStats(?string $dateFrom = null, ?string $dateTo = null, array $filters = []): array + { + $dateFromClean = $dateFrom ? Carbon::parse($dateFrom)->toDateString() : null; + $dateToClean = $dateTo ? Carbon::parse($dateTo)->toDateString() : null; + + $filtersHash = md5(json_encode($filters)); + $memoKey = ($dateFromClean ?: 'all').'_'.($dateToClean ?: 'all').'_'.$filtersHash; + + if (array_key_exists($memoKey, $this->memoizedStats)) { + return $this->memoizedStats[$memoKey]; + } + + $cacheTtl = (int) config('filament-short-url.geo_ip.stats_cache_ttl', 300); + $cacheKey = "short_url_stats_{$this->id}_".($dateFromClean ?: 'all').'_'.($dateToClean ?: 'all').'_'.$filtersHash; + $this->registerCacheKey($cacheKey); + + if (! empty($filters)) { + return $this->memoizedStats[$memoKey] = cache()->remember($cacheKey, $cacheTtl, function () use ($dateFromClean, $dateToClean, $filters) { + $today = Carbon::today()->toDateString(); + $baseQuery = DB::table('short_url_visits') + ->where('short_url_id', $this->id) + ->where('is_bot', false) + ->where('is_proxy', false); + + if ($dateFromClean) { + $baseQuery->where('visited_at', '>=', $dateFromClean.' 00:00:00'); + } + if ($dateToClean) { + $baseQuery->where('visited_at', '<=', $dateToClean.' 23:59:59'); + } + + $this->applyStatsFilters($baseQuery, $filters); + + $summary = (clone $baseQuery) + ->selectRaw(' + COUNT(*) as total_visits, + COUNT(DISTINCT ip_hash) as unique_visits, + SUM(CASE WHEN is_qr_scan = 1 THEN 1 ELSE 0 END) as qr_scans + ') + ->first(); + + $totalVisits = (int) ($summary->total_visits ?? 0); + $uniqueVisitsCount = (int) ($summary->unique_visits ?? 0); + $qrScans = (int) ($summary->qr_scans ?? 0); + + $driver = DB::connection()->getDriverName(); + $dateExpression = match ($driver) { + 'sqlite' => "strftime('%Y-%m-%d', visited_at)", + 'pgsql' => "to_char(visited_at, 'YYYY-MM-DD')", + default => "DATE_FORMAT(visited_at, '%Y-%m-%d')", + }; + + $timelineRows = (clone $baseQuery) + ->select(DB::raw("{$dateExpression} as day"), DB::raw('COUNT(*) as cnt')) + ->groupBy('day') + ->orderBy('day', 'asc') + ->pluck('cnt', 'day') + ->toArray(); + + $visitsByDay = []; + $chartFrom = $dateFromClean ? Carbon::parse($dateFromClean) : now()->subDays(29)->startOfDay(); + $chartTo = $dateToClean ? Carbon::parse($dateToClean) : now()->endOfDay(); + $daysDiff = (int) $chartFrom->diffInDays($chartTo); + + if ($daysDiff > 90) { + $monthExpression = match ($driver) { + 'sqlite' => "strftime('%Y-%m', visited_at)", + 'pgsql' => "to_char(visited_at, 'YYYY-MM')", + default => "DATE_FORMAT(visited_at, '%Y-%m')", + }; + + $visitsByDay = (clone $baseQuery) + ->select(DB::raw("{$monthExpression} as month_bucket"), DB::raw('COUNT(*) as cnt')) + ->groupBy('month_bucket') + ->orderBy('month_bucket', 'asc') + ->pluck('cnt', 'month_bucket') + ->toArray(); + } else { + for ($i = $daysDiff; $i >= 0; $i--) { + $d = (clone $chartTo)->subDays($i)->format('Y-m-d'); + $visitsByDay[$d] = 0; + } + foreach ($timelineRows as $day => $count) { + $formattedDay = Carbon::parse($day)->format('Y-m-d'); + if (isset($visitsByDay[$formattedDay])) { + $visitsByDay[$formattedDay] = (int) $count; + } + } + } + + $visitsToday = $visitsByDay[$today] ?? 0; + $startOfWeek = now()->startOfWeek()->toDateString(); + $startOfMonth = now()->startOfMonth()->toDateString(); + + $visitsThisWeek = 0; + $visitsThisMonth = 0; + foreach ($visitsByDay as $date => $cnt) { + if ($date >= $startOfWeek) { + $visitsThisWeek += $cnt; + } + if ($date >= $startOfMonth) { + $visitsThisMonth += $cnt; + } + } + + return [ + 'totalVisits' => $totalVisits, + 'uniqueVisits' => $uniqueVisitsCount, + 'visitsToday' => $visitsToday, + 'visitsThisWeek' => $visitsThisWeek, + 'visitsThisMonth' => $visitsThisMonth, + 'visitsByDay' => $visitsByDay, + 'visitsByCountry' => $this->getRawVisitsDistribution($baseQuery, 'country_code'), + 'visitsByCity' => $this->getRawVisitsCityDistribution($baseQuery), + 'visitsByDevice' => $this->getRawVisitsDistribution($baseQuery, 'device_type'), + 'visitsByBrowser' => $this->getRawVisitsDistribution($baseQuery, 'browser'), + 'visitsByBrowserVersion' => $this->getRawVisitsVersionDistribution($baseQuery, 'browser', 'browser_version'), + 'visitsByOs' => $this->getRawVisitsDistribution($baseQuery, 'operating_system'), + 'visitsByOsVersion' => $this->getRawVisitsVersionDistribution($baseQuery, 'operating_system', 'operating_system_version'), + 'visitsByReferer' => $this->getRawVisitsRefererDistribution($baseQuery), + 'utmSources' => $this->getRawVisitsDistribution($baseQuery, 'utm_source'), + 'utmMediums' => $this->getRawVisitsDistribution($baseQuery, 'utm_medium'), + 'utmCampaigns' => $this->getRawVisitsDistribution($baseQuery, 'utm_campaign'), + 'utmTerms' => $this->getRawVisitsDistribution($baseQuery, 'utm_term'), + 'utmContents' => $this->getRawVisitsDistribution($baseQuery, 'utm_content'), + 'qrScans' => $qrScans, + 'visitsByLanguage' => $this->getRawVisitsDistribution($baseQuery, 'browser_language'), + 'visitsByVariant' => $this->getRawVisitsDistribution($baseQuery, 'selected_variant'), + ]; + }); + } + + return $this->memoizedStats[$memoKey] = cache()->remember($cacheKey, $cacheTtl, function () use ($dateFromClean, $dateToClean) { + $today = Carbon::today()->toDateString(); + + $dailyQuery = $this->dailyStats()->where('date', '<', $today); + if ($dateFromClean) { + $dailyQuery->where('date', '>=', $dateFromClean); + } + if ($dateToClean && $dateToClean < $today) { + $dailyQuery->where('date', '<=', $dateToClean); + } + $dailyStatsRows = $dailyQuery->toBase()->get(); + + $includeToday = ($dateToClean === null || $dateToClean >= $today); + if ($dateFromClean && $dateFromClean > $today) { + $includeToday = false; + } + + $todayRawStats = []; + if ($includeToday) { + $baseTodayQuery = DB::table('short_url_visits') + ->where('short_url_id', $this->id) + ->where('visited_at', '>=', $today.' 00:00:00') + ->where('is_bot', false) + ->where('is_proxy', false); + + $todaySummary = (clone $baseTodayQuery) + ->selectRaw(' + COUNT(*) as total_visits, + COUNT(DISTINCT ip_hash) as unique_visits, + SUM(CASE WHEN is_qr_scan = 1 THEN 1 ELSE 0 END) as qr_scans + ') + ->first(); + + $todayRawStats = [ + 'totalVisits' => (int) ($todaySummary->total_visits ?? 0), + 'uniqueVisits' => (int) ($todaySummary->unique_visits ?? 0), + 'qrScans' => (int) ($todaySummary->qr_scans ?? 0), + 'visitsByCountry' => $this->getRawVisitsDistribution($baseTodayQuery, 'country_code'), + 'visitsByCity' => $this->getRawVisitsCityDistribution($baseTodayQuery), + 'visitsByDevice' => $this->getRawVisitsDistribution($baseTodayQuery, 'device_type'), + 'visitsByBrowser' => $this->getRawVisitsDistribution($baseTodayQuery, 'browser'), + 'visitsByOs' => $this->getRawVisitsDistribution($baseTodayQuery, 'operating_system'), + 'visitsByReferer' => $this->getRawVisitsRefererDistribution($baseTodayQuery), + 'utmSources' => $this->getRawVisitsDistribution($baseTodayQuery, 'utm_source'), + 'utmMediums' => $this->getRawVisitsDistribution($baseTodayQuery, 'utm_medium'), + 'utmCampaigns' => $this->getRawVisitsDistribution($baseTodayQuery, 'utm_campaign'), + 'visitsByLanguage' => $this->getRawVisitsDistribution($baseTodayQuery, 'browser_language'), + 'visitsByVariant' => $this->getRawVisitsDistribution($baseTodayQuery, 'selected_variant'), + ]; + } + + $mergeStats = function (array $base, ?array $additional): array { + if (empty($additional)) { + return $base; + } + foreach ($additional as $key => $val) { + $base[$key] = ($base[$key] ?? 0) + $val; + } + + return $base; + }; + + $totalVisits = 0; + $uniqueVisitsCount = 0; + $visitsToday = $todayRawStats['totalVisits'] ?? 0; + $visitsThisWeek = 0; + $visitsThisMonth = 0; + $qrScans = 0; + + $visitsByCountry = []; + $visitsByCity = []; + $visitsByDevice = []; + $visitsByBrowser = []; + $visitsByOs = []; + $visitsByReferer = []; + $utmSources = []; + $utmMediums = []; + $utmCampaigns = []; + $visitsByLanguage = []; + $visitsByVariant = []; + + $startOfWeek = now()->startOfWeek()->toDateString(); + $startOfMonth = now()->startOfMonth()->toDateString(); + + foreach ($dailyStatsRows as $row) { + $totalVisits += $row->visits_count; + $uniqueVisitsCount += $row->unique_visits_count; + $qrScans += $row->qr_visits_count ?? 0; + + $rowDate = is_string($row->date) ? $row->date : Carbon::parse($row->date)->toDateString(); + if ($rowDate >= $startOfWeek) { + $visitsThisWeek += $row->visits_count; + } + if ($rowDate >= $startOfMonth) { + $visitsThisMonth += $row->visits_count; + } + + $visitsByCountry = $mergeStats($visitsByCountry, is_string($row->country_stats) ? json_decode($row->country_stats, true) : (array) $row->country_stats); + $visitsByCity = $mergeStats($visitsByCity, is_string($row->city_stats) ? json_decode($row->city_stats, true) : (array) $row->city_stats); + $visitsByDevice = $mergeStats($visitsByDevice, is_string($row->device_stats) ? json_decode($row->device_stats, true) : (array) $row->device_stats); + $visitsByBrowser = $mergeStats($visitsByBrowser, is_string($row->browser_stats) ? json_decode($row->browser_stats, true) : (array) $row->browser_stats); + $visitsByOs = $mergeStats($visitsByOs, is_string($row->os_stats) ? json_decode($row->os_stats, true) : (array) $row->os_stats); + + if (! empty($row->referer_stats)) { + $refererStats = is_string($row->referer_stats) ? json_decode($row->referer_stats, true) : (array) $row->referer_stats; + $groupedHistoricalReferer = []; + foreach ($refererStats as $host => $cnt) { + $cat = static::resolveRefererCategory($host); + $groupedHistoricalReferer[$cat] = ($groupedHistoricalReferer[$cat] ?? 0) + $cnt; + } + $visitsByReferer = $mergeStats($visitsByReferer, $groupedHistoricalReferer); + } + + $utmSources = $mergeStats($utmSources, is_string($row->utm_source_stats) ? json_decode($row->utm_source_stats, true) : (array) $row->utm_source_stats); + $utmMediums = $mergeStats($utmMediums, is_string($row->utm_medium_stats) ? json_decode($row->utm_medium_stats, true) : (array) $row->utm_medium_stats); + $utmCampaigns = $mergeStats($utmCampaigns, is_string($row->utm_campaign_stats) ? json_decode($row->utm_campaign_stats, true) : (array) $row->utm_campaign_stats); + $visitsByLanguage = $mergeStats($visitsByLanguage, is_string($row->language_stats) ? json_decode($row->language_stats, true) : (array) $row->language_stats); + $visitsByVariant = $mergeStats($visitsByVariant, is_string($row->variant_stats) ? json_decode($row->variant_stats, true) : (array) $row->variant_stats); + } + + if ($includeToday && ! empty($todayRawStats)) { + $totalVisits += $todayRawStats['totalVisits']; + $uniqueVisitsCount += $todayRawStats['uniqueVisits']; + $qrScans += $todayRawStats['qrScans']; + + $visitsThisWeek += $todayRawStats['totalVisits']; + $visitsThisMonth += $todayRawStats['totalVisits']; + + $visitsByCountry = $mergeStats($visitsByCountry, $todayRawStats['visitsByCountry']); + $visitsByCity = $mergeStats($visitsByCity, $todayRawStats['visitsByCity']); + $visitsByDevice = $mergeStats($visitsByDevice, $todayRawStats['visitsByDevice']); + $visitsByBrowser = $mergeStats($visitsByBrowser, $todayRawStats['visitsByBrowser']); + $visitsByOs = $mergeStats($visitsByOs, $todayRawStats['visitsByOs']); + $visitsByReferer = $mergeStats($visitsByReferer, $todayRawStats['visitsByReferer']); + $utmSources = $mergeStats($utmSources, $todayRawStats['utmSources']); + $utmMediums = $mergeStats($utmMediums, $todayRawStats['utmMediums']); + $utmCampaigns = $mergeStats($utmCampaigns, $todayRawStats['utmCampaigns']); + $visitsByLanguage = $mergeStats($visitsByLanguage, $todayRawStats['visitsByLanguage']); + $visitsByVariant = $mergeStats($visitsByVariant, $todayRawStats['visitsByVariant']); + } + + $visitsByDay = []; + $chartFrom = $dateFromClean ? Carbon::parse($dateFromClean) : now()->subDays(29)->startOfDay(); + $chartTo = $dateToClean ? Carbon::parse($dateToClean) : now()->endOfDay(); + $daysDiff = (int) $chartFrom->diffInDays($chartTo); + + if ($daysDiff > 90) { + foreach ($dailyStatsRows as $row) { + $m = is_string($row->date) ? substr($row->date, 0, 7) : Carbon::parse($row->date)->format('Y-m'); + $visitsByDay[$m] = ($visitsByDay[$m] ?? 0) + $row->visits_count; + } + if ($includeToday) { + $mToday = Carbon::parse($today)->format('Y-m'); + $visitsByDay[$mToday] = ($visitsByDay[$mToday] ?? 0) + ($todayRawStats['totalVisits'] ?? 0); + } + } else { + for ($i = $daysDiff; $i >= 0; $i--) { + $d = (clone $chartTo)->subDays($i)->format('Y-m-d'); + $visitsByDay[$d] = 0; + } + foreach ($dailyStatsRows as $row) { + $d = is_string($row->date) ? $row->date : Carbon::parse($row->date)->format('Y-m-d'); + if (isset($visitsByDay[$d])) { + $visitsByDay[$d] = $row->visits_count; + } + } + if ($includeToday && isset($visitsByDay[$today])) { + $visitsByDay[$today] = $todayRawStats['totalVisits'] ?? 0; + } + } + + arsort($visitsByCountry); + arsort($visitsByCity); + arsort($visitsByDevice); + arsort($visitsByBrowser); + arsort($visitsByOs); + arsort($visitsByReferer); + arsort($utmSources); + arsort($utmMediums); + arsort($utmCampaigns); + arsort($visitsByLanguage); + arsort($visitsByVariant); + + $visitsByBrowserVersion = $this->getRawVisitsVersionDistribution( + DB::table('short_url_visits') + ->where('short_url_id', $this->id) + ->where('is_bot', false) + ->where('is_proxy', false) + ->when($dateFromClean, fn ($q) => $q->where('visited_at', '>=', $dateFromClean.' 00:00:00')) + ->when($dateToClean, fn ($q) => $q->where('visited_at', '<=', $dateToClean.' 23:59:59')), + 'browser', 'browser_version' + ); + + $visitsByOsVersion = $this->getRawVisitsVersionDistribution( + DB::table('short_url_visits') + ->where('short_url_id', $this->id) + ->where('is_bot', false) + ->where('is_proxy', false) + ->when($dateFromClean, fn ($q) => $q->where('visited_at', '>=', $dateFromClean.' 00:00:00')) + ->when($dateToClean, fn ($q) => $q->where('visited_at', '<=', $dateToClean.' 23:59:59')), + 'operating_system', 'operating_system_version' + ); + + $utmTerms = $this->getRawVisitsDistribution( + DB::table('short_url_visits') + ->where('short_url_id', $this->id) + ->where('is_bot', false) + ->where('is_proxy', false) + ->when($dateFromClean, fn ($q) => $q->where('visited_at', '>=', $dateFromClean.' 00:00:00')) + ->when($dateToClean, fn ($q) => $q->where('visited_at', '<=', $dateToClean.' 23:59:59')), + 'utm_term' + ); + + $utmContents = $this->getRawVisitsDistribution( + DB::table('short_url_visits') + ->where('short_url_id', $this->id) + ->where('is_bot', false) + ->where('is_proxy', false) + ->when($dateFromClean, fn ($q) => $q->where('visited_at', '>=', $dateFromClean.' 00:00:00')) + ->when($dateToClean, fn ($q) => $q->where('visited_at', '<=', $dateToClean.' 23:59:59')), + 'utm_content' + ); + + return [ + 'totalVisits' => $totalVisits, + 'uniqueVisits' => $uniqueVisitsCount, + 'visitsToday' => $visitsToday, + 'visitsThisWeek' => $visitsThisWeek, + 'visitsThisMonth' => $visitsThisMonth, + 'visitsByDay' => $visitsByDay, + 'visitsByCountry' => array_slice($visitsByCountry, 0, 10, true), + 'visitsByCity' => array_slice($visitsByCity, 0, 10, true), + 'visitsByDevice' => $visitsByDevice, + 'visitsByBrowser' => array_slice($visitsByBrowser, 0, 8, true), + 'visitsByBrowserVersion' => $visitsByBrowserVersion, + 'visitsByOs' => array_slice($visitsByOs, 0, 8, true), + 'visitsByOsVersion' => $visitsByOsVersion, + 'visitsByReferer' => array_slice($visitsByReferer, 0, 10, true), + 'utmSources' => array_slice($utmSources, 0, 8, true), + 'utmMediums' => array_slice($utmMediums, 0, 8, true), + 'utmCampaigns' => array_slice($utmCampaigns, 0, 8, true), + 'utmTerms' => array_slice($utmTerms, 0, 8, true), + 'utmContents' => array_slice($utmContents, 0, 8, true), + 'qrScans' => $qrScans, + 'visitsByLanguage' => array_slice($visitsByLanguage, 0, 10, true), + 'visitsByVariant' => $visitsByVariant, + ]; + }); + } + + /** + * Preload all buffered clicks in a single batch query for the entire request. + */ + protected static function loadAllBufferedVisits(): void + { + if (static::$bufferedTotalVisits !== null) { + return; + } + + static::$bufferedTotalVisits = []; + static::$bufferedUniqueVisits = []; + static::$bufferedQrScans = []; + + if (! config('filament-short-url.counter_buffering.enabled', false)) { + return; + } + + $prefix = config('filament-short-url.counter_buffering.cache_key_prefix', 'filament-short-url:buffer:'); + $dirtyKey = "{$prefix}dirty_ids"; + + // 1. Fetch the list of dirty IDs (URLs with pending buffered clicks) in one query + $dirtyIds = []; + try { + if (cache()->getDefaultDriver() === 'redis' && class_exists(Redis::class)) { + $dirtyIds = Redis::smembers($dirtyKey); + } else { + $dirtyIds = cache()->get($dirtyKey, []); + } + } catch (\Throwable) { + // Fallback + } + + if (empty($dirtyIds)) { + return; + } + + $dirtyIds = array_unique(array_filter((array) $dirtyIds)); + + // 2. Build array of keys to fetch in a single cache store read + $totalKeys = []; + $uniqueKeys = []; + $qrKeys = []; + foreach ($dirtyIds as $id) { + $totalKeys[$id] = "{$prefix}total:{$id}"; + $uniqueKeys[$id] = "{$prefix}unique:{$id}"; + $qrKeys[$id] = "{$prefix}qr:{$id}"; + } + + try { + // Cache::many() is highly optimized (e.g. 1 database query for database store, or 1 MGET for Redis) + $totals = cache()->many(array_values($totalKeys)); + $uniques = cache()->many(array_values($uniqueKeys)); + $qrs = cache()->many(array_values($qrKeys)); + + foreach ($totalKeys as $id => $key) { + static::$bufferedTotalVisits[$id] = (int) ($totals[$key] ?? 0); + } + foreach ($uniqueKeys as $id => $key) { + static::$bufferedUniqueVisits[$id] = (int) ($uniques[$key] ?? 0); + } + foreach ($qrKeys as $id => $key) { + static::$bufferedQrScans[$id] = (int) ($qrs[$key] ?? 0); + } + } catch (\Throwable) { + // Fallback + } + } + + /** + * Get the total visits count, merging the database value with any buffered clicks in cache. + */ + public function getTotalVisitsAttribute(): int + { + $dbValue = $this->attributes['total_visits'] ?? 0; + + if (! config('filament-short-url.counter_buffering.enabled', false)) { + return $dbValue; + } + + static::loadAllBufferedVisits(); + + $buffered = static::$bufferedTotalVisits[$this->id] ?? 0; + + return $dbValue + $buffered; + } + + /** + * Get the unique visits count, merging the database value with any buffered clicks in cache. + */ + public function getUniqueVisitsAttribute(): int + { + $dbValue = $this->attributes['unique_visits'] ?? 0; + + if (! config('filament-short-url.counter_buffering.enabled', false)) { + return $dbValue; + } + + static::loadAllBufferedVisits(); + + $buffered = static::$bufferedUniqueVisits[$this->id] ?? 0; + + return $dbValue + $buffered; + } + + /** + * Get the QR scans count, merging the database value with any buffered clicks in cache. + */ + public function getQrScansAttribute(): int + { + $dbValue = $this->attributes['qr_scans'] ?? 0; + + if (! config('filament-short-url.counter_buffering.enabled', false)) { + return $dbValue; + } + + static::loadAllBufferedVisits(); + + $buffered = static::$bufferedQrScans[$this->id] ?? 0; + + return $dbValue + $buffered; + } + + /** + * Clear stats and chart caches for this short URL. + */ + public function clearStatsCache(?string $dateFrom = null, ?string $dateTo = null, array $filters = []): void + { + // 1. Fetch tracked keys and forget them + try { + $trackerKey = "short_url_cache_keys_{$this->id}"; + $keys = cache()->get($trackerKey, []); + if (is_array($keys)) { + foreach ($keys as $k) { + cache()->forget($k); + } + } + cache()->forget($trackerKey); + } catch (\Throwable $e) { + // ignore + } + + // 2. Also keep the hardcoded fallback clears just in case + $dateFromClean = $dateFrom ? Carbon::parse($dateFrom)->toDateString() : null; + $dateToClean = $dateTo ? Carbon::parse($dateTo)->toDateString() : null; + $filtersHash = md5(json_encode($filters)); + + $cacheKey = "short_url_stats_{$this->id}_".($dateFromClean ?: 'all').'_'.($dateToClean ?: 'all').'_'.$filtersHash; + cache()->forget($cacheKey); + + $baseCacheKey = "short_url_stats_{$this->id}_".($dateFromClean ?: 'all').'_'.($dateToClean ?: 'all').'_'.md5(json_encode([])); + cache()->forget($baseCacheKey); + + foreach (['hourly', 'daily', 'weekly', 'monthly'] as $granularity) { + foreach (['total', 'unique', 'qr'] as $metric) { + $chartCacheKey = "short_url_chart_data_{$this->id}_".($dateFromClean ?: 'all').'_'.($dateToClean ?: 'all').'_'.$granularity.'_'.$filtersHash.'_'.$metric; + cache()->forget($chartCacheKey); + + $baseChartCacheKey = "short_url_chart_data_{$this->id}_".($dateFromClean ?: 'all').'_'.($dateToClean ?: 'all').'_'.$granularity.'_'.md5(json_encode([])).'_'.$metric; + cache()->forget($baseChartCacheKey); + } + } + + $this->memoizedStats = []; + } + + /** + * Apply active filter array to a visits query. + */ + public function applyStatsFilters($query, array $filters): void + { + if (empty($filters)) { + return; + } + + foreach ($filters as $key => $value) { + if ($value === null || $value === '') { + continue; + } + + switch ($key) { + case 'country': + case 'country_code': + $query->where('country_code', $value); + break; + case 'city': + if (preg_match('/^(.*?)\s*\((.*?)\)$/', $value, $matches)) { + $query->where('city', trim($matches[1])) + ->where('country_code', trim($matches[2])); + } else { + $query->where('city', $value); + } + break; + case 'browser': + $query->where('browser', $value); + break; + case 'operating_system': + $query->where('operating_system', $value); + break; + case 'browser_language': + $query->where('browser_language', $value); + break; + case 'device': + case 'device_type': + $query->where('device_type', $value); + break; + case 'referrer_category': + if ($value === 'Direct / Email / SMS') { + $query->where(fn ($q) => $q->whereNull('referer_host')->orWhere('referer_host', '')->orWhere('referer_host', 'direct')); + } elseif ($value === 'Twitter / X') { + $query->where(fn ($q) => $q->whereIn('referer_host', ['t.co', 'twitter.com', 'x.com']) + ->orWhere('referer_host', 'like', '%.t.co') + ->orWhere('referer_host', 'like', '%.twitter.com') + ->orWhere('referer_host', 'like', '%.x.com') + ); + } elseif ($value === 'Facebook') { + $query->where(fn ($q) => $q->whereIn('referer_host', ['facebook.com', 'm.facebook.com', 'l.facebook.com', 'lm.facebook.com']) + ->orWhere('referer_host', 'like', '%.facebook.com') + ); + } elseif ($value === 'Instagram') { + $query->where(fn ($q) => $q->whereIn('referer_host', ['instagram.com', 'l.instagram.com']) + ->orWhere('referer_host', 'like', '%.instagram.com') + ); + } elseif ($value === 'LinkedIn') { + $query->where(fn ($q) => $q->whereIn('referer_host', ['linkedin.com', 'lnkd.in']) + ->orWhere('referer_host', 'like', '%.linkedin.com') + ->orWhere('referer_host', 'like', '%.lnkd.in') + ); + } elseif ($value === 'YouTube') { + $query->where(fn ($q) => $q->whereIn('referer_host', ['youtube.com', 'youtu.be']) + ->orWhere('referer_host', 'like', '%.youtube.com') + ->orWhere('referer_host', 'like', '%.youtu.be') + ); + } elseif ($value === 'Pinterest') { + $query->where(fn ($q) => $q->whereIn('referer_host', ['pinterest.com', 'pin.it']) + ->orWhere('referer_host', 'like', '%.pinterest.com') + ->orWhere('referer_host', 'like', '%.pin.it') + ); + } elseif ($value === 'Google') { + $query->where('referer_host', 'like', '%google.%'); + } elseif ($value === 'Bing') { + $query->where('referer_host', 'like', '%bing.%'); + } elseif ($value === 'Yahoo') { + $query->where('referer_host', 'like', '%yahoo.%'); + } elseif ($value === 'DuckDuckGo') { + $query->where('referer_host', 'like', '%duckduckgo.%'); + } else { + $query->where(fn ($q) => $q->where('referer_host', $value)->orWhere('referer_host', 'www.'.$value)); + } + break; + case 'utm_source': + $query->where('utm_source', $value); + break; + case 'utm_medium': + $query->where('utm_medium', $value); + break; + case 'utm_campaign': + $query->where('utm_campaign', $value); + break; + case 'selected_variant': + $query->where('selected_variant', $value); + break; + } + } + } + + /** + * Group by and count a column on the raw visits query, applying filters. + * + * @return array + */ + protected function getRawVisitsDistribution($baseQuery, string $column, int $limit = 10): array + { + return (clone $baseQuery) + ->select($column, DB::raw('COUNT(*) as cnt')) + ->whereNotNull($column) + ->where($column, '!=', '') + ->groupBy($column) + ->orderByDesc('cnt') + ->limit($limit) + ->pluck('cnt', $column) + ->toArray(); + } + + /** + * Group by and count city/country_code on the raw visits query. + * + * @return array + */ + protected function getRawVisitsCityDistribution($baseQuery, int $limit = 10): array + { + $rows = (clone $baseQuery) + ->select('city', 'country_code', DB::raw('COUNT(*) as cnt')) + ->whereNotNull('city') + ->where('city', '!=', '') + ->groupBy('city', 'country_code') + ->orderByDesc('cnt') + ->limit($limit) + ->get(); + + $results = []; + foreach ($rows as $row) { + $key = "{$row->city} ({$row->country_code})"; + $results[$key] = (int) $row->cnt; + } + + return $results; + } + + /** + * Group by and count referer category on the raw visits query. + * + * @return array + */ + protected function getRawVisitsRefererDistribution($baseQuery, int $limit = 10): array + { + $rows = (clone $baseQuery) + ->select('referer_host', DB::raw('COUNT(*) as cnt')) + ->groupBy('referer_host') + ->orderByDesc('cnt') + ->limit($limit) + ->get(); + + $results = []; + foreach ($rows as $row) { + $host = $row->referer_host ?: 'Direct'; + $category = static::resolveRefererCategory($host); + $results[$category] = ($results[$category] ?? 0) + (int) $row->cnt; + } + + arsort($results); + + return array_slice($results, 0, $limit, true); + } + + /** + * Register a cache key for stats tracking. + */ + public function registerCacheKey(string $key): void + { + try { + $trackerKey = "short_url_cache_keys_{$this->id}"; + $keys = cache()->get($trackerKey, []); + if (! is_array($keys)) { + $keys = []; + } + if (! in_array($key, $keys)) { + $keys[] = $key; + cache()->put($trackerKey, $keys, 86400 * 30); // Store for 30 days + } + } catch (\Throwable $e) { + // Ignore cache registration failures + } + } + + /** + * Group by name and version on the raw visits query, applying filters. + * + * @return array> + */ + protected function getRawVisitsVersionDistribution($baseQuery, string $nameColumn, string $versionColumn, int $limit = 5): array + { + $rows = (clone $baseQuery) + ->select($nameColumn, $versionColumn, DB::raw('COUNT(*) as cnt')) + ->whereNotNull($nameColumn) + ->where($nameColumn, '!=', '') + ->groupBy($nameColumn, $versionColumn) + ->orderByDesc('cnt') + ->get(); + + $results = []; + foreach ($rows as $row) { + $name = $row->$nameColumn; + $version = $row->$versionColumn ?: 'Unknown'; + if (! isset($results[$name])) { + $results[$name] = []; + } + if (count($results[$name]) < $limit) { + $results[$name][$version] = (int) $row->cnt; + } + } + + return $results; + } + + /** + * Map a raw host to a clean Referer Category name. + */ + public static function resolveRefererCategory(?string $host): string + { + if (empty($host) || strtolower($host) === 'direct') { + return 'Direct / Email / SMS'; + } + + $host = strtolower(trim($host)); + + $map = [ + 't.co' => 'Twitter / X', + 'twitter.com' => 'Twitter / X', + 'x.com' => 'Twitter / X', + 'facebook.com' => 'Facebook', + 'm.facebook.com' => 'Facebook', + 'l.facebook.com' => 'Facebook', + 'lm.facebook.com' => 'Facebook', + 'instagram.com' => 'Instagram', + 'l.instagram.com' => 'Instagram', + 'linkedin.com' => 'LinkedIn', + 'lnkd.in' => 'LinkedIn', + 'youtube.com' => 'YouTube', + 'youtu.be' => 'YouTube', + 'tiktok.com' => 'TikTok', + 'reddit.com' => 'Reddit', + 'pinterest.com' => 'Pinterest', + 'pin.it' => 'Pinterest', + 'google.com' => 'Google', + 'google.pl' => 'Google', + 'google.co.uk' => 'Google', + 'google.de' => 'Google', + 'google.fr' => 'Google', + ]; + + foreach ($map as $domain => $name) { + if ($host === $domain || str_ends_with($host, '.'.$domain)) { + return $name; + } + } + + if (str_contains($host, 'google.')) { + return 'Google'; + } + if (str_contains($host, 'bing.')) { + return 'Bing'; + } + if (str_contains($host, 'yahoo.')) { + return 'Yahoo'; + } + if (str_contains($host, 'duckduckgo.')) { + return 'DuckDuckGo'; + } + + if (str_starts_with($host, 'www.')) { + return substr($host, 4); + } + + return $host; + } +} diff --git a/src/Models/Concerns/HasTargeting.php b/src/Models/Concerns/HasTargeting.php new file mode 100644 index 0000000..a2f4101 --- /dev/null +++ b/src/Models/Concerns/HasTargeting.php @@ -0,0 +1,266 @@ +targeting_rules; + + // Legacy rotation support + if (is_array($rules) && isset($rules['type']) && $rules['type'] === 'rotation') { + return $this->resolveSplitTarget($rules['rotation'] ?? [], $this->destination_url ?? ''); + } + + // Map legacy targeting rules format to new format on the fly + if (is_array($rules) && isset($rules['type'])) { + $rules = $this->mapLegacyTargetingRules($rules); + } + + // Evaluate multi-filter rule engine first + if (is_array($rules) && ! empty($rules)) { + // Lazy-loaded request properties for maximum performance + $deviceType = null; + $platformOs = null; + $countryCode = null; + $browserLanguages = null; + + foreach ($rules as $rule) { + $filters = $rule['filters'] ?? []; + if (empty($filters)) { + continue; + } + + $matchType = $rule['match'] ?? 'or'; + $ruleMatches = $matchType === 'and'; // 'and' defaults to true, 'or' defaults to false + + foreach ($filters as $filter) { + $filterType = $filter['type'] ?? ''; + $filterData = $filter['data'] ?? []; + $filterMatches = false; + + if ($filterType === 'device') { + if ($deviceType === null) { + $deviceType = app(UserAgentParser::class)->getDeviceType($request->userAgent() ?? ''); + } + $filterMatches = in_array($deviceType, $filterData['devices'] ?? []); + } elseif ($filterType === 'platform') { + if ($platformOs === null) { + $rawOs = app(UserAgentParser::class)->getOs($request->userAgent() ?? '') ?? ''; + $platformOs = match (true) { + stripos($rawOs, 'Windows') !== false => 'windows', + stripos($rawOs, 'Fire OS') !== false => 'fire_os', + stripos($rawOs, 'iOS') !== false || stripos($rawOs, 'iPad') !== false => 'ios', + stripos($rawOs, 'Mac') !== false => 'mac', + stripos($rawOs, 'Android') !== false => 'android', + stripos($rawOs, 'Linux') !== false => 'linux', + default => strtolower($rawOs), + }; + } + $filterMatches = in_array($platformOs, $filterData['platforms'] ?? []); + } elseif ($filterType === 'country') { + if ($countryCode === null) { + $countryCode = ClientIpExtractor::getCountryCode($request); + if (! $countryCode) { + $ip = ClientIpExtractor::getIp($request); + $geo = app(GeoIpService::class)->resolve($ip); + $countryCode = $geo['country_code'] ?? ''; + } + $countryCode = strtoupper(trim($countryCode)); + } + $filterMatches = in_array($countryCode, array_map('strtoupper', $filterData['countries'] ?? [])); + } elseif ($filterType === 'language') { + if ($browserLanguages === null) { + $browserLanguages = array_map(function ($lang) { + return strtolower(trim(str_replace('_', '-', $lang))); + }, $request->getLanguages()); + } + + $filterLangs = array_map('strtolower', $filterData['languages'] ?? []); + + // Pass 1: Exact match + foreach ($browserLanguages as $browserLang) { + if (in_array($browserLang, $filterLangs)) { + $filterMatches = true; + break; + } + } + + // Pass 2: Fallback prefix match + if (! $filterMatches) { + foreach ($browserLanguages as $browserLang) { + $primaryLang = explode('-', $browserLang)[0]; + if (in_array($primaryLang, $filterLangs)) { + $filterMatches = true; + break; + } + } + } + } + + if ($matchType === 'and') { + if (! $filterMatches) { + $ruleMatches = false; + break; // fail fast + } + } else { // 'or' + if ($filterMatches) { + $ruleMatches = true; + break; // succeed fast + } + } + } + + if ($ruleMatches) { + $destType = $rule['destination_type'] ?? 'single'; + if ($destType === 'split') { + return $this->resolveSplitTarget($rule['variants'] ?? [], $this->destination_url ?? ''); + } + + if (! empty($rule['url'])) { + return $rule['url']; + } + } + } + } + + // Fallback to default destination URL + if (($this->destination_type ?? 'single') === 'split') { + return $this->resolveSplitTarget($this->rotation_variants ?? [], $this->destination_url ?? ''); + } + + return $this->destination_url ?? ''; + } + + /** + * Resolve destination URL from a split testing configuration. + */ + public function resolveSplitTarget(array $variants, string $fallbackUrl): string + { + if (empty($variants)) { + return $fallbackUrl; + } + + $totalWeight = array_sum(array_column($variants, 'weight')); + if ($totalWeight <= 0) { + $selected = $variants[0]; + } else { + $rand = mt_rand(1, $totalWeight); + $currentWeight = 0; + $selected = $variants[0]; + foreach ($variants as $variant) { + $currentWeight += (int) ($variant['weight'] ?? 0); + if ($rand <= $currentWeight) { + $selected = $variant; + break; + } + } + } + + $selectedUrl = $selected['url'] ?? $fallbackUrl; + $selectedLabel = $selected['label'] ?? $selectedUrl; + + // Share the selected variant with the application container so the tracker can access it + app()->instance('resolved_ab_variant', $selectedLabel); + + return $selectedUrl; + } + + /** + * Map legacy targeting rules format to the new multi-filter engine format. + */ + public function mapLegacyTargetingRules(array $state): array + { + $type = $state['type'] ?? ''; + $newRules = []; + + if ($type === 'device') { + $devices = $state['device'] ?? []; + $mobileUrl = $devices['mobile'] ?? $devices['ios'] ?? null; + if ($mobileUrl) { + $newRules[] = [ + 'match' => 'or', + 'destination_type' => 'single', + 'url' => $mobileUrl, + 'filters' => [ + [ + 'type' => 'device', + 'data' => ['devices' => ['mobile']], + ], + ], + ]; + } + $tabletUrl = $devices['tablet'] ?? $devices['android'] ?? null; + if ($tabletUrl) { + $newRules[] = [ + 'match' => 'or', + 'destination_type' => 'single', + 'url' => $tabletUrl, + 'filters' => [ + [ + 'type' => 'device', + 'data' => ['devices' => ['tablet']], + ], + ], + ]; + } + $desktopUrl = $devices['desktop'] ?? null; + if ($desktopUrl) { + $newRules[] = [ + 'match' => 'or', + 'destination_type' => 'single', + 'url' => $desktopUrl, + 'filters' => [ + [ + 'type' => 'device', + 'data' => ['devices' => ['desktop']], + ], + ], + ]; + } + } elseif ($type === 'geo') { + foreach ($state['geo'] ?? [] as $geoRule) { + if (! empty($geoRule['url']) && ! empty($geoRule['country_code'])) { + $newRules[] = [ + 'match' => 'or', + 'destination_type' => 'single', + 'url' => $geoRule['url'], + 'filters' => [ + [ + 'type' => 'country', + 'data' => ['countries' => [strtoupper($geoRule['country_code'])]], + ], + ], + ]; + } + } + } elseif ($type === 'language') { + foreach ($state['language'] ?? [] as $langRule) { + if (! empty($langRule['url']) && ! empty($langRule['language_code'])) { + $newRules[] = [ + 'match' => 'or', + 'destination_type' => 'single', + 'url' => $langRule['url'], + 'filters' => [ + [ + 'type' => 'language', + 'data' => ['languages' => [strtolower($langRule['language_code'])]], + ], + ], + ]; + } + } + } + + return $newRules; + } +} diff --git a/src/Models/ShortUrl.php b/src/Models/ShortUrl.php index ceed4a7..28fe2de 100644 --- a/src/Models/ShortUrl.php +++ b/src/Models/ShortUrl.php @@ -8,23 +8,19 @@ namespace Bjanczak\FilamentShortUrl\Models; +use App\Models\User; use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlGlobalOverview; -use Bjanczak\FilamentShortUrl\Jobs\IncrementVisitJob; -use Bjanczak\FilamentShortUrl\Services\ClientIpExtractor; -use Bjanczak\FilamentShortUrl\Services\GeoIpService; use Bjanczak\FilamentShortUrl\Services\ShortUrlBuilder; use Bjanczak\FilamentShortUrl\Services\ShortUrlService; -use Bjanczak\FilamentShortUrl\Services\UserAgentParser; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; -use Illuminate\Http\Request; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Redis; use Illuminate\Support\Facades\Storage; /** @@ -56,11 +52,14 @@ use Illuminate\Support\Facades\Storage; */ class ShortUrl extends Model { + use Concerns\HasStats; + use Concerns\HasTargeting; use HasFactory; protected $table = 'short_urls'; protected $fillable = [ + 'user_id', 'destination_url', 'url_key', 'notes', @@ -93,10 +92,15 @@ class ShortUrl extends Model 'qr_logo', 'qr_scans', 'auto_open_app_mobile', + 'destination_type', + 'rotation_variants', + 'custom_domain_id', ]; /** @var array */ protected $casts = [ + 'user_id' => 'integer', + 'custom_domain_id' => 'integer', 'is_enabled' => 'boolean', 'single_use' => 'boolean', 'forward_query_params' => 'boolean', @@ -113,6 +117,7 @@ class ShortUrl extends Model 'qr_options' => 'array', 'show_warning_page' => 'boolean', 'targeting_rules' => 'array', + 'rotation_variants' => 'array', 'max_visits' => 'integer', 'activated_at' => 'datetime', 'deactivated_at' => 'datetime', @@ -122,6 +127,19 @@ class ShortUrl extends Model // ─── Relations ─────────────────────────────────────────────────────────── + public function customDomain(): BelongsTo + { + return $this->belongsTo(ShortUrlCustomDomain::class, 'custom_domain_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo( + config('filament-short-url.user.model', User::class), + 'user_id' + ); + } + public function visits(): HasMany { return $this->hasMany(ShortUrlVisit::class, 'short_url_id'); @@ -177,18 +195,45 @@ class ShortUrl extends Model * Find by URL key β€” cached for ultra-fast redirects. * Cache is invalidated automatically via model events on save/delete. */ - public static function findByKey(string $key): ?static + public static function findByKey(string $key, ?string $host = null): ?static { + $host ??= request()?->getHost(); + $mainDomain = parse_url(config('app.url'), PHP_URL_HOST); + $ttl = config('filament-short-url.cache_ttl', 3600); - if ($ttl <= 0) { - return static::where('url_key', $key)->with('pixels')->first(); + $query = static::where('url_key', $key)->with(['pixels', 'customDomain']); + + if ($host && strcasecmp($host, $mainDomain) !== 0) { + // Shared cache key with ShortUrlRedirectController \u2014 the controller warms this cache + // before calling findByKey(), so this lookup is typically a free cache hit. + $customDomain = cache()->remember( + "filament-short-url:custom-domain:{$host}", + 300, + fn () => ShortUrlCustomDomain::where('domain', $host) + ->where('is_active', true) + ->first() + ); + + if ($customDomain) { + $query->where('custom_domain_id', $customDomain->id); + } else { + return null; + } + } else { + $query->whereNull('custom_domain_id'); } + if ($ttl <= 0) { + return $query->first(); + } + + $cacheKey = "filament-short-url:{$key}:".($host ?? 'default'); + return cache()->remember( - "filament-short-url:{$key}", + $cacheKey, $ttl, - fn () => static::where('url_key', $key)->with('pixels')->first() + fn () => $query->first() ); } @@ -199,6 +244,12 @@ class ShortUrl extends Model */ protected static function booted(): void { + static::creating(function (self $m) { + if (auth()->check() && empty($m->user_id)) { + $m->user_id = auth()->id(); + } + }); + static::saving(function (self $m) { if ($m->single_use || $m->max_visits !== null || $m->expires_at !== null) { if ($m->single_use) { @@ -207,7 +258,7 @@ class ShortUrl extends Model $m->redirect_status_code = 302; // Force temporary redirect to prevent browser caching of limited/expiring URLs } - if ($m->activated_at === null && $m->expires_at === null) { + if ($m->activated_at === null && $m->expires_at === null && $m->deactivated_at === null) { $m->expiration_redirect_url = null; } @@ -240,34 +291,82 @@ class ShortUrl extends Model }); static::saved(function (self $m) { - cache()->forget("filament-short-url:{$m->url_key}"); cache()->forget("filament-short-url:visits:{$m->id}"); + $appHost = parse_url(config('app.url'), PHP_URL_HOST); + $hosts = ['default']; + if ($appHost) { + $hosts[] = $appHost; + } + + // Current domain + if ($m->custom_domain_id && $m->customDomain) { + $hosts[] = $m->customDomain->domain; + } + + // If custom domain changed, clear old domain cache too + if ($m->wasChanged('custom_domain_id')) { + $oldDomainId = $m->getOriginal('custom_domain_id'); + if ($oldDomainId) { + $oldDomain = ShortUrlCustomDomain::find($oldDomainId); + if ($oldDomain) { + $hosts[] = $oldDomain->domain; + } + } + } + + // Forget all redirect cache keys for this url_key + foreach ($hosts as $host) { + cache()->forget("filament-short-url:{$m->url_key}:{$host}"); + } + if ($m->wasChanged('url_key')) { $oldKey = $m->getOriginal('url_key'); if ($oldKey) { - cache()->forget("filament-short-url:{$oldKey}"); + foreach ($hosts as $host) { + cache()->forget("filament-short-url:{$oldKey}:{$host}"); + } } } + + // Bust the forever-cached link count on creation or deletion β€” count changes only then. + // Using wasRecentlyCreated avoids the double-forget that the separate created() event caused. + if ($m->wasRecentlyCreated) { + cache()->forget(ShortUrlGlobalOverview::LINKS_CACHE_KEY); + } }); + static::deleted(function (self $m) { - cache()->forget("filament-short-url:{$m->url_key}"); cache()->forget("filament-short-url:visits:{$m->id}"); cache()->forget(ShortUrlGlobalOverview::LINKS_CACHE_KEY); + $appHost = parse_url(config('app.url'), PHP_URL_HOST); + $hosts = ['default']; + if ($appHost) { + $hosts[] = $appHost; + } + + if ($m->custom_domain_id && $m->customDomain) { + $hosts[] = $m->customDomain->domain; + } + + foreach ($hosts as $host) { + cache()->forget("filament-short-url:{$m->url_key}:{$host}"); + } + if (! empty($m->qr_logo)) { Storage::disk('public')->delete($m->qr_logo); } }); - // Bust the forever-cached link counts displayed in the global overview widget. - static::created(fn () => cache()->forget(ShortUrlGlobalOverview::LINKS_CACHE_KEY)); } /** @return Collection */ public static function findByDestinationUrl(string $url): Collection { - return static::where('destination_url', $url)->get(); + $builder = static::where('destination_url', $url); + + return $builder->get(); } /** @@ -280,27 +379,21 @@ class ShortUrl extends Model return app(ShortUrlService::class)->destination($url); } - public function getRealTimeTotalVisits(): int - { - if (config('filament-short-url.counter_buffering.enabled', false)) { - $prefix = config('filament-short-url.counter_buffering.cache_key_prefix', 'filament-short-url:buffer:'); - $buffered = (int) cache()->get("{$prefix}total:{$this->id}", 0); - - return $this->total_visits + $buffered; - } - - // Use real-time visit count in cache to keep the cached model instance updated - $cacheKey = "filament-short-url:visits:{$this->id}"; - - return (int) cache()->remember($cacheKey, 3600, fn () => $this->total_visits); - } - public function isActive(): bool { if (! $this->is_enabled) { return false; } + // For single-use links the cached model may say is_enabled=true while the DB has + // already flipped it to false (another request beat us to it). We must re-read from + // the DB here because the redirect controller dispatches the tracking job (L174) + // BEFORE performing the atomic disable (L226). A stale cache hit would cause: + // 1. A phantom visit to be logged for an already-consumed single-use link. + // 2. Potentially serving content (warning page, pixel page) to the second visitor + // before they hit the atomic-update 410 check. + // The extra DB query is a justified cost: it only fires for single-use links, and + // only when the cached model still shows is_enabled=true. if ($this->single_use) { $realEnabled = DB::table($this->table)->where('id', $this->id)->value('is_enabled'); if (! $realEnabled) { @@ -308,23 +401,19 @@ class ShortUrl extends Model } } - // Not yet active if ($this->activated_at && $this->activated_at->isFuture()) { return false; } - // Explicitly deactivated if ($this->deactivated_at && $this->deactivated_at->isPast()) { return false; } - // TTL expiry - if ($this->expires_at && $this->expires_at->isPast()) { + if ($this->isExpired()) { return false; } - // Visit limit reached - if (! $this->single_use && $this->max_visits !== null && $this->getRealTimeTotalVisits() >= $this->max_visits) { + if ($this->max_visits !== null && $this->getRealTimeTotalVisits() >= $this->max_visits) { return false; } @@ -333,15 +422,7 @@ class ShortUrl extends Model public function isExpired(): bool { - if ($this->expires_at !== null && $this->expires_at->isPast()) { - return true; - } - - if (! $this->single_use && $this->max_visits !== null && $this->getRealTimeTotalVisits() >= $this->max_visits) { - return true; - } - - return false; + return $this->expires_at && $this->expires_at->isPast(); } public function trackingEnabled(): bool @@ -349,9 +430,7 @@ class ShortUrl extends Model return $this->track_visits; } - /** - * @return array - */ + /** @return array */ public function trackingFields(): array { if (! $this->track_visits) { @@ -388,641 +467,21 @@ class ShortUrl extends Model public function getShortUrl(): string { $prefix = config('filament-short-url.route_prefix'); + $baseUrl = config('app.url'); + + if ($this->custom_domain_id && $this->customDomain) { + // Use https for custom domains in production; fall back to the app.url scheme + // in other environments (local dev, staging). This prevents branded links from + // using http:// when the developer hasn't yet updated their app.url to https. + $appScheme = parse_url($baseUrl, PHP_URL_SCHEME) ?: 'https'; + $scheme = app()->isProduction() ? 'https' : $appScheme; + $baseUrl = $scheme.'://'.$this->customDomain->domain; + } if (! empty($prefix)) { - return rtrim(config('app.url'), '/').'/'.trim($prefix, '/').'/'.$this->url_key; + return rtrim($baseUrl, '/').'/'.trim($prefix, '/').'/'.$this->url_key; } - return rtrim(config('app.url'), '/').'/'.$this->url_key; - } - - /** - * Resolve the targeted destination URL based on request headers/context. - */ - public function resolveDestinationUrl(Request $request): string - { - $rules = $this->targeting_rules; - - if (empty($rules)) { - return $this->destination_url; - } - - // Backward compatibility check: legacy type-based strategy - if (is_array($rules) && isset($rules['type'])) { - $type = $rules['type'] ?? 'none'; - - if ($type === 'device') { - $parser = app(UserAgentParser::class); - $deviceType = $parser->getDeviceType($request->userAgent() ?? ''); - - if ($deviceType === 'mobile') { - return $rules['device']['mobile'] ?? $rules['device']['ios'] ?? $this->destination_url; - } - if ($deviceType === 'tablet') { - return $rules['device']['tablet'] ?? $rules['device']['android'] ?? $this->destination_url; - } - - return $rules['device']['desktop'] ?? $this->destination_url; - } - - if ($type === 'geo') { - $countryCode = ClientIpExtractor::getCountryCode($request); - if (! $countryCode) { - $ip = ClientIpExtractor::getIp($request); - $geo = app(GeoIpService::class)->resolve($ip); - $countryCode = $geo['country_code'] ?? null; - } - - if ($countryCode) { - $countryCode = strtoupper(trim($countryCode)); - foreach ($rules['geo'] ?? [] as $rule) { - if (strtoupper($rule['country_code'] ?? '') === $countryCode) { - return $rule['url'] ?? $this->destination_url; - } - } - } - } - - if ($type === 'language') { - $acceptedLanguages = $request->getLanguages(); - - foreach ($acceptedLanguages as $acceptedLanguage) { - $acceptedLanguage = strtolower(trim(str_replace('_', '-', $acceptedLanguage))); - if (empty($acceptedLanguage)) { - continue; - } - - foreach ($rules['language'] ?? [] as $rule) { - $ruleLang = strtolower(trim(str_replace('_', '-', $rule['language_code'] ?? ''))); - if ($ruleLang === $acceptedLanguage) { - return $rule['url'] ?? $this->destination_url; - } - } - } - - foreach ($acceptedLanguages as $acceptedLanguage) { - $acceptedLanguage = strtolower(trim(str_replace('_', '-', $acceptedLanguage))); - if (empty($acceptedLanguage)) { - continue; - } - - $parts = explode('-', $acceptedLanguage); - $primaryLang = strtolower(trim($parts[0])); - - foreach ($rules['language'] ?? [] as $rule) { - $ruleLang = strtolower(trim(str_replace('_', '-', $rule['language_code'] ?? ''))); - if ($ruleLang === $primaryLang) { - return $rule['url'] ?? $this->destination_url; - } - } - } - } - - if ($type === 'rotation') { - $items = $rules['rotation'] ?? []; - if (! empty($items)) { - $totalWeight = array_sum(array_column($items, 'weight')); - if ($totalWeight > 0) { - $rand = mt_rand(1, $totalWeight); - $currentWeight = 0; - foreach ($items as $item) { - $currentWeight += (int) ($item['weight'] ?? 0); - if ($rand <= $currentWeight) { - return $item['url'] ?? $this->destination_url; - } - } - } - } - } - - return $this->destination_url; - } - - // Evaluate new multi-filter rule engine - if (! is_array($rules)) { - return $this->destination_url; - } - - // Lazy-loaded request properties for maximum performance - $parsedUserAgent = null; - $deviceType = null; - $platformOs = null; - $countryCode = null; - $browserLanguages = null; - - foreach ($rules as $rule) { - $filters = $rule['filters'] ?? []; - if (empty($filters)) { - continue; - } - - $matchType = $rule['match'] ?? 'or'; - $ruleMatches = $matchType === 'and'; // 'and' defaults to true (requires all matching), 'or' defaults to false - - foreach ($filters as $filter) { - $filterType = $filter['type'] ?? ''; - $filterData = $filter['data'] ?? []; - $filterMatches = false; - - if ($filterType === 'device') { - if ($deviceType === null) { - $deviceType = app(UserAgentParser::class)->getDeviceType($request->userAgent() ?? ''); - } - $filterMatches = in_array($deviceType, $filterData['devices'] ?? []); - } elseif ($filterType === 'platform') { - if ($platformOs === null) { - $rawOs = app(UserAgentParser::class)->getOs($request->userAgent() ?? '') ?? ''; - $platformOs = match (true) { - stripos($rawOs, 'Windows') !== false => 'windows', - stripos($rawOs, 'Fire OS') !== false => 'fire_os', - stripos($rawOs, 'iOS') !== false || stripos($rawOs, 'iPad') !== false => 'ios', - stripos($rawOs, 'Mac') !== false => 'mac', - stripos($rawOs, 'Android') !== false => 'android', - stripos($rawOs, 'Linux') !== false => 'linux', - default => strtolower($rawOs), - }; - } - $filterMatches = in_array($platformOs, $filterData['platforms'] ?? []); - } elseif ($filterType === 'country') { - if ($countryCode === null) { - $countryCode = ClientIpExtractor::getCountryCode($request); - if (! $countryCode) { - $ip = ClientIpExtractor::getIp($request); - $geo = app(GeoIpService::class)->resolve($ip); - $countryCode = $geo['country_code'] ?? ''; - } - $countryCode = strtoupper(trim($countryCode)); - } - $filterMatches = in_array($countryCode, array_map('strtoupper', $filterData['countries'] ?? [])); - } elseif ($filterType === 'language') { - if ($browserLanguages === null) { - $browserLanguages = array_map(function ($lang) { - return strtolower(trim(str_replace('_', '-', $lang))); - }, $request->getLanguages()); - } - - $filterLangs = array_map('strtolower', $filterData['languages'] ?? []); - - // Pass 1: Exact match - foreach ($browserLanguages as $browserLang) { - if (in_array($browserLang, $filterLangs)) { - $filterMatches = true; - break; - } - } - - // Pass 2: Fallback prefix match - if (! $filterMatches) { - foreach ($browserLanguages as $browserLang) { - $primaryLang = explode('-', $browserLang)[0]; - if (in_array($primaryLang, $filterLangs)) { - $filterMatches = true; - break; - } - } - } - } - - if ($matchType === 'and') { - if (! $filterMatches) { - $ruleMatches = false; - break; // fail fast - } - } else { // 'or' - if ($filterMatches) { - $ruleMatches = true; - break; // succeed fast - } - } - } - - if ($ruleMatches && ! empty($rule['url'])) { - return $rule['url']; - } - } - - return $this->destination_url; - } - - /** - * Atomically increment visit counters β€” single query when unique, - * to avoid race conditions and two round-trips. Supports write-back caching. - */ - public function incrementVisits(bool $isUnique = false, bool $isQrScan = false): void - { - if (config('filament-short-url.counter_buffering.enabled', false)) { - $prefix = config('filament-short-url.counter_buffering.cache_key_prefix', 'filament-short-url:buffer:'); - try { - // Increment atomically in cache (works on Redis, Memcached, Database, File, etc.) - cache()->increment("{$prefix}total:{$this->id}"); - - if ($isUnique) { - cache()->increment("{$prefix}unique:{$this->id}"); - } - - if ($isQrScan) { - cache()->increment("{$prefix}qr:{$this->id}"); - } - - // Add to dirty IDs list atomically - if (cache()->getDefaultDriver() === 'redis' && class_exists(Redis::class)) { - Redis::sadd("{$prefix}dirty_ids", $this->id); - } else { - // Safe, concurrent-proof fallback for non-Redis stores using Cache locks - $lock = cache()->lock("{$prefix}dirty_ids_lock", 2); - $lock->get(function () use ($prefix) { - $dirtyIds = cache()->get("{$prefix}dirty_ids", []); - if (! is_array($dirtyIds)) { - $dirtyIds = []; - } - if (! in_array($this->id, $dirtyIds)) { - $dirtyIds[] = $this->id; - cache()->forever("{$prefix}dirty_ids", $dirtyIds); - } - }); - } - - return; - } catch (\Throwable $e) { - // Log and fall back to queue job below if caching backend fails - } - - // Safe fallback: Dispatch async job so clicks are queued and not lost on cache clear - $connection = config('filament-short-url.queue_connection', 'sync'); - dispatch((new IncrementVisitJob($this->id, $isUnique, $isQrScan))->onConnection($connection ?: 'sync')); - - return; - } - - $updates = []; - if ($isUnique) { - $updates['unique_visits'] = DB::raw('unique_visits + 1'); - } - if ($isQrScan) { - $updates['qr_scans'] = DB::raw('qr_scans + 1'); - } - - $this->newQuery() - ->where('id', $this->id) - ->increment('total_visits', 1, $updates); - - // Keep the real-time cache count incremented - $cacheKey = "filament-short-url:visits:{$this->id}"; - try { - if (cache()->has($cacheKey)) { - cache()->increment($cacheKey); - } else { - cache()->put($cacheKey, $this->total_visits + 1, 3600); - } - } catch (\Throwable $e) { - // Ignore cache errors in increment to never disrupt redirection - } - } - - /** - * Get cached statistics for this short URL. - * - * @return array - */ - public function getCachedStats(?string $dateFrom = null, ?string $dateTo = null): array - { - $dateFromClean = $dateFrom ? Carbon::parse($dateFrom)->toDateString() : null; - $dateToClean = $dateTo ? Carbon::parse($dateTo)->toDateString() : null; - - $cacheTtl = (int) config('filament-short-url.geo_ip.stats_cache_ttl', 300); - $cacheKey = "short_url_stats_{$this->id}_".($dateFromClean ?: 'all').'_'.($dateToClean ?: 'all'); - - return cache()->remember($cacheKey, $cacheTtl, function () use ($dateFromClean, $dateToClean) { - $today = Carbon::today()->toDateString(); - - // 1. Fetch daily stats (aggregated historical data) - $dailyQuery = $this->dailyStats()->where('date', '<', $today); - if ($dateFromClean) { - $dailyQuery->where('date', '>=', $dateFromClean); - } - if ($dateToClean && $dateToClean < $today) { - $dailyQuery->where('date', '<=', $dateToClean); - } - $dailyStatsRows = $dailyQuery->get(); - - // 2. Fetch raw visits for today (if within date range) - $includeToday = ($dateToClean === null || $dateToClean >= $today); - if ($dateFromClean && $dateFromClean > $today) { - $includeToday = false; - } - - $rawVisits = []; - if ($includeToday) { - $rawVisits = $this->visits()->where('visited_at', '>=', $today.' 00:00:00')->get(); - } - - // Helper to merge associative stats arrays - $mergeStats = function (array $base, ?array $additional): array { - if (empty($additional)) { - return $base; - } - foreach ($additional as $key => $val) { - $base[$key] = ($base[$key] ?? 0) + $val; - } - - return $base; - }; - - // Initialize metrics - $totalVisits = 0; - $uniqueVisitsCount = 0; - $visitsToday = count($rawVisits); - $visitsThisWeek = 0; - $visitsThisMonth = 0; - $qrScans = 0; - - $visitsByCountry = []; - $visitsByCity = []; - $visitsByDevice = []; - $visitsByBrowser = []; - $visitsByOs = []; - $visitsByReferer = []; - $utmSources = []; - $utmMediums = []; - $utmCampaigns = []; - $visitsByLanguage = []; - - // Sum up daily stats - $startOfWeek = now()->startOfWeek()->toDateString(); - $startOfMonth = now()->startOfMonth()->toDateString(); - - foreach ($dailyStatsRows as $row) { - $totalVisits += $row->visits_count; - $uniqueVisitsCount += $row->unique_visits_count; - $qrScans += $row->qr_visits_count ?? 0; - - $rowDate = $row->date->toDateString(); - if ($rowDate >= $startOfWeek) { - $visitsThisWeek += $row->visits_count; - } - if ($rowDate >= $startOfMonth) { - $visitsThisMonth += $row->visits_count; - } - - $visitsByCountry = $mergeStats($visitsByCountry, $row->country_stats); - $visitsByCity = $mergeStats($visitsByCity, $row->city_stats); - $visitsByDevice = $mergeStats($visitsByDevice, $row->device_stats); - $visitsByBrowser = $mergeStats($visitsByBrowser, $row->browser_stats); - $visitsByOs = $mergeStats($visitsByOs, $row->os_stats); - $visitsByReferer = $mergeStats($visitsByReferer, $row->referer_stats); - $utmSources = $mergeStats($utmSources, $row->utm_source_stats); - $utmMediums = $mergeStats($utmMediums, $row->utm_medium_stats); - $utmCampaigns = $mergeStats($utmCampaigns, $row->utm_campaign_stats); - $visitsByLanguage = $mergeStats($visitsByLanguage, $row->language_stats); - } - - // Combine today's raw visits - if ($includeToday) { - $totalVisits += count($rawVisits); - $uniqueVisitsCount += count(array_unique(array_filter($rawVisits->pluck('ip_hash')->toArray()))); - - $visitsThisWeek += count($rawVisits); - $visitsThisMonth += count($rawVisits); - - foreach ($rawVisits as $visit) { - if ($visit->is_qr_scan) { - $qrScans++; - } - if ($visit->browser_language) { - $visitsByLanguage[$visit->browser_language] = ($visitsByLanguage[$visit->browser_language] ?? 0) + 1; - } - if ($visit->country) { - $visitsByCountry[$visit->country] = ($visitsByCountry[$visit->country] ?? 0) + 1; - } - if ($visit->city) { - $cityKey = "{$visit->city} ({$visit->country_code})"; - $visitsByCity[$cityKey] = ($visitsByCity[$cityKey] ?? 0) + 1; - } - if ($visit->device_type) { - $visitsByDevice[$visit->device_type] = ($visitsByDevice[$visit->device_type] ?? 0) + 1; - } - if ($visit->browser) { - $visitsByBrowser[$visit->browser] = ($visitsByBrowser[$visit->browser] ?? 0) + 1; - } - if ($visit->operating_system) { - $visitsByOs[$visit->operating_system] = ($visitsByOs[$visit->operating_system] ?? 0) + 1; - } - $refererHost = $visit->referer_host ?: 'Direct'; - $visitsByReferer[$refererHost] = ($visitsByReferer[$refererHost] ?? 0) + 1; - - if ($visit->utm_source) { - $utmSources[$visit->utm_source] = ($utmSources[$visit->utm_source] ?? 0) + 1; - } - if ($visit->utm_medium) { - $utmMediums[$visit->utm_medium] = ($utmMediums[$visit->utm_medium] ?? 0) + 1; - } - if ($visit->utm_campaign) { - $utmCampaigns[$visit->utm_campaign] = ($utmCampaigns[$visit->utm_campaign] ?? 0) + 1; - } - } - } - - // Build visitsByDay timeline - $chartFrom = $dateFromClean ? Carbon::parse($dateFromClean) : now()->subDays(29)->startOfDay(); - $chartTo = $dateToClean ? Carbon::parse($dateToClean) : now()->endOfDay(); - $daysDiff = (int) $chartFrom->diffInDays($chartTo); - - $visitsByDay = []; - if ($daysDiff > 90) { - // Group by month - foreach ($dailyStatsRows as $row) { - $m = $row->date->format('Y-m'); - $visitsByDay[$m] = ($visitsByDay[$m] ?? 0) + $row->visits_count; - } - if ($includeToday) { - $mToday = Carbon::parse($today)->format('Y-m'); - $visitsByDay[$mToday] = ($visitsByDay[$mToday] ?? 0) + count($rawVisits); - } - } else { - // Initialize timeline with zeros - for ($i = $daysDiff; $i >= 0; $i--) { - $d = (clone $chartTo)->subDays($i)->format('Y-m-d'); - $visitsByDay[$d] = 0; - } - // Fill daily stats - foreach ($dailyStatsRows as $row) { - $d = $row->date->format('Y-m-d'); - if (isset($visitsByDay[$d])) { - $visitsByDay[$d] = $row->visits_count; - } - } - // Fill today - if ($includeToday && isset($visitsByDay[$today])) { - $visitsByDay[$today] = count($rawVisits); - } - } - - // Sort distributions descending - arsort($visitsByCountry); - arsort($visitsByCity); - arsort($visitsByDevice); - arsort($visitsByBrowser); - arsort($visitsByOs); - arsort($visitsByReferer); - arsort($utmSources); - arsort($utmMediums); - arsort($utmCampaigns); - arsort($visitsByLanguage); - - return [ - 'totalVisits' => $totalVisits, - 'uniqueVisits' => $uniqueVisitsCount, - 'visitsToday' => $visitsToday, - 'visitsThisWeek' => $visitsThisWeek, - 'visitsThisMonth' => $visitsThisMonth, - 'visitsByDay' => $visitsByDay, - 'visitsByCountry' => array_slice($visitsByCountry, 0, 10, true), - 'visitsByCity' => array_slice($visitsByCity, 0, 10, true), - 'visitsByDevice' => $visitsByDevice, - 'visitsByBrowser' => array_slice($visitsByBrowser, 0, 8, true), - 'visitsByOs' => array_slice($visitsByOs, 0, 8, true), - 'visitsByReferer' => array_slice($visitsByReferer, 0, 10, true), - 'utmSources' => array_slice($utmSources, 0, 8, true), - 'utmMediums' => array_slice($utmMediums, 0, 8, true), - 'utmCampaigns' => array_slice($utmCampaigns, 0, 8, true), - 'qrScans' => $qrScans, - 'visitsByLanguage' => array_slice($visitsByLanguage, 0, 10, true), - ]; - }); - } - - /** - * Cache properties to hold preloaded buffered visits for the current request. - */ - protected static ?array $bufferedTotalVisits = null; - - protected static ?array $bufferedUniqueVisits = null; - - protected static ?array $bufferedQrScans = null; - - /** - * Preload all buffered clicks in a single batch query for the entire request. - * Prevents N+1 database queries even if database cache driver is used. - */ - protected static function loadAllBufferedVisits(): void - { - if (static::$bufferedTotalVisits !== null) { - return; - } - - static::$bufferedTotalVisits = []; - static::$bufferedUniqueVisits = []; - static::$bufferedQrScans = []; - - if (! config('filament-short-url.counter_buffering.enabled', false)) { - return; - } - - $prefix = config('filament-short-url.counter_buffering.cache_key_prefix', 'filament-short-url:buffer:'); - $dirtyKey = "{$prefix}dirty_ids"; - - // 1. Fetch the list of dirty IDs (URLs with pending buffered clicks) in one query - $dirtyIds = []; - try { - if (cache()->getDefaultDriver() === 'redis' && class_exists(Redis::class)) { - $dirtyIds = Redis::smembers($dirtyKey); - } else { - $dirtyIds = cache()->get($dirtyKey, []); - } - } catch (\Throwable) { - // Fallback - } - - if (empty($dirtyIds)) { - return; - } - - $dirtyIds = array_unique(array_filter((array) $dirtyIds)); - - // 2. Build array of keys to fetch in a single cache store read - $totalKeys = []; - $uniqueKeys = []; - $qrKeys = []; - foreach ($dirtyIds as $id) { - $totalKeys[$id] = "{$prefix}total:{$id}"; - $uniqueKeys[$id] = "{$prefix}unique:{$id}"; - $qrKeys[$id] = "{$prefix}qr:{$id}"; - } - - try { - // Cache::many() is highly optimized (e.g. 1 database query for database store, or 1 MGET for Redis) - $totals = cache()->many(array_values($totalKeys)); - $uniques = cache()->many(array_values($uniqueKeys)); - $qrs = cache()->many(array_values($qrKeys)); - - foreach ($totalKeys as $id => $key) { - static::$bufferedTotalVisits[$id] = (int) ($totals[$key] ?? 0); - } - foreach ($uniqueKeys as $id => $key) { - static::$bufferedUniqueVisits[$id] = (int) ($uniques[$key] ?? 0); - } - foreach ($qrKeys as $id => $key) { - static::$bufferedQrScans[$id] = (int) ($qrs[$key] ?? 0); - } - } catch (\Throwable) { - // Fallback - } - } - - /** - * Get the total visits count, merging the database value with any buffered clicks in cache. - * Prevents database N+1 queries. - */ - public function getTotalVisitsAttribute(): int - { - $dbValue = $this->attributes['total_visits'] ?? 0; - - if (! config('filament-short-url.counter_buffering.enabled', false)) { - return $dbValue; - } - - static::loadAllBufferedVisits(); - - $buffered = static::$bufferedTotalVisits[$this->id] ?? 0; - - return $dbValue + $buffered; - } - - /** - * Get the unique visits count, merging the database value with any buffered clicks in cache. - * Prevents database N+1 queries. - */ - public function getUniqueVisitsAttribute(): int - { - $dbValue = $this->attributes['unique_visits'] ?? 0; - - if (! config('filament-short-url.counter_buffering.enabled', false)) { - return $dbValue; - } - - static::loadAllBufferedVisits(); - - $buffered = static::$bufferedUniqueVisits[$this->id] ?? 0; - - return $dbValue + $buffered; - } - - /** - * Get the QR scans count, merging the database value with any buffered clicks in cache. - * Prevents database N+1 queries. - */ - public function getQrScansAttribute(): int - { - $dbValue = $this->attributes['qr_scans'] ?? 0; - - if (! config('filament-short-url.counter_buffering.enabled', false)) { - return $dbValue; - } - - static::loadAllBufferedVisits(); - - $buffered = static::$bufferedQrScans[$this->id] ?? 0; - - return $dbValue + $buffered; + return rtrim($baseUrl, '/').'/'.$this->url_key; } } diff --git a/src/Models/ShortUrlCustomDomain.php b/src/Models/ShortUrlCustomDomain.php new file mode 100644 index 0000000..646b4e5 --- /dev/null +++ b/src/Models/ShortUrlCustomDomain.php @@ -0,0 +1,181 @@ + + * @copyright 2026 Bartek Janczak + * @license Custom Source-Available License (see LICENSE file) + */ + +namespace Bjanczak\FilamentShortUrl\Models; + +use App\Models\User; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Support\Carbon; + +/** + * @property int $id + * @property int|null $user_id + * @property string $domain + * @property bool $is_verified + * @property bool $is_active + * @property Carbon $created_at + * @property Carbon $updated_at + */ +class ShortUrlCustomDomain extends Model +{ + protected $table = 'short_url_custom_domains'; + + protected $fillable = [ + 'domain', + 'is_verified', + 'is_active', + 'user_id', + ]; + + protected $casts = [ + 'is_verified' => 'boolean', + 'is_active' => 'boolean', + ]; + + /** + * Get the owner of this custom domain. + */ + public function user(): BelongsTo + { + $userModel = config('filament-short-url.user.model', User::class); + + return $this->belongsTo($userModel, 'user_id'); + } + + /** + * Get short URLs mapped to this custom domain. + */ + public function shortUrls(): HasMany + { + return $this->hasMany(ShortUrl::class, 'custom_domain_id'); + } + + /** + * Invalidate the redirect-layer custom-domain cache on any state change. + * This ensures that verifying, deactivating, or deleting a domain is reflected + * immediately without waiting for the 5-minute cache TTL to expire. + */ + protected static function booted(): void + { + $bust = static function (self $m): void { + cache()->forget("filament-short-url:custom-domain:{$m->domain}"); + }; + + static::saved($bust); + static::deleted($bust); + } + + /** + * Dynamically resolve DNS records for this custom domain to verify if it points + * back to the host application server (via CNAME or A record). + */ + public function verifyDns(): bool + { + $domain = trim(strtolower($this->domain)); + if (empty($domain)) { + return false; + } + + // Get target host domain dynamically from configuration + $hostDomain = parse_url(config('app.url'), PHP_URL_HOST); + if (empty($hostDomain)) { + $hostDomain = request()?->getHost(); + } + + if (empty($hostDomain)) { + return false; + } + + $hostDomain = trim(strtolower($hostDomain)); + + // 1. Resolve host server IP dynamically + $hostIp = null; + try { + if (function_exists('dns_get_record')) { + $records = @dns_get_record($hostDomain, DNS_A); + if (is_array($records) && ! empty($records)) { + $hostIp = $records[0]['ip'] ?? null; + } + } + + if (empty($hostIp)) { + $output = shell_exec('dig +short +time=1 +tries=1 A '.escapeshellarg($hostDomain)); + if ($output) { + $ips = array_filter(array_map('trim', explode("\n", trim($output)))); + $hostIp = reset($ips) ?: null; + } + } + } catch (\Throwable $e) { + // ignore + } + + if (empty($hostIp)) { + $hostIp = $_SERVER['SERVER_ADDR'] ?? null; + } + + $isVerified = false; + + // 2. Resolve domain A records + $aIps = []; + try { + if (function_exists('dns_get_record')) { + $records = @dns_get_record($domain, DNS_A); + if (is_array($records)) { + $aIps = array_filter(array_column($records, 'ip')); + } + } + + if (empty($aIps)) { + $output = shell_exec('dig +short +time=1 +tries=1 A '.escapeshellarg($domain)); + if ($output) { + $aIps = array_filter(array_map('trim', explode("\n", trim($output)))); + } + } + } catch (\Throwable $e) { + // ignore + } + + if (! empty($aIps) && $hostIp && in_array($hostIp, $aIps)) { + $isVerified = true; + } + + // 3. Check CNAME record + if (! $isVerified) { + $cnameTarget = null; + try { + if (function_exists('dns_get_record')) { + $records = @dns_get_record($domain, DNS_CNAME); + if (is_array($records) && ! empty($records)) { + $cnameTarget = trim(strtolower(rtrim($records[0]['target'] ?? '', '.'))); + } + } + + if (empty($cnameTarget)) { + $output = shell_exec('dig +short +time=1 +tries=1 CNAME '.escapeshellarg($domain)); + if ($output) { + $cnameTarget = trim(strtolower(rtrim(trim($output), '.'))); + } + } + } catch (\Throwable $e) { + // ignore + } + + if ($cnameTarget && strcasecmp($cnameTarget, $hostDomain) === 0) { + $isVerified = true; + } + } + + if ($this->exists && $this->is_verified !== $isVerified) { + $this->update(['is_verified' => $isVerified]); + } + + return $isVerified; + } +} diff --git a/src/Models/ShortUrlDailyStats.php b/src/Models/ShortUrlDailyStats.php index 0c69b11..15e2c05 100644 --- a/src/Models/ShortUrlDailyStats.php +++ b/src/Models/ShortUrlDailyStats.php @@ -50,6 +50,7 @@ class ShortUrlDailyStats extends Model 'utm_campaign_stats', 'qr_visits_count', 'language_stats', + 'variant_stats', ]; /** @var array */ @@ -68,6 +69,7 @@ class ShortUrlDailyStats extends Model 'utm_campaign_stats' => 'array', 'qr_visits_count' => 'integer', 'language_stats' => 'array', + 'variant_stats' => 'array', ]; public function shortUrl(): BelongsTo diff --git a/src/Models/ShortUrlVisit.php b/src/Models/ShortUrlVisit.php index abca352..30a5e99 100644 --- a/src/Models/ShortUrlVisit.php +++ b/src/Models/ShortUrlVisit.php @@ -57,6 +57,7 @@ class ShortUrlVisit extends Model 'is_proxy', 'is_qr_scan', 'browser_language', + 'selected_variant', ]; /** @var array */ diff --git a/src/Rules/SafeUrl.php b/src/Rules/SafeUrl.php new file mode 100644 index 0000000..051df16 --- /dev/null +++ b/src/Rules/SafeUrl.php @@ -0,0 +1,36 @@ + + * @copyright 2026 Bartek Janczak + * @license Custom Source-Available License (see LICENSE file) + */ + +namespace Bjanczak\FilamentShortUrl\Rules; + +use Bjanczak\FilamentShortUrl\Services\SafeBrowsingService; +use Closure; +use Illuminate\Contracts\Validation\ValidationRule; +use Illuminate\Translation\PotentiallyTranslatedString; + +class SafeUrl implements ValidationRule +{ + /** + * Create a new rule instance. + */ + public function __construct( + public SafeBrowsingService $safeBrowsing + ) {} + + /** + * Run the validation rule. + * + * @param Closure(string): PotentiallyTranslatedString $fail + */ + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! $this->safeBrowsing->isSafe($value)) { + $fail(__('filament-short-url::default.safe_browsing_error')); + } + } +} diff --git a/src/Services/GeoIpService.php b/src/Services/GeoIpService.php index 3ac0ae5..cfdac01 100644 --- a/src/Services/GeoIpService.php +++ b/src/Services/GeoIpService.php @@ -28,6 +28,17 @@ class GeoIpService /** @var array */ private static array $runtimeCache = []; + /** + * Flush the in-process runtime cache. + * + * Call this in test setUp()/tearDown() to prevent state leaking between test cases + * when the same PHP process runs multiple tests (e.g. PHPUnit with Pest). + */ + public static function flush(): void + { + self::$runtimeCache = []; + } + /** * Resolve country and city data for the given IP address. * @@ -158,25 +169,32 @@ class GeoIpService try { $reader = new Reader($dbPath); - $dbType = $reader->metadata()->databaseType; - if (str_contains($dbType, 'City')) { - $record = $reader->city($ip); + try { + $dbType = $reader->metadata()->databaseType; + + if (str_contains($dbType, 'City')) { + $record = $reader->city($ip); + + return [ + 'country' => $record->country->name ?? null, + 'country_code' => $record->country->isoCode ?? null, + 'city' => $record->city->name ?? null, + ]; + } + + $record = $reader->country($ip); return [ 'country' => $record->country->name ?? null, 'country_code' => $record->country->isoCode ?? null, - 'city' => $record->city->name ?? null, + 'city' => null, ]; + } finally { + // Always close the file handle to prevent FD leaks in long-lived + // processes such as Laravel Octane or FrankenPHP workers. + $reader->close(); } - - $record = $reader->country($ip); - - return [ - 'country' => $record->country->name ?? null, - 'country_code' => $record->country->isoCode ?? null, - 'city' => null, - ]; } catch (\Throwable $e) { Log::warning('[FilamentShortUrl] MaxMind GeoIP lookup failed', [ 'ip' => substr($ip, 0, 8).'***', diff --git a/src/Services/ProxyDetectionService.php b/src/Services/ProxyDetectionService.php index 71dbcb3..7ec9030 100644 --- a/src/Services/ProxyDetectionService.php +++ b/src/Services/ProxyDetectionService.php @@ -32,8 +32,9 @@ class ProxyDetectionService $cacheKey = "short-url:proxy-check:{$ip}"; - if (Cache::has($cacheKey)) { - return Cache::get($cacheKey); + $cached = Cache::get($cacheKey); + if ($cached !== null) { + return $cached; } $driver = config('filament-short-url.vpn_detection.driver', 'ip-api'); @@ -54,11 +55,11 @@ class ProxyDetectionService } catch (\Throwable $e) { Log::warning("Proxy detection failed or timed out for IP: {$ip}. Error: ".$e->getMessage()); - // Temporary cache for failure (60 seconds) to allow retry and prevent whitelisting IPs long-term - $failResult = ['is_proxy' => false, 'is_bot' => false]; - Cache::put($cacheKey, $failResult, 60); - - return $failResult; + // Do NOT cache failure results as safe. If we cached 'is_proxy => false' on API + // errors, a malicious actor could deliberately trigger API rate-limiting to obtain + // a guaranteed bypass window for their IP. Instead we fail-open without caching, + // allowing a fresh check on the next request. + return ['is_proxy' => false, 'is_bot' => false]; } } diff --git a/src/Services/ShortUrlService.php b/src/Services/ShortUrlService.php index 0d4ecc7..b27978d 100644 --- a/src/Services/ShortUrlService.php +++ b/src/Services/ShortUrlService.php @@ -9,6 +9,7 @@ namespace Bjanczak\FilamentShortUrl\Services; use Bjanczak\FilamentShortUrl\Models\ShortUrl; +use Bjanczak\FilamentShortUrl\Models\ShortUrlCustomDomain; use Illuminate\Http\Request; use Illuminate\Support\Carbon; @@ -93,6 +94,20 @@ class ShortUrlService 'track_referer_url' => $fields['referer_url'] ?? true, ], $data); + if (! array_key_exists('custom_domain_id', $data) || $data['custom_domain_id'] === '') { + $defaultDisabled = (bool) config('filament-short-url.disable_default_domain', false); + if (! $defaultDisabled) { + $data['custom_domain_id'] = null; + } else { + $domains = ShortUrlCustomDomain::where('is_active', true) + ->where('is_verified', true) + ->get(); + if ($domains->count() === 1) { + $data['custom_domain_id'] = $domains->first()->id; + } + } + } + if (empty($data['url_key'])) { $data['url_key'] = $this->generateKey(); } diff --git a/src/Services/ShortUrlSettingsManager.php b/src/Services/ShortUrlSettingsManager.php index 47865d6..c4239ed 100644 --- a/src/Services/ShortUrlSettingsManager.php +++ b/src/Services/ShortUrlSettingsManager.php @@ -10,18 +10,12 @@ namespace Bjanczak\FilamentShortUrl\Services; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Schema; class ShortUrlSettingsManager { private ?array $cache = null; - public function getSettingsPath(): string - { - return storage_path('app/filament-short-url-settings.json'); - } - /** * Get all settings merged with configuration defaults. * @@ -49,32 +43,6 @@ class ShortUrlSettingsManager $decoded[$key] = json_decode($val, true); } - // If DB is empty, check if we can import legacy settings from JSON file - if (empty($decoded)) { - $legacyPath = $this->getSettingsPath(); - if (File::exists($legacyPath)) { - try { - $legacyContent = File::get($legacyPath); - $legacySettings = json_decode($legacyContent, true) ?: []; - - // Import each legacy setting to database - foreach ($legacySettings as $k => $v) { - DB::table('short_url_settings')->updateOrInsert( - ['key' => $k], - ['value' => json_encode($v), 'updated_at' => now(), 'created_at' => now()] - ); - } - - // Rename the legacy file to avoid re-importing - File::move($legacyPath, $legacyPath.'.bak'); - - return $legacySettings; - } catch (\Throwable $e) { - // Suppress import failures to prevent boot crash - } - } - } - return $decoded; } catch (\Throwable $e) { // Fallback to empty if DB query fails during boot/installation @@ -94,6 +62,8 @@ class ShortUrlSettingsManager // Merge stored settings with default values from config() $this->cache = array_merge([ 'route_prefix' => config('filament-short-url.route_prefix', 's'), + 'lock_url_key' => config('filament-short-url.lock_url_key', false), + 'disable_default_domain' => config('filament-short-url.disable_default_domain', false), 'redirect_status_code' => config('filament-short-url.redirect_status_code', 302), 'key_length' => config('filament-short-url.key_length', 6), 'geo_ip_enabled' => config('filament-short-url.geo_ip.enabled', true), @@ -115,6 +85,7 @@ class ShortUrlSettingsManager 'rate_limiting_max_attempts' => config('filament-short-url.rate_limiting.max_attempts', 60), 'rate_limiting_decay_seconds' => config('filament-short-url.rate_limiting.decay_seconds', 60), 'tracking_enabled' => config('filament-short-url.tracking.enabled', true), + 'tracking_anonymize_ips' => config('filament-short-url.tracking.anonymize_ips', false), 'tracking_fields_ip_address' => config('filament-short-url.tracking.fields.ip_address', true), 'tracking_fields_browser' => config('filament-short-url.tracking.fields.browser', true), 'tracking_fields_browser_version' => config('filament-short-url.tracking.fields.browser_version', true), @@ -151,6 +122,7 @@ class ShortUrlSettingsManager 'assetlinks_json' => config('filament-short-url.deep_linking.assetlinks_json'), // Webhook signing secret 'webhook_signing_secret' => null, + 'enable_fallback_route' => config('filament-short-url.enable_fallback_route', true), ], $stored); return $this->cache; @@ -171,8 +143,14 @@ class ShortUrlSettingsManager $oldPrefix = $this->get('route_prefix'); // Keep only supported settings keys to prevent bloat + // Canonical list of all settings that may be persisted to the database. + // NOTE: 'enable_fallback_route' is intentionally excluded β€” it controls route + // registration which happens at boot time, before DB settings are applied. + // Configure it via config/filament-short-url.php or SHORT_URL_ENABLE_FALLBACK env variable. $keys = [ 'route_prefix', + 'lock_url_key', + 'disable_default_domain', 'redirect_status_code', 'key_length', 'geo_ip_enabled', @@ -194,6 +172,7 @@ class ShortUrlSettingsManager 'rate_limiting_max_attempts', 'rate_limiting_decay_seconds', 'tracking_enabled', + 'tracking_anonymize_ips', 'tracking_fields_ip_address', 'tracking_fields_browser', 'tracking_fields_browser_version', @@ -282,6 +261,9 @@ class ShortUrlSettingsManager if (isset($filtered['tracking_enabled'])) { $filtered['tracking_enabled'] = (bool) $filtered['tracking_enabled']; } + if (isset($filtered['tracking_anonymize_ips'])) { + $filtered['tracking_anonymize_ips'] = (bool) $filtered['tracking_anonymize_ips']; + } if (isset($filtered['tracking_fields_ip_address'])) { $filtered['tracking_fields_ip_address'] = (bool) $filtered['tracking_fields_ip_address']; } @@ -321,6 +303,15 @@ class ShortUrlSettingsManager if (isset($filtered['global_webhook_enabled'])) { $filtered['global_webhook_enabled'] = (bool) $filtered['global_webhook_enabled']; } + if (isset($filtered['api_enabled'])) { + $filtered['api_enabled'] = (bool) $filtered['api_enabled']; + } + if (isset($filtered['lock_url_key'])) { + $filtered['lock_url_key'] = (bool) $filtered['lock_url_key']; + } + if (isset($filtered['disable_default_domain'])) { + $filtered['disable_default_domain'] = (bool) $filtered['disable_default_domain']; + } // Security v2.0 casts if (isset($filtered['vpn_detection_enabled'])) { @@ -336,21 +327,28 @@ class ShortUrlSettingsManager $newKeys = []; if (isset($filtered['api_keys']) && is_array($filtered['api_keys'])) { foreach ($filtered['api_keys'] as &$keyObj) { - $key = $keyObj['key'] ?? ''; - if (str_starts_with($key, 'sh_key_')) { - $plainKey = $key; - $hashed = hash('sha256', $plainKey); - $masked = substr($plainKey, 0, 11).'β€’β€’β€’β€’'.substr($plainKey, -4); + $rawKey = $keyObj['key'] ?? ''; + + // Only hash plain-text keys (prefix 'sh_key_' without masking characters). + // Masked keys (containing 'β€’β€’β€’β€’') are already stored hashed β€” re-hashing + // the masked value would permanently break authentication for that key. + $isPlainKey = str_starts_with($rawKey, 'sh_key_') && ! str_contains($rawKey, 'β€’β€’β€’β€’'); + + if ($isPlainKey) { + $hashed = hash('sha256', $rawKey); + $masked = substr($rawKey, 0, 11).'β€’β€’β€’β€’'.substr($rawKey, -4); $keyObj['hashed_key'] = $hashed; $keyObj['key'] = $masked; $newKeys[] = [ 'name' => $keyObj['name'] ?? 'API Key', - 'plain' => $plainKey, + 'plain' => $rawKey, ]; } } + unset($keyObj); // Break reference from foreach + if (! empty($newKeys)) { session()->flash('fsu_new_api_keys', $newKeys); } @@ -358,8 +356,8 @@ class ShortUrlSettingsManager try { if (Schema::hasTable('short_url_settings')) { - DB::transaction(function () use ($filtered) { - // Update or insert each key + DB::transaction(function () use ($filtered, $keys) { + // Update or insert each submitted key foreach ($filtered as $key => $val) { DB::table('short_url_settings')->updateOrInsert( ['key' => $key], @@ -367,9 +365,13 @@ class ShortUrlSettingsManager ); } - // Delete settings from the database that are no longer in keys (e.g. removed features) + // Remove keys that are no longer part of our canonical schema. + // We intentionally delete from the FULL $keys whitelist, NOT from + // array_keys($filtered) β€” otherwise a partial form submission + // (e.g. saving only the QR tab) would destroy unrelated settings + // such as API keys, webhook secrets, etc. DB::table('short_url_settings') - ->whereNotIn('key', array_keys($filtered)) + ->whereNotIn('key', $keys) ->delete(); }); } @@ -380,8 +382,18 @@ class ShortUrlSettingsManager if (app()->bound('cache')) { cache()->forget('filament-short-url:settings'); } + + // Detect counter_buffering mode switch β€” flush orphaned buffer caches to prevent + // the two counting systems from diverging after a toggle. + $oldBuffering = (bool) ($this->cache['counter_buffering_enabled'] ?? false); $this->cache = null; + $newBuffering = (bool) ($filtered['counter_buffering_enabled'] ?? $oldBuffering); + if ($oldBuffering !== $newBuffering && app()->bound('cache')) { + $prefix = config('filament-short-url.counter_buffering.cache_key_prefix', 'filament-short-url:buffer:'); + cache()->forget("{$prefix}dirty_ids"); + } + // Apply immediately to current request config $this->applyConfigOverrides(); @@ -428,6 +440,7 @@ class ShortUrlSettingsManager 'filament-short-url.rate_limiting.max_attempts' => $settings['rate_limiting_max_attempts'], 'filament-short-url.rate_limiting.decay_seconds' => $settings['rate_limiting_decay_seconds'], 'filament-short-url.tracking.enabled' => $settings['tracking_enabled'], + 'filament-short-url.tracking.anonymize_ips' => $settings['tracking_anonymize_ips'], 'filament-short-url.tracking.fields.ip_address' => $settings['tracking_fields_ip_address'], 'filament-short-url.tracking.fields.browser' => $settings['tracking_fields_browser'], 'filament-short-url.tracking.fields.browser_version' => $settings['tracking_fields_browser_version'], @@ -449,6 +462,8 @@ class ShortUrlSettingsManager 'filament-short-url.webhook_events' => $settings['webhook_events'] ?? ['visited'], 'filament-short-url.api_enabled' => (bool) ($settings['api_enabled'] ?? false), 'filament-short-url.site_name' => $settings['site_name'] ?? null, + 'filament-short-url.lock_url_key' => (bool) ($settings['lock_url_key'] ?? false), + 'filament-short-url.disable_default_domain' => (bool) ($settings['disable_default_domain'] ?? false), // Security v2.0 'filament-short-url.vpn_detection.enabled' => (bool) ($settings['vpn_detection_enabled'] ?? false), 'filament-short-url.vpn_detection.driver' => $settings['vpn_detection_driver'] ?? 'ip-api', diff --git a/src/Services/ShortUrlTracker.php b/src/Services/ShortUrlTracker.php index 821a1b9..4df7a85 100644 --- a/src/Services/ShortUrlTracker.php +++ b/src/Services/ShortUrlTracker.php @@ -42,9 +42,14 @@ class ShortUrlTracker ?string $utmContent = null, bool $isQrScan = false, ?string $browserLanguage = null, + ?string $selectedVariant = null, ): ?ShortUrlVisit { $ip = ClientIpExtractor::getIp($request); - $ipHash = hash('sha256', $ip); + + // HMAC-SHA256 keyed with app.key β€” plain SHA-256 is trivially reversible for + // IPv4 (only ~4.3B unique values). The app.key salt makes this per-installation + // and computationally infeasible to reverse without the secret. + $ipHash = hash_hmac('sha256', $ip, config('app.key', '')); $ua = $request->userAgent() ?? ''; $parsed = $this->uaParser->parse($ua); @@ -78,11 +83,16 @@ class ShortUrlTracker ->exists(); } + $storedIp = $ip; + if ($storedIp && config('filament-short-url.tracking.anonymize_ips', false)) { + $storedIp = static::anonymizeIp($storedIp); + } + $visit = new ShortUrlVisit; $visit->short_url_id = $shortUrl->id; $visit->visited_at = now(); $visit->ip_hash = $ipHash; - $visit->ip_address = $shortUrl->track_ip_address ? $ip : null; + $visit->ip_address = $shortUrl->track_ip_address ? $storedIp : null; $visit->browser = $shortUrl->track_browser ? $parsed['browser'] : null; $visit->browser_version = $shortUrl->track_browser_version ? $parsed['browser_version'] : null; $visit->operating_system = $shortUrl->track_operating_system ? $parsed['operating_system'] : null; @@ -96,6 +106,7 @@ class ShortUrlTracker $visit->is_proxy = $isProxy; $visit->is_qr_scan = $isQrScan; $visit->browser_language = $shortUrl->track_browser_language ? $browserLanguage : null; + $visit->selected_variant = $selectedVariant; $visit->utm_source = $utmSource; $visit->utm_medium = $utmMedium; @@ -139,4 +150,31 @@ class ShortUrlTracker return $visit; } + + /** + * Anonymize IP address by masking the last octet (IPv4) or last 80 bits (IPv6) with zeros. + */ + public static function anonymizeIp(?string $ip): ?string + { + if (empty($ip)) { + return null; + } + + $packed = @inet_pton($ip); + if ($packed === false) { + return $ip; // Fallback if parsing fails + } + + if (strlen($packed) === 4) { + // IPv4: mask last byte (8 bits) + $packed[3] = "\x00"; + } elseif (strlen($packed) === 16) { + // IPv6: mask last 10 bytes (80 bits) + for ($i = 6; $i < 16; $i++) { + $packed[$i] = "\x00"; + } + } + + return inet_ntop($packed); + } } diff --git a/tests/Feature/ShortUrlComplexInteractionsTest.php b/tests/Feature/ShortUrlComplexInteractionsTest.php new file mode 100644 index 0000000..a824bce --- /dev/null +++ b/tests/Feature/ShortUrlComplexInteractionsTest.php @@ -0,0 +1,408 @@ +create(array_merge([ + 'destination_url' => 'https://example.com', + 'track_visits' => false, + ], $attrs)); + } +} + +if (! function_exists('mgr')) { + function mgr(): ShortUrlSettingsManager + { + return app(ShortUrlSettingsManager::class); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// TESTS +// ═══════════════════════════════════════════════════════════════════════════ + +describe('complex option interactions', function () { + + beforeEach(function () { + cache()->forget('filament-short-url:settings'); + app()->forgetInstance(ShortUrlSettingsManager::class); + RateLimiter::clear('short_url_limit:pw-rl-combo:127.0.0.1'); + RateLimiter::clear('short_url_password_limit:pw-rl-combo:127.0.0.1'); + RateLimiter::clear('short_url_limit:pw-brute:127.0.0.1'); + RateLimiter::clear('short_url_password_limit:pw-brute:127.0.0.1'); + }); + + it('global rate limiting hits before the password check on the redirect path', function () { + config([ + 'filament-short-url.rate_limiting.enabled' => true, + 'filament-short-url.rate_limiting.max_attempts' => 2, + 'filament-short-url.rate_limiting.decay_seconds' => 60, + ]); + + makeLink([ + 'url_key' => 'pw-rl-combo', + 'password' => 'secret123', + ]); + + // First attempt (below rate limit) -> Shows password page (200) + $this->get('/s/pw-rl-combo')->assertStatus(200)->assertSee('Password Required'); + + // Second attempt (at rate limit) -> Shows password page (200) + $this->get('/s/pw-rl-combo')->assertStatus(200); + + // Third attempt (exceeds rate limit) -> Aborts with 429 + $this->get('/s/pw-rl-combo')->assertStatus(429); + + // Submitting POST should also be blocked by the rate limiter + $this->post('/s/pw-rl-combo', ['password' => 'secret123'])->assertStatus(429); + }); + + it('password rate limiting is independent and tracks wrong attempts only', function () { + config([ + 'filament-short-url.rate_limiting.enabled' => false, + ]); + + makeLink([ + 'url_key' => 'pw-brute', + 'password' => 'open-sesame', + ]); + + // Submit 5 wrong attempts -> 200 with incorrect password + for ($i = 0; $i < 5; $i++) { + $this->post('/s/pw-brute', ['password' => 'wrong-password']) + ->assertStatus(200) + ->assertSee('Incorrect password'); + } + + // 6th attempt (even correct password) -> 429 due to brute force protection + $this->post('/s/pw-brute', ['password' => 'open-sesame']) + ->assertStatus(429); + + // Check that a different IP can still log in successfully immediately + $this->withServerVariables(['REMOTE_ADDR' => '1.1.1.1']) + ->post('/s/pw-brute', ['password' => 'open-sesame']) + ->assertRedirect('/s/pw-brute'); + }); + + it('targeting rules apply after password protection but before warning page', function () { + config([ + 'filament-short-url.rate_limiting.enabled' => false, + ]); + + $link = makeLink([ + 'url_key' => 'tgt-pw-warn', + 'destination_url' => 'https://default.com', + 'password' => 'super-secret', + 'show_warning_page' => true, + 'targeting_rules' => [[ + 'match' => 'or', + 'url' => 'https://mobile.com', + 'filters' => [['type' => 'device', 'data' => ['devices' => ['mobile']]]], + ]], + ]); + + // 1. Unauthenticated request as mobile: password prompt must show first, not warning, not redirect + $response = $this->withHeader('User-Agent', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)') + ->get('/s/tgt-pw-warn'); + + $response->assertStatus(200) + ->assertSee('Password Required') + ->assertDontSee('Security Redirect Warning') + ->assertDontSee('https://mobile.com'); + + // 2. Submit correct password + $this->withHeader('User-Agent', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)') + ->post('/s/tgt-pw-warn', ['password' => 'super-secret']) + ->assertRedirect('/s/tgt-pw-warn'); + + // 3. Authenticated request as mobile: targeting resolved to mobile.com, warning page shown + $response2 = $this->withHeader('User-Agent', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)') + ->get('/s/tgt-pw-warn'); + + $response2->assertStatus(200) + ->assertSee('Security Redirect Warning') + ->assertSee('https://mobile.com') + ->assertDontSee('https://default.com'); + + // 4. Confirm redirection: goes to mobile URL + $this->withHeader('User-Agent', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)') + ->get('/s/tgt-pw-warn?confirmed=1') + ->assertRedirect('https://mobile.com'); + }); + + it('counter buffering does not cause data loss during visit aggregation and pruning', function () { + config([ + 'filament-short-url.queue_connection' => 'sync', + 'filament-short-url.counter_buffering.enabled' => true, + 'filament-short-url.pruning.enabled' => true, + 'filament-short-url.pruning.retention_days' => 5, + ]); + + $link = makeLink([ + 'url_key' => 'buf-prune', + 'track_visits' => true, + ]); + + // Record a real historical visit in database (older than retention limit, e.g. 10 days ago) + $oldVisit = $link->visits()->create([ + 'visited_at' => now()->subDays(10), + 'ip_hash' => 'old-hash', + ]); + + // Record a recent visit in database (within retention window, e.g. 2 days ago) + $recentVisit = $link->visits()->create([ + 'visited_at' => now()->subDays(2), + 'ip_hash' => 'recent-hash', + ]); + + // Simulate new visits buffered in cache (current day) + $prefix = config('filament-short-url.counter_buffering.cache_key_prefix', 'filament-short-url:buffer:'); + cache()->put("{$prefix}total:{$link->id}", 5); + cache()->put("{$prefix}unique:{$link->id}", 4); + cache()->forever("{$prefix}dirty_ids", [$link->id]); + + // Run Aggregate and Prune command + $this->artisan('short-url:aggregate-and-prune')->assertExitCode(0); + + // Verification: + // 1. The old visit should be pruned from database + $this->assertDatabaseMissing('short_url_visits', ['id' => $oldVisit->id]); + + // 2. The recent visit should remain in database + $this->assertDatabaseHas('short_url_visits', ['id' => $recentVisit->id]); + + // 3. The historical visits before today (recentVisit at 2 days ago) should be aggregated into daily stats + $dailyStat = ShortUrlDailyStats::where('short_url_id', $link->id)->first(); + expect($dailyStat)->not->toBeNull() + ->and($dailyStat->visits_count)->toBe(1) + ->and($dailyStat->unique_visits_count)->toBe(1); + + // 4. Cache buffer for today's visits should still exist intact (aggregation only processes visits prior to today) + expect((int) cache()->get("{$prefix}total:{$link->id}"))->toBe(5) + ->and((int) cache()->get("{$prefix}unique:{$link->id}"))->toBe(4); + + // 5. Sync the counters + $this->artisan('short-url:sync-counters')->assertExitCode(0); + + // 6. DB totals should now be updated with buffered values plus previously stored values + $link->refresh(); + expect($link->total_visits)->toBe(5) + ->and($link->unique_visits)->toBe(4); + }); + + it('split destination variants are resolved and UTM parameters forwarded correctly', function () { + config([ + 'filament-short-url.queue_connection' => 'sync', + ]); + + // Setup a short URL with targeting rules pointing to either single or split destinations + $link = makeLink([ + 'url_key' => 'split-utm-tgt', + 'destination_url' => 'https://fallback.com', + 'forward_query_params' => true, + 'destination_type' => 'split', + 'rotation_variants' => [ + ['label' => 'Root Variant A', 'url' => 'https://root-a.com', 'weight' => 100], + ], + 'targeting_rules' => [ + [ + // Rule 1: Mobile users go to split rotation + 'match' => 'or', + 'destination_type' => 'split', + 'filters' => [['type' => 'device', 'data' => ['devices' => ['mobile']]]], + 'variants' => [ + ['label' => 'Mobile Variant B', 'url' => 'https://mobile-b.com', 'weight' => 100], + ], + ], + [ + // Rule 2: Tablet users go to a single destination + 'match' => 'or', + 'destination_type' => 'single', + 'url' => 'https://tablet-single.com', + 'filters' => [['type' => 'device', 'data' => ['devices' => ['tablet']]]], + ] + ], + ]); + + // 1. Visit as tablet with UTM params -> redirected to tablet-single.com with query parameters + $this->withHeader('User-Agent', 'Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X)') + ->get('/s/split-utm-tgt?utm_source=newsletter&foo=bar') + ->assertRedirect('https://tablet-single.com?utm_source=newsletter&foo=bar'); + + // 2. Visit as mobile with query params -> redirected to mobile-b.com with query parameters + $this->withHeader('User-Agent', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)') + ->get('/s/split-utm-tgt?utm_source=social') + ->assertRedirect('https://mobile-b.com?utm_source=social'); + + // Verify the resolved variant is stored in container for tracking + expect(app('resolved_ab_variant'))->toBe('Mobile Variant B'); + + // 3. Visit as desktop with query params -> redirects to default root variant A with query parameters + $this->withHeader('User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)') + ->get('/s/split-utm-tgt?utm_source=direct') + ->assertRedirect('https://root-a.com?utm_source=direct'); + + expect(app('resolved_ab_variant'))->toBe('Root Variant A'); + }); + + it('custom domain + rate limiting + single use operate without caching bypasses', function () { + config([ + 'filament-short-url.cache_ttl' => 3600, + 'filament-short-url.rate_limiting.enabled' => true, + 'filament-short-url.rate_limiting.max_attempts' => 10, + 'filament-short-url.rate_limiting.decay_seconds' => 60, + 'app.url' => 'http://localhost', + ]); + + $domain = ShortUrlCustomDomain::create([ + 'domain' => 'brand.test', + 'is_active' => true, + 'is_verified' => true, + ]); + + $link = makeLink([ + 'url_key' => 'su-domain', + 'custom_domain_id' => $domain->id, + 'single_use' => true, + 'is_enabled' => true, + ]); + + // Clear local process/facade cache + cache()->forget('filament-short-url:custom-domain:brand.test'); + cache()->forget('filament-short-url:su-domain:brand.test'); + + // First visit via custom domain: redirect succeeds using full URL + $this->get('http://brand.test/su-domain') + ->assertRedirect('https://example.com'); + + // Verify DB is disabled + $link->refresh(); + expect($link->is_enabled)->toBeFalse(); + + // Second visit via custom domain: returns 410 Gone (even if cache contains stale enabled status) + $this->get('http://brand.test/su-domain') + ->assertStatus(410); + }); + + it('manually disabled link returns 410 even if expired and has expiration_redirect_url set', function () { + makeLink([ + 'url_key' => 'disabled-fallback-expired', + 'is_enabled' => false, + 'expires_at' => now()->subDay(), + 'expiration_redirect_url' => 'https://fallback.example.com', + ]); + + $this->get('/s/disabled-fallback-expired')->assertStatus(410); + }); + + it('single_use link with track_visits disabled still gets disabled on first visit', function () { + $link = makeLink([ + 'url_key' => 'su-no-track', + 'single_use' => true, + 'track_visits' => false, + 'is_enabled' => true, + ]); + + $this->get('/s/su-no-track')->assertRedirect('https://example.com'); + + $link->refresh(); + expect($link->is_enabled)->toBeFalse(); + }); + + it('rate limiting prevents single_use consumption on blocked requests', function () { + $key = 'su-rl-block-first'; + $ip = '127.0.0.1'; + $limiterKey = "short_url_limit:{$key}:{$ip}"; + + // Clear rate limiter key + RateLimiter::clear($limiterKey); + + config([ + 'filament-short-url.rate_limiting.enabled' => true, + 'filament-short-url.rate_limiting.max_attempts' => 1, + 'filament-short-url.rate_limiting.decay_seconds' => 60, + ]); + + $link = makeLink([ + 'url_key' => $key, + 'single_use' => true, + 'is_enabled' => true, + ]); + + // Manually record 1 hit so the next hit (the request) will exceed the limit of 1 + RateLimiter::hit($limiterKey, 60); + + // First visit to the link is immediately rate limited + $this->get("/s/{$key}")->assertStatus(429); + + // The link must NOT be disabled because the request was blocked before hitting single_use update + $link->refresh(); + expect($link->is_enabled)->toBeTrue(); + }); + + it('cannot bypass password prompt by passing confirmed parameter', function () { + makeLink([ + 'url_key' => 'pw-bypass', + 'password' => 'secret', + 'show_warning_page' => true, + ]); + + // Attempting to bypass password using ?confirmed=1 should still show password prompt + $this->get('/s/pw-bypass?confirmed=1') + ->assertStatus(200) + ->assertSee('Password Required') + ->assertDontSee('Security Redirect Warning'); + }); + + it('single_use link combined with warning page is only disabled after confirmation redirect', function () { + $link = makeLink([ + 'url_key' => 'su-warn', + 'single_use' => true, + 'show_warning_page' => true, + 'is_enabled' => true, + ]); + + // 1. First request: warning page rendered, link remains enabled + $this->get('/s/su-warn') + ->assertStatus(200) + ->assertSee('Security Redirect Warning'); + + $link->refresh(); + expect($link->is_enabled)->toBeTrue(); + + // 2. Second request: confirmed, redirects, link is disabled + $this->get('/s/su-warn?confirmed=1') + ->assertRedirect('https://example.com'); + + $link->refresh(); + expect($link->is_enabled)->toBeFalse(); + + // 3. Third request: returns 410 + $this->get('/s/su-warn?confirmed=1') + ->assertStatus(410); + }); + +}); diff --git a/tests/Feature/ShortUrlCoreOptimizationTest.php b/tests/Feature/ShortUrlCoreOptimizationTest.php index f00c982..b48c790 100644 --- a/tests/Feature/ShortUrlCoreOptimizationTest.php +++ b/tests/Feature/ShortUrlCoreOptimizationTest.php @@ -219,7 +219,37 @@ it('runs database-level daily stats aggregation successfully', function () { expect($stats->device_stats)->toBe(['desktop' => 2, 'mobile' => 1]) ->and($stats->browser_stats)->toBe(['Chrome' => 2, 'Safari' => 1]) ->and($stats->os_stats)->toBe(['iOS' => 1, 'macOS' => 2]) - ->and($stats->country_stats)->toBe(['Poland' => 3]) + ->and($stats->country_stats)->toBe(['PL' => 3]) ->and($stats->city_stats)->toBe(['Krakow (PL)' => 1, 'Warsaw (PL)' => 2]) ->and($stats->language_stats)->toBe(['en' => 1, 'pl' => 2]); }); + +it('anonymizes IP address correctly for IPv4 and IPv6', function () { + $ipv4 = '192.168.1.123'; + $ipv6 = '2001:db8:85a3:8d3:1319:8a2e:370:7334'; + + expect(\Bjanczak\FilamentShortUrl\Services\ShortUrlTracker::anonymizeIp($ipv4))->toBe('192.168.1.0') + ->and(\Bjanczak\FilamentShortUrl\Services\ShortUrlTracker::anonymizeIp($ipv6))->toBe('2001:db8:85a3::'); +}); + +it('anonymizes saved IP address when anonymize_ips is enabled but computes hash on raw IP', function () { + $mgr = app(\Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager::class); + $mgr->set(['tracking_anonymize_ips' => true]); + + $link = ShortUrl::create([ + 'destination_url' => 'https://example.com/anonymize', + 'url_key' => 'anonkey', + 'track_ip_address' => true, + ]); + + $request = \Illuminate\Http\Request::create('/anonkey', 'GET', [], [], [], [ + 'REMOTE_ADDR' => '1.2.3.4', + ]); + + $tracker = app(\Bjanczak\FilamentShortUrl\Services\ShortUrlTracker::class); + $visit = $tracker->record($link, $request); + + expect($visit->ip_address)->toBe('1.2.3.0') + // Hash must now use HMAC-SHA256 keyed with app.key \u2014 NOT plain SHA-256 + ->and($visit->ip_hash)->toBe(hash_hmac('sha256', '1.2.3.4', config('app.key', ''))); +}); diff --git a/tests/Feature/ShortUrlCustomDomainTest.php b/tests/Feature/ShortUrlCustomDomainTest.php new file mode 100644 index 0000000..be001cc --- /dev/null +++ b/tests/Feature/ShortUrlCustomDomainTest.php @@ -0,0 +1,95 @@ + 'links.acme.com', + 'is_verified' => true, + 'is_active' => true, + ]); + + $shortUrl = ShortUrl::create([ + 'destination_url' => 'https://example.com/target', + 'url_key' => 'promo', + 'custom_domain_id' => $domain->id, + ]); + + expect($shortUrl->customDomain->domain)->toBe('links.acme.com'); + + // Finding URL on the custom domain + $found = ShortUrl::findByKey('promo', 'links.acme.com'); + expect($found)->not->toBeNull() + ->and($found->id)->toBe($shortUrl->id); + + // Finding URL on standard domain should fail since it's scoped to the custom domain + $notFound = ShortUrl::findByKey('promo', 'app.com'); + expect($notFound)->toBeNull(); +}); + +it('routes requests on custom domains at root-level fallback', function () { + $domain = ShortUrlCustomDomain::create([ + 'domain' => 'links.acme.com', + 'is_verified' => true, + 'is_active' => true, + ]); + + $shortUrl = ShortUrl::create([ + 'destination_url' => 'https://example.com/target', + 'url_key' => 'promo', + 'custom_domain_id' => $domain->id, + ]); + + // Request to root of custom domain key should trigger redirect + $response = $this->get('http://links.acme.com/promo'); + + $response->assertRedirect('https://example.com/target'); +}); + +it('returns 404 for fallback requests on unregistered custom domains', function () { + // Attempting access on unregistered domain + $response = $this->get('http://unregistered.com/somekey'); + + $response->assertStatus(404); +}); + +it('returns 404 for path segments / subfolders under custom domains fallback', function () { + $domain = ShortUrlCustomDomain::create([ + 'domain' => 'links.acme.com', + 'is_verified' => true, + 'is_active' => true, + ]); + + $response = $this->get('http://links.acme.com/not-registered/subfolder'); + + $response->assertStatus(404); +}); + +it('returns correct short URL link string based on custom domain association', function () { + $domain = ShortUrlCustomDomain::create([ + 'domain' => 'links.acme.com', + 'is_verified' => true, + 'is_active' => true, + ]); + + $shortUrl = ShortUrl::create([ + 'destination_url' => 'https://example.com/target', + 'url_key' => 'promo', + 'custom_domain_id' => $domain->id, + ]); + + $standardUrl = ShortUrl::create([ + 'destination_url' => 'https://example.com/standard', + 'url_key' => 'stdkey', + ]); + + config(['app.url' => 'https://maindomain.com']); + config(['filament-short-url.route_prefix' => 's']); + + expect($shortUrl->getShortUrl())->toBe('https://links.acme.com/s/promo'); + expect($standardUrl->getShortUrl())->toBe('https://maindomain.com/s/stdkey'); +}); diff --git a/tests/Feature/ShortUrlDeepLinkingTest.php b/tests/Feature/ShortUrlDeepLinkingTest.php index 50746dd..5d026c0 100644 --- a/tests/Feature/ShortUrlDeepLinkingTest.php +++ b/tests/Feature/ShortUrlDeepLinkingTest.php @@ -107,10 +107,11 @@ it('clears deep linking cache when settings page save is triggered', function () { public ?array $formData = []; - public function save(): void + public function save(?ShortUrlSettingsManager $mgr = null): void { // Simulating save logic - app(ShortUrlSettingsManager::class)->set($this->formData); + $mgr ??= app(ShortUrlSettingsManager::class); + $mgr->set($this->formData); cache()->forget('fsu:deep-linking:aasa'); cache()->forget('fsu:deep-linking:assetlinks'); diff --git a/tests/Feature/ShortUrlRedirectTest.php b/tests/Feature/ShortUrlRedirectTest.php index 0bef25d..f18d7c3 100644 --- a/tests/Feature/ShortUrlRedirectTest.php +++ b/tests/Feature/ShortUrlRedirectTest.php @@ -235,7 +235,7 @@ it('caches stats page calculations', function () { // Clear the specific date-filtered cache key $dateFrom = now()->subDays(29)->format('Y-m-d'); $dateTo = now()->format('Y-m-d'); - Cache::forget("short_url_stats_{$shortUrl->id}_{$dateFrom}_{$dateTo}"); + $shortUrl->clearStatsCache($dateFrom, $dateTo); // Third mount: should now be 1 because cache is cleared and recalculated $page->mount($shortUrl); diff --git a/tests/Feature/ShortUrlSettingsAndFormOptionsTest.php b/tests/Feature/ShortUrlSettingsAndFormOptionsTest.php new file mode 100644 index 0000000..7c61305 --- /dev/null +++ b/tests/Feature/ShortUrlSettingsAndFormOptionsTest.php @@ -0,0 +1,1238 @@ +create(array_merge([ + 'destination_url' => 'https://example.com', + 'track_visits' => false, + ], $attrs)); + } +} + +if (! function_exists('mgr')) { + function mgr(): ShortUrlSettingsManager + { + return app(ShortUrlSettingsManager::class); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 1. SETTINGS MANAGER β€” persistence, cache, config override +// ═══════════════════════════════════════════════════════════════════════════ + +describe('settings manager', function () { + beforeEach(function () { + cache()->forget('filament-short-url:settings'); + app()->forgetInstance(ShortUrlSettingsManager::class); + }); + + it('returns default values when nothing is stored', function () { + $mgr = mgr(); + + expect($mgr->get('route_prefix'))->toBe(config('filament-short-url.route_prefix', 's')) + ->and($mgr->get('cache_ttl'))->toBe(config('filament-short-url.cache_ttl', 3600)) + ->and($mgr->get('redirect_status_code'))->toBe(302) + ->and($mgr->get('key_length'))->toBe(6) + ->and($mgr->get('lock_url_key'))->toBeFalse() + ->and($mgr->get('disable_default_domain'))->toBeFalse() + ->and($mgr->get('geo_ip_enabled'))->toBeTrue() + ->and($mgr->get('tracking_enabled'))->toBeTrue() + ->and($mgr->get('tracking_anonymize_ips'))->toBeFalse() + ->and($mgr->get('counter_buffering_enabled'))->toBeFalse() + ->and($mgr->get('rate_limiting_enabled'))->toBeFalse() + ->and($mgr->get('pruning_enabled'))->toBeTrue() + ->and($mgr->get('pruning_retention_days'))->toBe(90) + ->and($mgr->get('trust_cdn_headers'))->toBeFalse() + ->and($mgr->get('api_enabled'))->toBeFalse() + ->and($mgr->get('global_webhook_enabled'))->toBeFalse() + ->and($mgr->get('vpn_detection_enabled'))->toBeFalse(); + }); + + it('persists a setting to database and reads it back', function () { + mgr()->set(['route_prefix' => 'go']); + + // Fresh instance (cache cleared by set()) + app()->forgetInstance(ShortUrlSettingsManager::class); + cache()->forget('filament-short-url:settings'); + + expect(mgr()->get('route_prefix'))->toBe('go'); + }); + + it('stored setting overrides config default', function () { + config(['filament-short-url.cache_ttl' => 3600]); + mgr()->set(['cache_ttl' => 99]); + + app()->forgetInstance(ShortUrlSettingsManager::class); + cache()->forget('filament-short-url:settings'); + + expect(mgr()->get('cache_ttl'))->toBe(99); + }); + + it('does not persist enable_fallback_route (boot-time-only setting)', function () { + mgr()->set(['enable_fallback_route' => false]); + + $stored = DB::table('short_url_settings') + ->where('key', 'enable_fallback_route') + ->exists(); + + expect($stored)->toBeFalse(); + }); + + it('applies config overrides immediately after set()', function () { + mgr()->set(['cache_ttl' => 777]); + + // applyConfigOverrides should have pushed the value to config() directly + expect(config('filament-short-url.cache_ttl'))->toBe(777); + }); + + it('caches settings in local process memory after first read', function () { + $mgr = mgr(); + $mgr->get('route_prefix'); // warms internal cache + + // Delete from DB β€” should still read from in-memory cache + DB::table('short_url_settings')->truncate(); + cache()->forget('filament-short-url:settings'); + + // In-memory cache still populated on same instance + expect($mgr->get('route_prefix'))->toBe(config('filament-short-url.route_prefix', 's')); + }); + + it('invalidates cache on set() so next read contains the updated value', function () { + mgr()->set(['key_length' => 10]); + + // set() clears the old cache entry then immediately re-populates it via + // applyConfigOverrides(). The invariant is that the UPDATED value is now + // cached β€” so a fresh read from another process sees the new value without + // a DB round-trip. + app()->forgetInstance(ShortUrlSettingsManager::class); + cache()->forget('filament-short-url:settings'); + + expect(mgr()->get('key_length'))->toBe(10); + }); + + it('does not store unsupported (unknown) keys', function () { + mgr()->set(['totally_fake_key' => 'evil_value']); + + $stored = DB::table('short_url_settings') + ->where('key', 'totally_fake_key') + ->exists(); + + expect($stored)->toBeFalse(); + }); + + it('partial set() only updates given keys, others are preserved', function () { + mgr()->set(['route_prefix' => 'short', 'cache_ttl' => 500]); + app()->forgetInstance(ShortUrlSettingsManager::class); + cache()->forget('filament-short-url:settings'); + + mgr()->set(['cache_ttl' => 600]); // only update cache_ttl + app()->forgetInstance(ShortUrlSettingsManager::class); + cache()->forget('filament-short-url:settings'); + + $fresh = mgr(); + expect($fresh->get('route_prefix'))->toBe('short') // unchanged + ->and($fresh->get('cache_ttl'))->toBe(600); // updated + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 2. LINK OPTIONS β€” is_enabled, forward_query_params +// ═══════════════════════════════════════════════════════════════════════════ + +describe('link option: is_enabled', function () { + it('enabled link redirects to destination', function () { + makeLink(['url_key' => 'enabled-link', 'is_enabled' => true]); + $this->get('/s/enabled-link')->assertRedirect('https://example.com'); + }); + + it('disabled link returns 410', function () { + makeLink(['url_key' => 'disabled-link', 'is_enabled' => false]); + $this->get('/s/disabled-link')->assertStatus(410); + }); + + it('disabled link returns 410 even with expiration_redirect_url set (only expiry triggers fallback)', function () { + // expiration_redirect_url is only followed when the link has actually expired or is not yet active. + // A manually disabled link (is_enabled=false) returns 410 regardless. + makeLink([ + 'url_key' => 'disabled-fallback', + 'is_enabled' => false, + 'expiration_redirect_url' => 'https://fallback.example.com', + ]); + $this->get('/s/disabled-fallback')->assertStatus(410); + }); +}); + +describe('link option: forward_query_params', function () { + it('forwards query params to destination when enabled', function () { + makeLink(['url_key' => 'fwd-on', 'forward_query_params' => true]); + $this->get('/s/fwd-on?foo=bar&baz=qux') + ->assertRedirect('https://example.com?foo=bar&baz=qux'); + }); + + it('does not forward query params when disabled', function () { + makeLink(['url_key' => 'fwd-off', 'forward_query_params' => false]); + $this->get('/s/fwd-off?foo=bar') + ->assertRedirect('https://example.com'); + }); + + it('merges query params with existing destination query string', function () { + makeLink([ + 'url_key' => 'fwd-merge', + 'destination_url' => 'https://example.com?existing=1', + 'forward_query_params' => true, + ]); + $response = $this->get('/s/fwd-merge?added=2'); + $location = $response->headers->get('Location'); + + expect($location)->toContain('existing=1') + ->and($location)->toContain('added=2'); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 3. LINK OPTIONS β€” redirect_status_code +// ═══════════════════════════════════════════════════════════════════════════ + +describe('link option: redirect_status_code', function () { + it('uses 302 by default', function () { + makeLink(['url_key' => 'code-302']); + $this->get('/s/code-302')->assertStatus(302); + }); + + it('uses 301 when specified', function () { + $link = makeLink(['url_key' => 'code-301', 'redirect_status_code' => 301]); + + // Per business rule, 301 is forced to 302 when expires_at or max_visits is set. + // Without those, 301 should be respected. + expect($link->redirect_status_code)->toBe(301); + $this->get('/s/code-301')->assertStatus(301); + }); + + it('forces 302 when expires_at is set regardless of requested status code', function () { + $link = makeLink([ + 'url_key' => 'code-force', + 'redirect_status_code' => 301, + 'expires_at' => now()->addDay(), + ]); + expect($link->redirect_status_code)->toBe(302); + }); + + it('forces 302 when max_visits is set regardless of requested status code', function () { + $link = makeLink([ + 'url_key' => 'code-force-mv', + 'redirect_status_code' => 301, + 'max_visits' => 100, + ]); + expect($link->redirect_status_code)->toBe(302); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 4. LINK OPTIONS β€” activated_at / expires_at / deactivated_at / max_visits +// ═══════════════════════════════════════════════════════════════════════════ + +describe('link option: date validity', function () { + it('returns 410 when not yet activated', function () { + makeLink(['url_key' => 'not-yet', 'activated_at' => now()->addHour()]); + $this->get('/s/not-yet')->assertStatus(410); + }); + + it('redirects when activated_at is in the past', function () { + makeLink(['url_key' => 'was-activated', 'activated_at' => now()->subHour()]); + $this->get('/s/was-activated')->assertRedirect('https://example.com'); + }); + + it('returns 410 after expires_at', function () { + makeLink(['url_key' => 'expired', 'expires_at' => now()->subSecond()]); + $this->get('/s/expired')->assertStatus(410); + }); + + it('redirects before expires_at', function () { + makeLink(['url_key' => 'not-expired', 'expires_at' => now()->addHour()]); + $this->get('/s/not-expired')->assertRedirect('https://example.com'); + }); + + it('returns 410 when both activated_at is past and deactivated_at is past', function () { + makeLink([ + 'url_key' => 'deactivated', + 'activated_at' => now()->subDay(), + 'deactivated_at' => now()->subHour(), + ]); + $this->get('/s/deactivated')->assertStatus(410); + }); + + it('redirects when within valid activated_at / deactivated_at window', function () { + makeLink([ + 'url_key' => 'valid-window', + 'activated_at' => now()->subHour(), + 'deactivated_at' => now()->addHour(), + ]); + $this->get('/s/valid-window')->assertRedirect('https://example.com'); + }); + + it('uses expiration_redirect_url when expired', function () { + makeLink([ + 'url_key' => 'exp-redirect', + 'expires_at' => now()->subSecond(), + 'expiration_redirect_url' => 'https://new.example.com', + ]); + $this->get('/s/exp-redirect')->assertRedirect('https://new.example.com'); + }); +}); + +describe('link option: max_visits', function () { + it('redirects when below max_visits', function () { + $link = makeLink(['url_key' => 'mv-below', 'max_visits' => 5]); + $link->update(['total_visits' => 4]); + + $this->get('/s/mv-below')->assertRedirect('https://example.com'); + }); + + it('returns 410 when max_visits reached', function () { + $link = makeLink(['url_key' => 'mv-at', 'max_visits' => 3]); + $link->update(['total_visits' => 3]); + + $this->get('/s/mv-at')->assertStatus(410); + }); + + it('returns 410 when max_visits reached (expiration_redirect_url does not apply to visit cap)', function () { + // expiration_redirect_url only fires for time-based expiry, not for visit count cap. + $link = makeLink([ + 'url_key' => 'mv-fallback', + 'max_visits' => 1, + 'expiration_redirect_url' => 'https://done.example.com', + ]); + $link->update(['total_visits' => 1]); + + $this->get('/s/mv-fallback')->assertStatus(410); + }); + + it('single_use is mutually exclusive with max_visits in the form: max_visits field hidden when single_use is on', function () { + // When single_use=true, max_visits should have no effect (it's hidden in the UI). + // Model-level: single_use is independent in DB, but form hides max_visits. + // Here we just verify that a single_use link with max_visits > 0 still + // disables after first use (single_use takes precedence at controller level). + config(['filament-short-url.queue_connection' => 'sync']); + + $link = makeLink([ + 'url_key' => 'su-mv', + 'single_use' => true, + 'max_visits' => 999, // should be irrelevant + 'track_visits' => true, + ]); + + $this->get('/s/su-mv')->assertRedirect('https://example.com'); + + // Link is now disabled + $link->refresh(); + expect($link->is_enabled)->toBeFalse(); + + // Second visit must return 410 + $this->get('/s/su-mv')->assertStatus(410); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 5. LINK OPTIONS β€” single_use +// ═══════════════════════════════════════════════════════════════════════════ + +describe('link option: single_use', function () { + it('disables the link after first visit', function () { + config(['filament-short-url.queue_connection' => 'sync']); + makeLink(['url_key' => 'su1', 'single_use' => true, 'track_visits' => true]); + + $this->get('/s/su1')->assertRedirect(); + + $link = ShortUrl::where('url_key', 'su1')->first(); + expect($link->is_enabled)->toBeFalse(); + }); + + it('returns 410 on second visit after single use', function () { + config([ + 'filament-short-url.cache_ttl' => 3600, + 'filament-short-url.queue_connection' => 'sync', + ]); + makeLink(['url_key' => 'su2', 'single_use' => true, 'track_visits' => true]); + + $this->get('/s/su2')->assertRedirect(); + $this->get('/s/su2')->assertStatus(410); + }); + + it('re-fetch from DB prevents stale-cache bypass for single_use', function () { + // Manually prime the cache with is_enabled=true, + // then disable the link in DB. + config(['filament-short-url.cache_ttl' => 3600]); + + $link = makeLink(['url_key' => 'su-cache', 'single_use' => true]); + + // Warm cache (is_enabled=true in cache) + ShortUrl::findByKey('su-cache'); + + // Disable in DB directly (simulates another request having consumed it) + ShortUrl::where('id', $link->id)->update(['is_enabled' => false]); + + // isActive() must re-fetch from DB for single_use and return false + $link->refresh(); + $fresh = ShortUrl::findByKey('su-cache'); // still cached with enabled=true + expect($fresh)->not->toBeNull(); + expect($fresh->isActive())->toBeFalse(); // DB re-fetch must catch this + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 6. LINK OPTIONS β€” password protection +// ═══════════════════════════════════════════════════════════════════════════ + +describe('link option: password', function () { + it('shows password prompt to unauthenticated visitor', function () { + makeLink(['url_key' => 'pw1', 'password' => 'letmein']); + + $this->get('/s/pw1')->assertStatus(200)->assertSee('Password Required'); + }); + + it('redirects after correct password via session', function () { + makeLink(['url_key' => 'pw2', 'password' => 'open']); + + $this->post('/s/pw2', ['password' => 'open'])->assertRedirect('/s/pw2'); + $this->get('/s/pw2')->assertRedirect('https://example.com'); + }); + + it('returns error view on wrong password', function () { + makeLink(['url_key' => 'pw3', 'password' => 'right']); + + $this->post('/s/pw3', ['password' => 'wrong']) + ->assertStatus(200) + ->assertSee('Incorrect password'); + }); + + it('rate limits after 5 wrong password attempts', function () { + makeLink(['url_key' => 'pw-rl', 'password' => 'correct']); + + for ($i = 0; $i < 5; $i++) { + $this->post('/s/pw-rl', ['password' => 'wrong'])->assertStatus(200); + } + + $this->post('/s/pw-rl', ['password' => 'wrong'])->assertStatus(429); + }); + + it('password check happens before warning page', function () { + makeLink(['url_key' => 'pw-warn', 'password' => 'abc', 'show_warning_page' => true]); + + // Password prompt must appear first, not warning + $this->get('/s/pw-warn')->assertSee('Password Required')->assertDontSee('Security Redirect Warning'); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 7. LINK OPTIONS β€” show_warning_page +// ═══════════════════════════════════════════════════════════════════════════ + +describe('link option: show_warning_page', function () { + it('shows warning view without ?confirmed', function () { + makeLink(['url_key' => 'warn1', 'show_warning_page' => true]); + + $this->get('/s/warn1')->assertStatus(200)->assertSee('Security Redirect Warning'); + }); + + it('redirects with ?confirmed=1', function () { + makeLink(['url_key' => 'warn2', 'show_warning_page' => true]); + + $this->get('/s/warn2?confirmed=1')->assertRedirect('https://example.com'); + }); + + it('does not show warning when disabled', function () { + makeLink(['url_key' => 'no-warn', 'show_warning_page' => false]); + + $this->get('/s/no-warn')->assertRedirect('https://example.com'); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 8. SETTINGS OPTION β€” rate_limiting +// ═══════════════════════════════════════════════════════════════════════════ + +describe('settings option: rate_limiting', function () { + it('passes requests below the limit', function () { + config([ + 'filament-short-url.rate_limiting.enabled' => true, + 'filament-short-url.rate_limiting.max_attempts' => 5, + 'filament-short-url.rate_limiting.decay_seconds' => 60, + ]); + + makeLink(['url_key' => 'rl-pass']); + + $this->get('/s/rl-pass')->assertRedirect('https://example.com'); + }); + + it('blocks after limit is exceeded', function () { + config([ + 'filament-short-url.rate_limiting.enabled' => true, + 'filament-short-url.rate_limiting.max_attempts' => 2, + 'filament-short-url.rate_limiting.decay_seconds' => 10, + ]); + + makeLink(['url_key' => 'rl-block']); + + $this->get('/s/rl-block')->assertStatus(302); + $this->get('/s/rl-block')->assertStatus(302); + $this->get('/s/rl-block')->assertStatus(429); + }); + + it('does not rate-limit when disabled', function () { + config(['filament-short-url.rate_limiting.enabled' => false]); + + makeLink(['url_key' => 'rl-off']); + + for ($i = 0; $i < 10; $i++) { + $this->get('/s/rl-off')->assertStatus(302); + } + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 9. SETTINGS OPTION β€” geo_ip (headers driver) +// ═══════════════════════════════════════════════════════════════════════════ + +describe('settings option: geo_ip with headers driver', function () { + beforeEach(function () { + config([ + 'filament-short-url.queue_connection' => 'sync', + 'filament-short-url.geo_ip.enabled' => true, + 'filament-short-url.geo_ip.driver' => 'headers', + 'filament-short-url.trust_cdn_headers' => true, + ]); + }); + + it('resolves country from CF-IPCountry header', function () { + $link = makeLink(['url_key' => 'geo-cf', 'track_visits' => true]); + + $this->get('/s/geo-cf', ['CF-IPCountry' => 'FR']); + + expect($link->visits()->first()->country_code)->toBe('FR'); + }); + + it('resolves city from CF-IPCity header', function () { + $link = makeLink(['url_key' => 'geo-city', 'track_visits' => true]); + + $this->get('/s/geo-city', ['CF-IPCountry' => 'DE', 'CF-IPCity' => 'Berlin']); + + $visit = $link->visits()->first(); + expect($visit->country_code)->toBe('DE') + ->and($visit->city)->toBe('Berlin'); + }); + + it('stores null country when no geo header present and geo is enabled', function () { + config(['filament-short-url.trust_cdn_headers' => false]); + $link = makeLink(['url_key' => 'geo-null', 'track_visits' => true]); + + $this->get('/s/geo-null'); // no geo headers + + $visit = $link->visits()->first(); + expect($visit->country_code)->toBeNull(); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 10. SETTINGS OPTION β€” tracking fields +// ═══════════════════════════════════════════════════════════════════════════ + +describe('settings option: tracking fields', function () { + beforeEach(function () { + config(['filament-short-url.queue_connection' => 'sync']); + }); + + it('records ip_address when track_ip_address is enabled', function () { + $link = makeLink(['url_key' => 'tip-on', 'track_visits' => true, 'track_ip_address' => true]); + + $this->get('/s/tip-on', [], ['REMOTE_ADDR' => '5.6.7.8']); + + expect($link->visits()->first()->ip_address)->not->toBeNull(); + }); + + it('does not record ip_address when track_ip_address is disabled', function () { + $link = makeLink(['url_key' => 'tip-off', 'track_visits' => true, 'track_ip_address' => false]); + + $this->get('/s/tip-off'); + + expect($link->visits()->first()->ip_address)->toBeNull(); + }); + + it('records browser when track_browser is enabled', function () { + $link = makeLink(['url_key' => 'tb-on', 'track_visits' => true, 'track_browser' => true]); + + $this->get('/s/tb-on', ['User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36']); + + expect($link->visits()->first()->browser)->not->toBeNull(); + }); + + it('does not record browser when track_browser is disabled', function () { + $link = makeLink(['url_key' => 'tb-off', 'track_visits' => true, 'track_browser' => false]); + + $this->get('/s/tb-off', ['User-Agent' => 'Mozilla/5.0 Chrome/120']); + + expect($link->visits()->first()->browser)->toBeNull(); + }); + + it('records device_type when track_device_type is enabled', function () { + $link = makeLink(['url_key' => 'tdt-on', 'track_visits' => true, 'track_device_type' => true]); + + $this->get('/s/tdt-on', ['User-Agent' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)']); + + expect($link->visits()->first()->device_type)->toBe('mobile'); + }); + + it('does not record device_type when track_device_type is disabled', function () { + $link = makeLink(['url_key' => 'tdt-off', 'track_visits' => true, 'track_device_type' => false]); + + $this->get('/s/tdt-off', ['User-Agent' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)']); + + expect($link->visits()->first()->device_type)->toBeNull(); + }); + + it('records referer_url when track_referer_url is enabled', function () { + $link = makeLink(['url_key' => 'tref-on', 'track_visits' => true, 'track_referer_url' => true]); + + $this->get('/s/tref-on', ['Referer' => 'https://google.com/search?q=test']); + + expect($link->visits()->first()->referer_url)->toBe('https://google.com/search?q=test'); + }); + + it('does not record referer_url when track_referer_url is disabled', function () { + $link = makeLink(['url_key' => 'tref-off', 'track_visits' => true, 'track_referer_url' => false]); + + $this->get('/s/tref-off', ['Referer' => 'https://google.com/search?q=test']); + + expect($link->visits()->first()->referer_url)->toBeNull(); + }); + + it('records browser_language when enabled', function () { + $link = makeLink(['url_key' => 'tbl-on', 'track_visits' => true, 'track_browser_language' => true]); + + $this->get('/s/tbl-on', ['Accept-Language' => 'pl-PL,pl;q=0.9']); + + expect($link->visits()->first()->browser_language)->toBe('pl'); + }); + + it('does not record browser_language when disabled', function () { + $link = makeLink(['url_key' => 'tbl-off', 'track_visits' => true, 'track_browser_language' => false]); + + $this->get('/s/tbl-off', ['Accept-Language' => 'pl-PL,pl;q=0.9']); + + expect($link->visits()->first()->browser_language)->toBeNull(); + }); + + it('records no visit data at all when track_visits is false', function () { + $link = makeLink(['url_key' => 'no-track', 'track_visits' => false]); + + $this->get('/s/no-track'); + + expect($link->visits()->count())->toBe(0); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 11. SETTINGS OPTION β€” tracking_anonymize_ips +// ═══════════════════════════════════════════════════════════════════════════ + +describe('settings option: tracking_anonymize_ips', function () { + it('anonymizes ipv4 when enabled', function () { + config([ + 'filament-short-url.queue_connection' => 'sync', + 'filament-short-url.tracking.anonymize_ips' => true, + ]); + + $link = makeLink(['url_key' => 'anon-v4', 'track_visits' => true, 'track_ip_address' => true]); + + // withServerVariables sets REMOTE_ADDR correctly in the test client + $this->withServerVariables(['REMOTE_ADDR' => '192.168.10.55']) + ->get('/s/anon-v4'); + + expect($link->visits()->first()->ip_address)->toBe('192.168.10.0'); + }); + + it('hash is computed on raw ip before anonymization', function () { + config([ + 'filament-short-url.queue_connection' => 'sync', + 'filament-short-url.tracking.anonymize_ips' => true, + ]); + + $link = makeLink(['url_key' => 'anon-hash', 'track_visits' => true, 'track_ip_address' => true]); + + $this->withServerVariables(['REMOTE_ADDR' => '1.2.3.4']) + ->get('/s/anon-hash'); + + $visit = $link->visits()->first(); + // Stored IP is anonymized + expect($visit->ip_address)->toBe('1.2.3.0'); + // Hash is of the raw (pre-anonymization) IP + expect($visit->ip_hash)->toBe(hash_hmac('sha256', '1.2.3.4', config('app.key', ''))); + }); + + it('stores full ip when anonymize_ips is disabled', function () { + config([ + 'filament-short-url.queue_connection' => 'sync', + 'filament-short-url.tracking.anonymize_ips' => false, + ]); + + $link = makeLink(['url_key' => 'no-anon', 'track_visits' => true, 'track_ip_address' => true]); + + $this->withServerVariables(['REMOTE_ADDR' => '10.20.30.40']) + ->get('/s/no-anon'); + + expect($link->visits()->first()->ip_address)->toBe('10.20.30.40'); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 12. SETTINGS OPTION β€” trust_cdn_headers + IP extraction +// ═══════════════════════════════════════════════════════════════════════════ + +describe('settings option: trust_cdn_headers', function () { + it('reads CF-Connecting-IP when trust_cdn_headers is enabled', function () { + config([ + 'filament-short-url.queue_connection' => 'sync', + 'filament-short-url.geo_ip.enabled' => false, + 'filament-short-url.trust_cdn_headers' => true, + ]); + + $link = makeLink(['url_key' => 'cdn-ip', 'track_visits' => true, 'track_ip_address' => true]); + + $this->get('/s/cdn-ip', ['CF-Connecting-IP' => '11.22.33.44']); + + expect($link->visits()->first()->ip_address)->toBe('11.22.33.44'); + }); + + it('ignores CDN headers when trust_cdn_headers is disabled', function () { + config([ + 'filament-short-url.queue_connection' => 'sync', + 'filament-short-url.geo_ip.enabled' => false, + 'filament-short-url.trust_cdn_headers' => false, + ]); + + $link = makeLink(['url_key' => 'no-cdn-ip', 'track_visits' => true, 'track_ip_address' => true]); + + // Even with CF header, should use REMOTE_ADDR + $this->get('/s/no-cdn-ip', ['CF-Connecting-IP' => '99.99.99.99'], ['REMOTE_ADDR' => '127.0.0.1']); + + expect($link->visits()->first()->ip_address)->not->toBe('99.99.99.99'); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 13. SETTINGS OPTION β€” cache_ttl interaction with redirects +// ═══════════════════════════════════════════════════════════════════════════ + +describe('settings option: cache_ttl', function () { + it('short url is served from cache on hot path', function () { + config(['filament-short-url.cache_ttl' => 3600]); + + makeLink(['url_key' => 'cache-hot']); + + // First request primes cache + $this->get('/s/cache-hot')->assertStatus(302); + + // The cache key uses the request host β€” in tests that is 'localhost' + $cacheKey = 'filament-short-url:cache-hot:localhost'; + // Try both with and without app host suffix + $appHost = parse_url(config('app.url'), PHP_URL_HOST); + $found = cache()->has($cacheKey) || cache()->has("filament-short-url:cache-hot:{$appHost}"); + + expect($found)->toBeTrue(); + }); + + it('cache_ttl=0 disables caching', function () { + config(['filament-short-url.cache_ttl' => 0]); + + makeLink(['url_key' => 'no-cache-link']); + $this->get('/s/no-cache-link')->assertStatus(302); + + expect(cache()->has('filament-short-url:no-cache-link:localhost'))->toBeFalse(); + }); + + it('deleting a link clears cache', function () { + config(['filament-short-url.cache_ttl' => 3600]); + + $link = makeLink(['url_key' => 'del-cache']); + $this->get('/s/del-cache'); // warm cache + + $link->delete(); + + $this->get('/s/del-cache')->assertStatus(404); + }); + + it('updating a link clears cache', function () { + config(['filament-short-url.cache_ttl' => 3600]); + + $link = makeLink(['url_key' => 'upd-cache', 'destination_url' => 'https://old.example.com']); + $this->get('/s/upd-cache'); // warm cache + + $link->update(['destination_url' => 'https://new.example.com']); + + $this->get('/s/upd-cache')->assertRedirect('https://new.example.com'); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 14. SETTINGS OPTION β€” counter_buffering with file/array store +// ═══════════════════════════════════════════════════════════════════════════ + +describe('settings option: counter_buffering', function () { + beforeEach(function () { + config([ + 'filament-short-url.counter_buffering.enabled' => true, + 'filament-short-url.queue_connection' => 'sync', + ]); + }); + + it('buffers visit counts in cache instead of writing to db immediately', function () { + $link = makeLink(['url_key' => 'buf-1', 'track_visits' => true]); + $originalVisits = $link->total_visits; + + $this->get('/s/buf-1'); + + // DB should not have changed yet (buffered) + $link->refresh(); + expect($link->total_visits)->toBe($originalVisits); + + // Cache buffer should have count=1 + $prefix = config('filament-short-url.counter_buffering.cache_key_prefix', 'filament-short-url:buffer:'); + expect((int) cache()->get("{$prefix}total:{$link->id}"))->toBe(1); + }); + + it('sync-counters command flushes buffer to database', function () { + $link = makeLink(['url_key' => 'buf-sync', 'track_visits' => true]); + $link->update(['total_visits' => 0]); + + // Simulate 3 visits buffered + $prefix = config('filament-short-url.counter_buffering.cache_key_prefix', 'filament-short-url:buffer:'); + cache()->put("{$prefix}total:{$link->id}", 3); + cache()->put("{$prefix}unique:{$link->id}", 2); + cache()->forever("{$prefix}dirty_ids", [$link->id]); + + $this->artisan('short-url:sync-counters')->assertExitCode(0); + + $link->refresh(); + expect($link->total_visits)->toBe(3) + ->and($link->unique_visits)->toBe(2); + }); + + it('getRealTimeTotalVisits includes buffered counts', function () { + $link = makeLink(['url_key' => 'buf-rt']); + $link->update(['total_visits' => 10]); + + $prefix = config('filament-short-url.counter_buffering.cache_key_prefix', 'filament-short-url:buffer:'); + cache()->put("{$prefix}total:{$link->id}", 5); + + expect($link->getRealTimeTotalVisits())->toBe(15); + }); + + it('falls back to DB increment if cache throws on buffering', function () { + // Simulate a broken cache by replacing with a store that throws on increment + // We verify the fallback path (queue connection=sync) does not crash the redirect + + config(['filament-short-url.queue_connection' => 'sync']); + $link = makeLink(['url_key' => 'buf-fail', 'track_visits' => true]); + + // Even if buffering path has issues, redirect must still succeed + $this->get('/s/buf-fail')->assertStatus(302); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 15. SETTINGS OPTION β€” pruning +// ═══════════════════════════════════════════════════════════════════════════ + +describe('settings option: pruning', function () { + it('prunes visits older than retention_days', function () { + config([ + 'filament-short-url.pruning.enabled' => true, + 'filament-short-url.pruning.retention_days' => 7, + ]); + + $link = makeLink(['url_key' => 'prune-test']); + $oldVisit = $link->visits()->create([ + 'visited_at' => now()->subDays(10), + 'ip_hash' => 'abc123', + ]); + $recentVisit = $link->visits()->create([ + 'visited_at' => now()->subDays(3), + 'ip_hash' => 'def456', + ]); + + $this->artisan('short-url:aggregate-and-prune')->assertExitCode(0); + + $this->assertDatabaseMissing('short_url_visits', ['id' => $oldVisit->id]); + $this->assertDatabaseHas('short_url_visits', ['id' => $recentVisit->id]); + }); + + it('does not prune when pruning is disabled', function () { + config([ + 'filament-short-url.pruning.enabled' => false, + 'filament-short-url.pruning.retention_days' => 7, + ]); + + $link = makeLink(['url_key' => 'prune-off']); + $oldVisit = $link->visits()->create([ + 'visited_at' => now()->subDays(30), + 'ip_hash' => 'xyz789', + ]); + + $this->artisan('short-url:aggregate-and-prune')->assertExitCode(0); + + $this->assertDatabaseHas('short_url_visits', ['id' => $oldVisit->id]); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 16. LINK OPTION β€” destination_type split (A/B rotation) +// ═══════════════════════════════════════════════════════════════════════════ + +describe('link option: destination_type split (A/B rotation)', function () { + it('redirects to a variant when destination_type is split with 100% weight', function () { + makeLink([ + 'url_key' => 'split-100', + 'destination_type' => 'split', + 'rotation_variants' => [ + ['label' => 'Only', 'url' => 'https://variant-a.example.com', 'weight' => 100], + ], + ]); + + $this->get('/s/split-100')->assertRedirect('https://variant-a.example.com'); + }); + + it('falls back to destination_url when no variant matches (rotation_variants empty)', function () { + makeLink([ + 'url_key' => 'split-empty', + 'destination_type' => 'split', + 'destination_url' => 'https://fallback.example.com', + 'rotation_variants' => [], + ]); + + $this->get('/s/split-empty')->assertRedirect('https://fallback.example.com'); + }); + + it('legacy targeting rules with rotation type redirect correctly', function () { + makeLink([ + 'url_key' => 'legacy-rot', + 'targeting_rules' => [ + 'type' => 'rotation', + 'rotation' => [ + ['url' => 'https://rot-a.example.com', 'weight' => 100], + ], + ], + ]); + + $this->get('/s/legacy-rot')->assertRedirect('https://rot-a.example.com'); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 17. LINK OPTION β€” targeting rules (new multi-filter engine) +// ═══════════════════════════════════════════════════════════════════════════ + +describe('link option: targeting rules (new engine)', function () { + it('routes mobile users to mobile url', function () { + makeLink([ + 'url_key' => 'tgt-mobile', + 'targeting_rules' => [[ + 'match' => 'or', + 'url' => 'https://mobile.example.com', + 'filters' => [['type' => 'device', 'data' => ['devices' => ['mobile']]]], + ]], + ]); + + $this->get('/s/tgt-mobile', ['User-Agent' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)']) + ->assertRedirect('https://mobile.example.com'); + }); + + it('falls back to destination_url when device does not match', function () { + makeLink([ + 'url_key' => 'tgt-no-match', + 'targeting_rules' => [[ + 'match' => 'or', + 'url' => 'https://mobile.example.com', + 'filters' => [['type' => 'device', 'data' => ['devices' => ['mobile']]]], + ]], + ]); + + $this->get('/s/tgt-no-match', ['User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)']) + ->assertRedirect('https://example.com'); + }); + + it('AND rule only matches when ALL filters match', function () { + config(['filament-short-url.trust_cdn_headers' => true]); + + makeLink([ + 'url_key' => 'tgt-and', + 'targeting_rules' => [[ + 'match' => 'and', + 'url' => 'https://and-match.example.com', + 'filters' => [ + ['type' => 'country', 'data' => ['countries' => ['PL']]], + ['type' => 'device', 'data' => ['devices' => ['mobile']]], + ], + ]], + ]); + + // Both match β†’ redirect + $this->get('/s/tgt-and', [ + 'CF-IPCountry' => 'PL', + 'User-Agent' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)', + ])->assertRedirect('https://and-match.example.com'); + + // Only country β†’ fallback + $this->get('/s/tgt-and', [ + 'CF-IPCountry' => 'PL', + 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + ])->assertRedirect('https://example.com'); + }); + + it('OR rule matches when ANY filter matches', function () { + config(['filament-short-url.trust_cdn_headers' => true]); + + makeLink([ + 'url_key' => 'tgt-or', + 'targeting_rules' => [[ + 'match' => 'or', + 'url' => 'https://or-match.example.com', + 'filters' => [ + ['type' => 'country', 'data' => ['countries' => ['PL']]], + ['type' => 'device', 'data' => ['devices' => ['mobile']]], + ], + ]], + ]); + + // Only country matches + $this->get('/s/tgt-or', [ + 'CF-IPCountry' => 'PL', + 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0)', + ])->assertRedirect('https://or-match.example.com'); + }); + + it('language filter routes correctly', function () { + makeLink([ + 'url_key' => 'tgt-lang', + 'targeting_rules' => [[ + 'match' => 'or', + 'url' => 'https://pl.example.com', + 'filters' => [['type' => 'language', 'data' => ['languages' => ['pl']]]], + ]], + ]); + + $this->get('/s/tgt-lang', ['Accept-Language' => 'pl-PL,pl;q=0.9']) + ->assertRedirect('https://pl.example.com'); + + $this->get('/s/tgt-lang', ['Accept-Language' => 'en-US,en;q=0.9']) + ->assertRedirect('https://example.com'); + }); + + it('platform filter routes correctly', function () { + makeLink([ + 'url_key' => 'tgt-plat', + 'targeting_rules' => [[ + 'match' => 'or', + 'url' => 'https://ios.example.com', + 'filters' => [['type' => 'platform', 'data' => ['platforms' => ['ios']]]], + ]], + ]); + + $this->get('/s/tgt-plat', ['User-Agent' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15']) + ->assertRedirect('https://ios.example.com'); + + $this->get('/s/tgt-plat', ['User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)']) + ->assertRedirect('https://example.com'); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 18. LINK OPTION β€” GA tracking ID (does not break redirect) +// ═══════════════════════════════════════════════════════════════════════════ + +describe('link option: ga_tracking_id', function () { + it('redirect succeeds when ga_tracking_id is set but no GA4 api secret configured', function () { + config(['filament-short-url.queue_connection' => 'sync']); + config(['filament-short-url.ga4.api_secret' => null]); + + makeLink([ + 'url_key' => 'ga-no-secret', + 'track_visits' => true, + 'ga_tracking_id' => 'G-TESTID', + ]); + + // Should redirect without throwing β€” GA4 call skipped silently + $this->get('/s/ga-no-secret')->assertStatus(302); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 19. LINK OPTION β€” notes (internal, not exposed in redirect) +// ═══════════════════════════════════════════════════════════════════════════ + +describe('link option: notes', function () { + it('notes field is stored and does not affect redirect behavior', function () { + $link = makeLink(['url_key' => 'notes-test', 'notes' => 'Internal reminder']); + + expect($link->notes)->toBe('Internal reminder'); + $this->get('/s/notes-test')->assertRedirect('https://example.com'); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 20. CONFLICT / INTERACTION checks +// ═══════════════════════════════════════════════════════════════════════════ + +describe('option interactions and conflict scenarios', function () { + it('expired link is not affected by is_enabled=true', function () { + makeLink([ + 'url_key' => 'conflict-exp', + 'is_enabled' => true, // explicitly enabled + 'expires_at' => now()->subSecond(), // but expired + ]); + + $this->get('/s/conflict-exp')->assertStatus(410); + }); + + it('not-yet-activated link returns 410 even with is_enabled=true', function () { + makeLink([ + 'url_key' => 'conflict-act', + 'is_enabled' => true, + 'activated_at' => now()->addHour(), + ]); + + $this->get('/s/conflict-act')->assertStatus(410); + }); + + it('single_use link with password shows password prompt on first visit', function () { + makeLink(['url_key' => 'su-pw', 'single_use' => true, 'password' => 'abc']); + + // Should show password prompt, NOT redirect + disable + $this->get('/s/su-pw')->assertSee('Password Required'); + + // Link must still be enabled (not consumed by the password prompt visit) + expect(ShortUrl::where('url_key', 'su-pw')->value('is_enabled'))->toBeTrue(); + }); + + it('warning page + max_visits: warning shown first, max_visits enforced after confirm', function () { + config(['filament-short-url.queue_connection' => 'sync']); + + $link = makeLink([ + 'url_key' => 'warn-mv', + 'show_warning_page' => true, + 'max_visits' => 1, + 'track_visits' => true, + ]); + $link->update(['total_visits' => 0]); + + // First unconfirmed visit: warning page shown, visit NOT counted + $this->get('/s/warn-mv')->assertSee('Security Redirect Warning'); + + // Confirm: redirect succeeds, visit is tracked + $this->get('/s/warn-mv?confirmed=1')->assertRedirect('https://example.com'); + + // Now at max_visits=1. Next confirmed visit should 410 + $this->get('/s/warn-mv?confirmed=1')->assertStatus(410); + }); + + it('rate limiting + geo targeting: both apply independently', function () { + config([ + 'filament-short-url.rate_limiting.enabled' => true, + 'filament-short-url.rate_limiting.max_attempts' => 3, + 'filament-short-url.rate_limiting.decay_seconds' => 60, + 'filament-short-url.trust_cdn_headers' => true, + ]); + + makeLink([ + 'url_key' => 'rl-geo', + 'targeting_rules' => [[ + 'match' => 'or', + 'url' => 'https://pl-target.example.com', + 'filters' => [['type' => 'country', 'data' => ['countries' => ['PL']]]], + ]], + ]); + + // Under rate limit: targeting works + $this->get('/s/rl-geo', ['CF-IPCountry' => 'PL'])->assertRedirect('https://pl-target.example.com'); + $this->get('/s/rl-geo', ['CF-IPCountry' => 'PL'])->assertRedirect('https://pl-target.example.com'); + $this->get('/s/rl-geo', ['CF-IPCountry' => 'PL'])->assertRedirect('https://pl-target.example.com'); + + // Rate limited + $this->get('/s/rl-geo', ['CF-IPCountry' => 'PL'])->assertStatus(429); + }); + + it('forward_query_params does not interfere with UTM tracking', function () { + config(['filament-short-url.queue_connection' => 'sync']); + + $link = makeLink([ + 'url_key' => 'fwd-utm', + 'destination_url' => 'https://example.com', + 'forward_query_params' => true, + 'track_visits' => true, + 'track_referer_url' => true, + ]); + + $this->get('/s/fwd-utm?utm_source=test&utm_medium=email', [ + 'Referer' => 'https://newsletter.example.com', + ]); + + $visit = $link->visits()->first(); + expect($visit->utm_source)->toBe('test') + ->and($visit->utm_medium)->toBe('email') + ->and($visit->referer_url)->toBe('https://newsletter.example.com'); + + // Also verify the redirect includes the forwarded params + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 21. PERFORMANCE: no N+1 queries on redirect hot path +// ═══════════════════════════════════════════════════════════════════════════ + +describe('performance: redirect hot path', function () { + it('serves redirect with at most 2 DB queries when model is cached', function () { + config([ + 'filament-short-url.cache_ttl' => 3600, + 'filament-short-url.queue_connection' => 'sync', + ]); + + makeLink(['url_key' => 'perf-hot', 'track_visits' => false]); + + // Warm the cache + $this->get('/s/perf-hot'); + + // On second hit, model comes from cache + // We can only measure DB queries via DB::getQueryLog() + DB::enableQueryLog(); + + $this->get('/s/perf-hot')->assertStatus(302); + + $queries = DB::getQueryLog(); + + // On a pure cache hit with no tracking: 0 DB queries expected. + // With single_use re-fetch: 1 DB query max. + // With tracking (sync): additional queries for inserting visit. + // Here track_visits=false so 0 DB queries on cache hit. + expect(count($queries))->toBeLessThanOrEqual(1); + + DB::disableQueryLog(); + }); +}); diff --git a/tests/Feature/ShortUrlStatsEnhancementsTest.php b/tests/Feature/ShortUrlStatsEnhancementsTest.php new file mode 100644 index 0000000..d87a1f3 --- /dev/null +++ b/tests/Feature/ShortUrlStatsEnhancementsTest.php @@ -0,0 +1,260 @@ +create(array_merge([ + 'destination_url' => 'https://example.com', + ], $attrs)); + } +} + +it('calculates period over period trends in stats overview', function () { + $link = createStatsLink(); + + // Current period (today): 10 visits + for ($i = 0; $i < 10; $i++) { + $link->visits()->create([ + 'ip_address' => '1.2.3.'.$i, + 'ip_hash' => hash('sha256', '1.2.3.'.$i), + 'visited_at' => now(), + ]); + } + + // Previous period (35 days ago): 5 visits + for ($i = 0; $i < 5; $i++) { + $link->visits()->create([ + 'ip_address' => '5.6.7.'.$i, + 'ip_hash' => hash('sha256', '5.6.7.'.$i), + 'visited_at' => now()->subDays(35), + ]); + } + + $widget = new ShortUrlStatsOverview; + $widget->record = $link; + $widget->dateFrom = now()->subDays(29)->toDateString(); + $widget->dateTo = now()->toDateString(); + + // Trigger the protected getStats() method reflection + $ref = new ReflectionMethod($widget, 'getStats'); + $ref->setAccessible(true); + $stats = $ref->invoke($widget); + + // Total clicks current = 10, previous = 5 -> +100.0% trend description + $totalStat = $stats[0]; + expect($totalStat->getDescription())->toContain('+100.0%'); + expect($totalStat->getDescriptionColor())->toBe('success'); + + // Unique visits current = 10, previous = 5 -> +100.0% trend description + $uniqueStat = $stats[1]; + expect($uniqueStat->getDescription())->toContain('+100.0%'); +}); + +it('groups chart data by weekly and monthly granularity', function () { + $link = createStatsLink(); + + // Create a visit 10 days ago and another today + $link->visits()->create([ + 'ip_address' => '1.1.1.1', + 'visited_at' => now()->subDays(10), + ]); + $link->visits()->create([ + 'ip_address' => '2.2.2.2', + 'visited_at' => now(), + ]); + + $widget = new ShortUrlVisitsChart; + $widget->record = $link; + $widget->dateFrom = now()->subDays(29)->toDateString(); + $widget->dateTo = now()->toDateString(); + + // Test weekly + $widget->filter = 'weekly'; + $ref = new ReflectionMethod($widget, 'getData'); + $ref->setAccessible(true); + $weeklyData = $ref->invoke($widget); + + expect(count($weeklyData['datasets'][0]['data']))->toBeGreaterThan(0); + + // Test monthly + $widget->filter = 'monthly'; + $monthlyData = $ref->invoke($widget); + expect(count($monthlyData['datasets'][0]['data']))->toBeGreaterThan(0); +}); + +it('memoizes statistics in-memory and allows selective cache clearing', function () { + $link = createStatsLink(); + $link->visits()->create([ + 'ip_address' => '1.2.3.4', + 'visited_at' => now(), + ]); + + // Retrieve stats (caches it) + $stats1 = $link->getCachedStats(); + expect($stats1['totalVisits'])->toBe(1); + + // Add a new visit directly to DB (bypassing cache increment) + $link->visits()->create([ + 'ip_address' => '5.6.7.8', + 'visited_at' => now(), + ]); + + // Retrieve stats again (should hit in-memory memoization / cache, returning 1) + $stats2 = $link->getCachedStats(); + expect($stats2['totalVisits'])->toBe(1); + + // Clear stats cache + $link->clearStatsCache(); + + // Retrieve stats again (should compute fresh, returning 2) + $stats3 = $link->getCachedStats(); + expect($stats3['totalVisits'])->toBe(2); +}); + +it('dynamically defaults chart granularity based on date range', function () { + $link = createStatsLink(); + + $widget = new ShortUrlVisitsChart; + $widget->record = $link; + + // <= 1 day -> hourly + $widget->dateFrom = now()->toDateString(); + $widget->dateTo = now()->toDateString(); + $widget->mount(); + expect($widget->filter)->toBe('hourly'); + + // <= 30 days -> daily + $widget->dateFrom = now()->subDays(29)->toDateString(); + $widget->dateTo = now()->toDateString(); + $widget->mount(); + expect($widget->filter)->toBe('daily'); + + // <= 90 days -> weekly + $widget->dateFrom = now()->subDays(89)->toDateString(); + $widget->dateTo = now()->toDateString(); + $widget->mount(); + expect($widget->filter)->toBe('weekly'); + + // > 90 days -> monthly + $widget->dateFrom = now()->subDays(120)->toDateString(); + $widget->dateTo = now()->toDateString(); + $widget->mount(); + expect($widget->filter)->toBe('monthly'); +}); + +it('calculates data for geo breakdown widget', function () { + $link = createStatsLink(); + $link->visits()->create([ + 'ip_address' => '1.2.3.4', + 'city' => 'Warsaw', + 'country_code' => 'PL', + 'browser_language' => 'pl', + 'visited_at' => now(), + ]); + + $widget = new ShortUrlGeoBreakdownWidget; + $widget->record = $link; + $widget->dateFrom = now()->subDays(2)->toDateString(); + $widget->dateTo = now()->toDateString(); + $widget->loadedTabs = ['countries' => true, 'cities' => true, 'languages' => true]; + + $ref = new ReflectionMethod($widget, 'getViewData'); + $ref->setAccessible(true); + $data = $ref->invoke($widget); + + expect($data['visitsByCountry'])->toBe(['PL' => 1]) + ->and($data['visitsByCity'])->toBe(['Warsaw (PL)' => 1]) + ->and($data['visitsByLanguage'])->toBe(['pl' => 1]); +}); + +it('calculates data for device breakdown widget', function () { + $link = createStatsLink(); + $link->visits()->create([ + 'ip_address' => '1.2.3.4', + 'device_type' => 'mobile', + 'browser' => 'Chrome', + 'operating_system' => 'Android', + 'visited_at' => now(), + ]); + + $widget = new ShortUrlDeviceBreakdownWidget; + $widget->record = $link; + $widget->dateFrom = now()->subDays(2)->toDateString(); + $widget->dateTo = now()->toDateString(); + $widget->loadedTabs = ['devices' => true, 'browsers' => true, 'os' => true]; + + $ref = new ReflectionMethod($widget, 'getViewData'); + $ref->setAccessible(true); + $data = $ref->invoke($widget); + + expect($data['visitsByDevice'])->toBe(['mobile' => 1]) + ->and($data['visitsByBrowser'])->toBe(['Chrome' => 1]) + ->and($data['visitsByOs'])->toBe(['Android' => 1]); +}); + +it('calculates data for traffic sources breakdown widget', function () { + $link = createStatsLink(); + $link->visits()->create([ + 'ip_address' => '1.2.3.4', + 'referer_host' => 'facebook.com', + 'utm_source' => 'fb', + 'utm_medium' => 'cpc', + 'utm_campaign' => 'summer', + 'utm_term' => 'yacht', + 'utm_content' => 'ad1', + 'visited_at' => now(), + ]); + + $widget = new ShortUrlSourcesBreakdownWidget; + $widget->record = $link; + $widget->dateFrom = now()->subDays(2)->toDateString(); + $widget->dateTo = now()->toDateString(); + $widget->loadedTabs = ['referrers' => true, 'utm_sources' => true, 'utm_mediums' => true, 'utm_campaigns' => true, 'utm_terms' => true, 'utm_contents' => true]; + + $ref = new ReflectionMethod($widget, 'getViewData'); + $ref->setAccessible(true); + $data = $ref->invoke($widget); + + expect($data['visitsByReferer'])->toBe(['Facebook' => 1]) + ->and($data['utmSources'])->toBe(['fb' => 1]) + ->and($data['utmMediums'])->toBe(['cpc' => 1]) + ->and($data['utmCampaigns'])->toBe(['summer' => 1]) + ->and($data['utmTerms'])->toBe(['yacht' => 1]) + ->and($data['utmContents'])->toBe(['ad1' => 1]); +}); + +it('calculates data for security breakdown widget', function () { + $link = createStatsLink(); + $link->visits()->create([ + 'ip_address' => '1.2.3.4', + 'is_bot' => true, + 'is_proxy' => true, + 'visited_at' => now(), + ]); + + $widget = new ShortUrlSecurityBreakdownWidget; + $widget->record = $link; + $widget->dateFrom = now()->subDays(2)->toDateString(); + $widget->dateTo = now()->toDateString(); + $widget->loadedTabs = ['bots' => true, 'vpn' => true]; + + $ref = new ReflectionMethod($widget, 'getViewData'); + $ref->setAccessible(true); + $data = $ref->invoke($widget); + + expect($data['totalClicks'])->toBe(1) + ->and($data['botClicks'])->toBe(1) + ->and($data['proxyClicks'])->toBe(1); +});