67 Commits

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

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

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

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

## Docs
- README.md: corrected API scopes/rate-limiting docs (features were already implemented)
- README.md: added Folders, Tags, Archiving to features list
- README.md: added v5.0.0 changelog entry
2026-06-06 02:16:07 +02:00
Bartłomiej Janczak
aa05f026b5 feat: speed optimization, portability fixes, and documentation update for DNS resolution 2026-06-05 09:15:04 +02:00
Bartłomiej Janczak
15d992c5ad v3.3.0: Advanced Multi-Filter Targeting, Strict API Validation, Clean Configs, UX Improvements 2026-06-04 15:45:14 +02:00
Bartłomiej Janczak
719901d4c6 feat: expand developer REST API, modularize logo controller, and update README for v3.2.0 2026-06-04 14:29:07 +02:00
Bartłomiej Janczak
3b9411808e feat: Release v3.1.0 - Performance, Security, and Scalability updates 2026-06-04 12:53:54 +02:00
Bartłomiej Janczak
3a9575f5b1 Optimize redirect performance and protect password validation against brute force 2026-06-04 12:26:21 +02:00
Bartłomiej Janczak
325a38bfe8 Update license badge color to violet in README 2026-06-04 12:09:40 +02:00
Bartłomiej Janczak
1cd85af0a1 Update license to proprietary source-available and add copyright headers 2026-06-04 12:07:31 +02:00
Bartłomiej Janczak
137a81dcab feat: Release v3.0.0 — Native App Linking, Deep Linking, Pixels Registry, and Custom Expiry Pages 2026-06-03 17:21:03 +02:00
Bartłomiej Janczak
276b75ad9f docs: Update README for v2.1.0 registry and language features 2026-06-03 14:57:45 +02:00
Bartłomiej Janczak
d8d6b1b22f feat: Implement Centralized Retargeting Pixels Registry (v2.1.0) 2026-06-03 14:55:46 +02:00
Bartłomiej Janczak
0d4eab2016 docs/feat: Add targeting and security fields support to API and README docs 2026-06-03 14:28:57 +02:00
Bartłomiej Janczak
0349825890 Add browser language-based redirect targeting strategy, localize language names, and clean up translations 2026-06-03 14:24:08 +02:00
Bartłomiej Janczak
e1e6ded015 Update README to document empty route prefix and automatic scheduler registration 2026-06-03 14:12:05 +02:00
Bartłomiej Janczak
c5147ac170 Optimize visit aggregation, prevent stale cache in limits, fix Redis exist check, edge mobile detection, and scheduling 2026-06-03 14:10:14 +02:00
Bartłomiej Janczak
0cdc4ad0db Update helper texts for route prefix and redirect status code 2026-06-03 14:01:19 +02:00
Bartłomiej Janczak
5e2b266793 Allow route prefix to be empty for root domain short URLs 2026-06-03 13:58:09 +02:00
Bartłomiej Janczak
26247fd94a Release v2.0.0: SVG QR Code Designer, visit counter buffering, Geo-IP and browser language tracking, dashboard redesign 2026-06-03 13:52:31 +02:00
Bartłomiej Janczak
f3623df340 feat: show share modal after creating a new short URL 2026-06-02 20:54:33 +02:00
Bartłomiej Janczak
48ecb8ed28 docs: add authorizeSettingsUsing documentation and v1.7.0 changelog entry 2026-06-02 20:39:19 +02:00
Bartłomiej Janczak
d67bd56f68 fix: canAccess signature compatible with Filament Page parent class 2026-06-02 20:37:29 +02:00
Bartłomiej Janczak
d075ee83b0 feat: hide settings button in table header for unauthorized users 2026-06-02 20:34:03 +02:00
Bartłomiej Janczak
67ce293685 Optimize counter buffering, add reactive settings callouts, and resolve audit bugs 2026-06-02 19:33:19 +02:00
Bartłomiej Janczak
ce8062036a Configure global webhook toggle, emerald prefix padlock, and key length range 3-20 2026-06-02 18:52:46 +02:00
Bartłomiej Janczak
e8e9f26c9b Add queue worker placeholder info callout 2026-06-02 18:39:39 +02:00
Bartłomiej Janczak
da810db511 Make queue_name dynamically required based on queue connection 2026-06-02 18:35:43 +02:00
Bartłomiej Janczak
8de83e3973 Hide Queue Name field when sync connection is selected 2026-06-02 18:27:59 +02:00
Bartłomiej Janczak
a5421a2d30 Add missing security v2 and world map translation keys (EN and PL) 2026-06-02 18:25:41 +02:00
Bartłomiej Janczak
d1eb322cb6 Add visit logs screenshot (5.png) to README 2026-06-02 18:21:56 +02:00
Bartłomiej Janczak
3d62dbcbb7 Improve tooltip hover triggers using event delegation 2026-06-02 18:17:27 +02:00
Bartłomiej Janczak
c650c1f205 Fix world map visual display, dark mode support and tooltips 2026-06-02 18:13:38 +02:00
Bartłomiej Janczak
34e4339c7c Release v1.6.0: Google Safe Browsing integration, VPN/Proxy/Bot filtering, and Visitor World Map widget 2026-06-02 17:56:16 +02:00
Bartłomiej Janczak
e847328b9b UX: Dynamically show/hide Developer API Keys section based on REST API enable state 2026-06-02 13:22:35 +02:00
Bartłomiej Janczak
911e98a2e3 Release v1.5.1: Rest API toggle, webhook fault tolerance, time-boundary aggregation fix, pixel loading UI, and resolveRedirectUrl targeting rules alignment 2026-06-02 13:17:42 +02:00
Bartłomiej Janczak
bdef0b6687 feat: add api_enabled on/off toggle in settings + update README for v1.5 2026-06-02 11:10:10 +02:00
Bartłomiej Janczak
e0b7e3f28c feat: add social retargeting pixels, developer REST API, and webhooks system 2026-06-02 09:44:53 +02:00
Bartłomiej Janczak
e29c3ef24e fix: resolve real-time visit limits under counter buffering and enforce rotation weight constraints 2026-06-02 09:27:06 +02:00
Bartłomiej Janczak
b1416fbe86 chore: exclude art and tests folders from composer distribution using .gitattributes 2026-06-02 09:18:03 +02:00
Bartłomiej Janczak
59e4fc0e6b v1.4.0: Add validity ranges, visit limit counters, custom redirects, and reactive controls 2026-06-02 09:16:47 +02:00
Bartłomiej Janczak
0e371ad139 Fix database compatibility: use database-agnostic boolean check in case statement for PostgreSQL 2026-06-02 07:55:22 +02:00
Bartłomiej Janczak
e5437dd0a6 Audit fixes: device targeting, query index optimizations, translation gaps, rate-limiting, and path consistency 2026-06-02 07:51:45 +02:00
312 changed files with 57824 additions and 3095 deletions

4
.gitattributes vendored Normal file
View File

@@ -0,0 +1,4 @@
/art export-ignore
/tests export-ignore
/.gitattributes export-ignore
/.gitignore export-ignore

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

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

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

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

View File

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

3
.gitignore vendored
View File

@@ -9,3 +9,6 @@
*.log
.DS_Store
Thumbs.db
# Internal maintainer notes (not shipped)
/AUDIT_IMPLEMENTATION.md

363
CHANGELOG.md Normal file
View File

@@ -0,0 +1,363 @@
# Changelog
All notable changes to **janczakb/filament-short-url** are documented here.
Recent releases (v5.x) are summarized in [README.md](README.md#changelog). This file contains the full history for older versions and the complete **v5.2.0** release notes (including enterprise hardening follow-up, signed 2026-06-08).
---
## v5.2.3
> **Production Redis mode, stats scaling for high traffic, and Settings diagnostics.**
### Settings → Redis & queue diagnostics
- **Redis connection in Settings** — When **Queue Connection = `redis`**, configure host, port, password, database, and key prefix in **Settings → General**. Values are stored in `short_url_settings` and **override** `database.redis` + `queue.connections.redis` at runtime (independent of `CACHE_STORE`).
- **Test Redis connection** — PING + pipelined `INCR`/`SADD` probe (same Redis primitives as visit counters). Uses unsaved form values via preview overrides.
- **Test queue worker** — Dispatches `VerifyQueueWorkerJob` on the selected connection/queue and waits up to 12 s for a worker to process it; reports the exact `php artisan queue:work {connection} --queue={name}` command on failure.
- **Unified queue callout** — Single info box per async driver with a **dynamic** worker command from current Settings (no duplicate boxes; hidden entirely for `sync`).
- **`withPreviewSettings()`** — Settings test buttons apply form state temporarily without saving.
### Stats scaling (`queue_connection = redis`)
- **`StatsScalingProfile`** — Central profile: auto counter buffering, dedicated Redis counters, Redis today buffer, SQL micro-cache for sync mode.
- **`PluginRedisConnection`** — Resolves Redis from `queue.connections.redis` (phpredis/Predis), not from `CACHE_STORE`.
- **`VisitCounterBuffer`** — Unified buffered totals/uniques: dedicated Redis path when Settings queue is `redis`, Laravel cache path for sync + manual toggle.
- **`TodayStatsBuffer`** — Pipelined Redis counters for today's summary and hourly charts; merged live in `HasStatsCache` without busting historical stats cache on every click.
- **`StatsVisitRecorder`** — Hot-path stats side effects extracted from the visit job; **`TrackShortUrlVisitJob`** no longer clears full stats cache per visit.
- **`SyncBufferedCountersCommand`** — Refactored to flush via `VisitCounterBuffer` (Redis dirty-set + cache path).
- **Live feed** — `LiveFeedBroadcaster` prefers plugin queue Redis over cache-store Redis (unchanged priority, now shares infra with counters/stats when queue is `redis`).
### Config & migrations
- **`config/filament-short-url.php`** — New `redis.*` defaults (`REDIS_HOST`, `REDIS_PORT`, …) used as fallbacks before Settings overrides.
- **Service provider** — All package migrations registered in `hasMigrations()` including `2026_06_10_000001` and `2026_06_11_000001`.
### Tests
- **`tests/Feature/StatsScalingTest.php`** — Redis queue profile, today buffer, counter buffering.
- **`tests/Feature/RedisSettingsTest.php`** — Config overrides, masked password preservation, sync worker test.
- **`tests/Unit/PluginRedisConnectionTesterTest.php`** — Redis health-check service.
---
## v5.2.2
> **Enterprise hardening, airtight visit counters, trait refactor, and i18n audit.**
### Security
- **XSS** — `@json()` in pixel interstitial and Alpine stats widgets (replaces `addslashes()`).
- **Atomic `max_visits`** — `VisitSlotReservation::reserveAtomicSlot()` with fast-path SQL and pessimistic lock near cap.
- **Single-use bots** — BotDetector gate before consuming single-use links.
- **Buffer double-count** — `revertBufferedIncrements()` before DB fallback in `HasVisitCounters`.
- **API tenancy** — `owner_user_id` required on API keys when `scope_links_to_user` is enabled.
- **IDOR** — `ResourceOwnershipValidator` for folder/tag/pixel on API writes.
- **Redirect pipeline** — Limits before app interstitial; caps enforced when `track_visits=false`.
### Performance
- **`max_visits_pessimistic_remaining`** config (default 5).
- **`LiveFeedBroadcaster`** — Redis source priority: plugin **Settings → Queue Connection = redis** first, then `CACHE_STORE=redis`; pub/sub push with PhpRedis, SSE poll fallback otherwise.
- **`StatsCacheHelper`** — `Cache::lock` on stats miss.
- **`preloadBufferedCountersForIds()`** — Batch buffer reads in list tables.
### Quality
- **Public stats** — Uniform 404; public-safe response subset.
- **Octane** — A/B variant on request attributes.
- **Filtered uniques** — Rollup merge in `FilteredStatsCollector`.
- **`findByKey()`** — `domain_scope_id` index path.
### Maintainability
- **`HasStats` facade** — `HasVisitCounters`, `HasStatsCache`, `HasStatsQueries`, `HasSecurityStats`.
- **i18n** — API, redirect, and visitor-facing strings use translation keys (EN + PL).
### Tests
- **`tests/Feature/WorldClassHardeningTest.php`** — Comprehensive audit regression suite.
---
## v5.2.1
> **Audit roadmap follow-up** — Authorization policy, API stats parity, SSE live feed, load baselines, and hardening tests.
### Authorization
- **`ShortUrlPolicy`** — `view`, `update`, `delete`, `restore`, `forceDelete`, and `manageSettings` aligned with `scope_links_to_user` / `user_id`. Auto-registered in `FilamentShortUrlServiceProvider` when the host has no custom policy.
- **Stats page** — `ViewShortUrlStats` calls `authorize('view', $record)`.
### REST API
- **Filtered stats** — `GET /links/{idOrKey}/stats` passes query filters to `FilteredStatsCollector` (same dimensions as the Filament stats dashboard).
- **`ApiStatsFilterParser`** — Parses `CrossDimensionalStatsEngine::FILTER_KEYS` plus aliases `country``country_code`, `device``device_type`.
- **Response meta** — Returns `meta.date_from`, `meta.date_to`, and `meta.filters`.
### Live feed (SSE)
- **`ShortUrlLiveFeedStreamController`** — Authenticated SSE endpoint at `GET /short-url/live-feed/{shortUrl}/stream`.
- **Widget** — Alpine `EventSource` + `ShortUrlLiveFeedWidget::onStreamUpdate()` replaces Livewire polling.
- **Config** — `live_feed.sse_interval_seconds` (default 3), `live_feed.sse_max_duration_seconds` (default 120).
### Load & stress baselines
- **`php artisan short-url:stress-redirect {key}`** — In-process redirect benchmark (avg / min / p95 / max ms).
- **`scripts/k6/redirect-baseline.js`** — k6 HTTP load script with setup probe, configurable VUs/duration, and p95 thresholds.
### Tests
- **`tests/Feature/RoadmapHardeningTest.php`** — Policy scoping, API stats filters, webhook HMAC E2E via `Http::fake()`, `max_visits` burst guard, SSE stream authorization, stress command smoke test.
---
## v5.2.0
> **Main release theme: SEO & Social + integration hardening** — Open Graph metadata, link cloaking, search indexing control, expanded REST API, production security fixes, and **enterprise hardening** (GA4 MP, Geo-IP, custom domain DNS gate).
### Stats & security hardening (audit follow-up)
- **Filtered stats optimizer** — `FilteredStatsCollector` replaces 15+ raw GROUP BY queries with daily JSON rollups + single summary + cursor pass.
- **Cross-dimensional daily rollups** — `CrossDimensionalStatsEngine` stores single- and two-filter slices in `short_url_daily_stats` (`cross_dimensional_stats`, `cross_filter_pairs`, `filter_qr_counts`); aggregation uses one cursor pass instead of 11 GROUP BY queries; re-aggregation backfills NULL cross columns.
- **Stats cache helper** — `StatsCacheHelper` wraps remember/forget with driver-agnostic fallbacks (file, database, Redis, Memcached, array).
- **Security widget cache** — `getSecurityBreakdownStats()` with daily `all_visits_count` / `bot_visits_count` / `proxy_visits_count` rollups.
- **World map** — Always routes through `getCachedStats()`.
- **VPN fail-closed** — When `block_with_403`, detection timeout/error blocks access.
- **Public stats password** — Accepted only via POST body or `Authorization` header (not query string).
- **Custom domain legacy URLs** — `/s/{key}` → 301 → `/{key}` on verified custom domains.
### SEO, Open Graph & Link Cloaking
- **New SEO & Social tab** — `og_title`, `og_description`, `og_image`, `do_index`, `is_cloaked`, live social preview sidebar.
- **Custom Open Graph meta** — Per-link OG/Twitter tags for Facebook, LinkedIn, X, Slack, WhatsApp, and other preview bots.
- **Search Engine Indexing (`do_index`)** — Per-link toggle; when off, all responses emit `noindex, nofollow`.
- **Link Cloaking (`is_cloaked`)** — Iframe embedding on your short-link domain for humans; standalone OG page for crawlers.
- **OG meta scraper** — SSRF-safe streaming fetch (`GET /short-url/scrape-meta`).
- **OG image pipeline** — WebP conversion, temp storage, promotion to permanent disk on save.
- **Iframeable pre-check** — `POST /short-url/check-iframeable` with redirect-chain validation.
### REST API
- **Bulk create** — `POST /api/short-url/links/bulk` (max 100).
- **Upsert** — `PUT /api/short-url/links/upsert` by `external_id` or `url_key` + `destination_url`.
- **Helpers** — `GET /links/exists`, `/links/random`, `/links/info`.
- **CSV export** — `GET /links/{idOrKey}/visits/export`.
- **Tags & Folders CRUD** — `/api/short-url/tags`, `/api/short-url/folders`.
- **Per-key owner scoping** — Optional `owner_user_id` on API keys.
- **Link-level UTM** — `utm_*` and `ref` stored on the link and merged on redirect.
- **Expanded serializer** — `external_id`, UTM fields, tags, folder, archive, public stats flags.
- **Visits, bulk delete/update** — Paginated visits log, bulk delete by IDs/keys, bulk patch.
### Public Stats
- **Shareable endpoint** — `GET /short-url/public-stats/{url_key}` with optional password (`public_stats_enabled`, `public_stats_password`).
### Security & Reliability
- **Password hashing** — Link passwords hashed at rest; legacy plain-text re-hashed on save.
- **DNS-aware SSRF guard** — Webhooks, OG scraper, iframe checker block hostnames resolving to private IPs.
- **`OutboundUrl` rule** — Shared validation for outbound URLs.
- **Safe Browsing fail-closed** — Unreachable API rejects URLs when checking is enabled.
- **Global webhook toggle** — `global_webhook_enabled` must be on for global webhooks to fire.
- **`log-error` hardened** — Auth + throttle + validation.
- **Bot hardening** — Bot visit exclusion, optional click dedup, Googlebot IP verify, `?bot=1` secret.
- **Single-use fix** — No visit recorded after single-use consumption.
- **Redis counter fix** — Buffered stats sync with Redis cache driver.
### Settings GUI
- **Analytics & Bot Detection** — Click deduplication, Googlebot verify, bot debug secret in **Settings → Advanced**.
### Database (safe upgrade)
- **Migration `2026_06_08_000001`** — Adds nullable `external_id`, `utm_*`, `ref`, `public_stats_*`, and index on `short_url_visits (short_url_id, ip_hash)`. No existing data modified.
- **Migration `2026_06_09_000001`** — Adds `domain_scope_id`, composite unique `(url_key, domain_scope_id)`, FK on `custom_domain_id` (`ON DELETE SET NULL`), admin/aggregation indexes, and JSON stats columns on `short_url_daily_stats`. Existing rows backfilled (`domain_scope_id = custom_domain_id` where set, else `0`).
- **Cross-database** — No `->after()` hints, no DB-specific `ENUM`; runs on SQLite, MySQL, and PostgreSQL.
### Performance & reliability audit
- **`domain_scope_id` + composite unique** — Same slug on default domain and custom domains without collision.
- **Custom domain routing** — Root-level URLs on verified domains; reserved segments blocked (`api`, `admin`, `auth`, …).
- **`getRealTimeTotalVisits()`** — Merges buffered counters for accurate `max_visits` / single-use checks with cached redirect models.
- **HasStats / aggregation** — Incremental daily aggregation, bot/proxy filters, chunked prune, Redis dirty-set fix, scoped buffer reads, unique metric via `COUNT(DISTINCT ip_hash)`.
- **`SyncBufferedCountersCommand`** — Atomic batch flush; temp keys cleaned after commit.
- **`VerifyCustomDomainsCommand`** — Scheduled DNS re-verification for custom domains.
- **API** — Transactions on bulk/upsert, `ShortUrlCacheInvalidator`, scoped `exists`, hashed-only API keys, public stats throttle.
- **Security** — `AuthenticateShortUrlApi` drops plaintext legacy keys; public stats password from query/body/Authorization.
- **Settings** — VPN cache TTL + timeout in GUI; settings cache 3600s; queue default remains **`sync`** (opt-in `database`/`redis`).
- **Support classes** — `HostNormalizer`, `ShortUrlCacheInvalidator`, `LinkUtmMerger`, `ApiLinkScope`, `VisitCsvExporter`.
- **Verification doc** — Full itemized checklist in **[AUDIT_IMPLEMENTATION.md](AUDIT_IMPLEMENTATION.md)**.
### Refactoring
- **`ShortUrlRedirectHandler`**, **`RedirectUrlResolver`**, **`OgMetaPresenter`**, **`StatsSqlHelper`**, **`SafeUrl` rule**.
### Enterprise hardening (follow-up — 2026-06-08)
> **Signed:** Bartek Janczak — post-audit production hardening before v5.2.0 tag.
#### GA4 Measurement Protocol (enterprise)
- **`Ga4MeasurementProtocolService`** — Extracted from `TrackShortUrlVisitJob`; singleton registered in service provider.
- **Full MP payload** — `timestamp_micros`, `session_id`, `engagement_time_msec`, standard events `page_view` + `click`, custom `short_url_visit`.
- **Measurement ID validation** — Rejects invalid `ga_tracking_id` format before HTTP; logs and skips send.
- **Settings Test connection** — Requires real `G-XXXXXXXXXX` (or Firebase App ID); validates via GA4 debug collector with the configured secret (not placeholder `G-XXXXXXXXXX`).
- **Privacy-safe `client_id`** — Deterministic SHA-256 hash of IP + User Agent (unchanged semantics, now in dedicated service).
#### Geo-IP foolproofing
- **Headers driver auto-trust** — Saving `geo_ip_driver = headers` forces `trust_cdn_headers = true` in DB and runtime config.
- **Offline fallback chain** — When CDN headers are missing, Headers driver falls back to MaxMind, then ip-api (instead of returning empty geo).
- **ip-api rate limit** — Laravel `RateLimiter` guard: max 40 lookups/minute (`fsu_geo_ip_api:{YmdHi}`).
- **Settings UI** — GeoIP tab info callout explains auto-trust and fallback behaviour.
#### Custom domains (DNS gate)
- **Multi-record verification** — All A records resolved; multiple CNAME targets; IP match via `array_intersect`; CNAME chain to `app.url` host.
- **Activation enforcement** — `custom_domains.enforce_dns_on_activate` (default `true`, env `SHORT_URL_CUSTOM_DOMAIN_ENFORCE_DNS`); saving with `is_active=true` without DNS → auto-deactivate.
- **Removed `SERVER_ADDR` fallback** — Verification relies on DNS resolution against `app.url` only.
#### API & settings
- **Upsert + `lock_url_key`** — `UpsertShortUrlRequest` blocks changing `url_key` and `custom_domain_id` when lock is enabled (closes panel/API parity gap).
- **`ShortUrlSettingsManager`** — `last_aggregation_date` in system keys; secret preservation on masked save; `trust_cdn_headers` derived when driver is headers.
#### Stats & reliability audit (P0P2)
- **Redis dirty-set prefix** — `SyncBufferedCountersCommand` uses store prefix for dirty ID set.
- **Webhook SSRF** — `SendWebhookJob`: `allow_redirects => false`, rejects 3xx responses.
- **Buffer fallback** — `HasStats::incrementVisits()` DB fallback when dirty-ID tracking fails.
- **Unique visits** — Human clicks from daily rollups; no DISTINCT override inflation.
- **Cross-filter stats** — Two-filter pairs in aggregation; gap fill in `FilteredStatsCollector`.
- **Per-link webhooks** — Respects `webhook_events` on link model.
- **Octane** — `GeoIpService::flush()`, `ShortUrl::flushBufferedCounterMemory()` on request terminate.
- **Security** — Redirect HTML XSS fix; public stats rate limit; visit logs default to stats-counted scope.
- **Tests** — `EnterpriseHardeningTest.php`, `StatsAuditHardeningTest.php`; run `php artisan test packages/filament-short-url/tests/ --compact` in the host app.
#### Critical audit hardening (follow-up — 2026-06-08)
- **Stateless redirect default** — Main `/s/{key}` route uses `throttle:120,1` only (no `web` middleware). Password flow stays on `/s-auth/{key}` with explicit `web`.
- **Filament `lock_url_key` server-side** — `LockedUrlKeyGuard` sanitizes Filament save data and blocks model persistence when key/domain change is attempted (parity with API).
- **Multi-tenant Filament scope** — `LinkUserScope` filters `ShortUrlResource` by `user_id` when `scope_links_to_user` is enabled (default).
- **Custom domain ownership in API** — `CustomDomainValidator` ensures API keys/users can only assign domains they own.
- **Scoped `exists` API** — `custom_domain_id` + `domain_scope_id` in response; checks per domain scope.
- **Safe Browsing on redirect** — `SafeBrowsingService::isSafeCached()` re-checks destination at redirect time (configurable TTL).
- **Webhook signing required** — Global webhooks require `webhook_signing_secret` when `webhook_signing_required` is true (default); jobs skip unsigned dispatch.
- **Hot path optimizations** — Single domain resolution passed to `findByKey()`; pixels lazy-loaded; `max_visits` enforced under row lock before tracking.
- **Docs** — README claims corrected (no sub-15ms; dub.co parity table scoped; production checklist; sync queue default documented).
- **Tests** — `SecurityHardeningFixesTest.php`, `LockedUrlKeyGuardTest.php`.
---
## v4.0.0
- **Custom Domains Branding** — Let users connect branded custom domains with real-time CNAME/A record DNS verification and automatic prefix-free root routing.
- **Cache Leak Fix** — Solved a critical caching bug where single-use links could be visited multiple times during the cache TTL by passing the host-specific key suffix to cache invalidations.
- **Aggregator Performance Tuning** — Restructured daily aggregation SQL queries to filter only active URL IDs, leveraging composite indexes and running native DB aggregations.
- **Logo Upload Authorization** — Secured the logo upload API with resource-level permission and policy checks.
- **DB-Free Registration Phase** — Deferred settings overrides to the service provider boot phase to prevent database errors during offline configuration caching.
- **Conditional Redirection Fallback** — Added configurations to optionally disable global fallback route registration.
## v3.5.0
- **User Attribution** — Short URLs now record the creator. Their avatar and hover card (name + email) are displayed in the list table.
- **Relative Date Badges** — Creation dates show compact relative strings (`2h`, `5d`, `3mo`). Hover to see the precise date and timezone.
- **Keyboard Shortcuts** — Hover any table row and press `E` (edit), `Q` (QR code), `I` (share), `S` (statistics), or `X` (delete).
- **Unified Action Dropdown** — All per-row actions consolidated under a single 3-dot button with keyboard shortcut badges.
- **Row Click → Statistics** — Clicking a table row navigates directly to the link's Statistics page.
- **A/B Split Testing** — Weighted traffic rotation in root-level links and nested targeting rules, with drag-based sliders and a one-click "Balance weights" action.
- **Variant Analytics** — Tracks which A/B variant was served and shows a "Variant Clicks Distribution" chart in the analytics dashboard.
- **Query Optimization** — Composite DB index on the visits table; queries filter by indexed `country_code`; Eloquent hydration bypassed with `toBase()->get()` for large sets.
- **GDPR IP Anonymization** — Opt-in IP masking (IPv4/IPv6) with SHA-256 hashing for unique visitor identification, managed via Settings.
- **Google Safe Browsing in REST API** — Safety checks now apply to all incoming URLs in the API (destination, A/B variants, targeting rules).
- **Settings URL Cleanup** — Tab query parameters are now human-readable (e.g. `?tab=qr` instead of `?tab=qr-defaults::data::tab`).
- **Analytics Enhancements** — QR Scan Conversion Rate card with period-over-period delta, browser/OS version subtexts, A/B click-share vs. weight comparison.
## v3.3.0
- **Advanced Multi-Filter Targeting Engine** — Replaced the legacy single-strategy selection panel with a highly flexible rule engine supporting custom `AND` / `OR` logic matching.
- **Granular Filter Categories** — Added support for filtering by devices (Desktop, Mobile, Tablet), platform operating systems (Windows, macOS, Linux, iOS, Android, Fire OS), countries (multiselect with search), and browser languages (multiselect with search).
- **Client-Side Flag Icons** — Integrated high-quality country flag icons dynamically loaded from a trusted CDN (`flagcdn.com`) inside both the Filament form targeting dropdown and the analytics country statistics widget.
- **Redirection Performance Boost** — Bypassed full user agent parsing (which involves regular expression scanning for versions) during redirections by introducing specialized fast-path `getDeviceType` and `getOs` helper functions.
- **Dynamic Legacy Data Adapter** — Added automatic on-the-fly hydration and upgrade of legacy database records to the new rule format when loaded in the Filament form.
## v3.2.0
- **Expanded Developer REST API** — Added new endpoints to inspect single short URLs (`GET /api/short-url/links/{idOrKey}`), fetch real-time click metrics (`GET /api/short-url/links/{idOrKey}/stats`), and fully update links programmatically (`PUT/PATCH /api/short-url/links/{idOrKey}`). Enabled flexible lookup dynamically by ID or URL key.
- **REST API Throttling & Rate Limiting** — Added route middleware throttling for the Developer REST API. Current default: `throttle:120,1` (configurable via `filament-short-url.middleware`); per-key limits default to 60 req/min.
- **Unified Validation System** — Cleaned up and unified API and admin panel form validation, supporting advanced fields like granular logging parameters (`track_visits`, `track_browser_language`, etc.), custom GA4 tracking IDs (`ga_tracking_id`), and auto-open deep linking options (`auto_open_app_mobile`).
- **Media Controller Separation** — Refactored internal logo uploads and serving routes out of the public REST API controller into a dedicated `ShortUrlLogoController` (Single Responsibility compliance).
- **Alpine.js Webhook Payload Preview** — Replaced the Filament package CodeEditor component with a custom, high-reliability dark-mode HTML/CSS component featuring live code highlighting and copy-to-clipboard functionality powered by Alpine.js.
## v3.1.0
- **Database-Backed & Cached Settings** — Relocated user configuration from local JSON files to the database (`short_url_settings` table) with automatic caching and zero-downtime migration of legacy settings.
- **High-Performance Aggregations** — Completely refactored the statistics aggregator command to run optimized GROUP BY queries directly in the database, reducing memory usage (OOM protection) to near zero.
- **Secure Hashed API Keys** — API keys are now stored securely as SHA-256 hashes, verified using constant-time comparisons (`hash_equals()`), and displayed only once upon generation.
- **HMAC Signed Webhooks** — Webhook payloads are now optionally signed with a configured secret key and verified via the `X-ShortUrl-Signature` header.
- **Privacy-Safe GA4 Client IDs** — Replaced random UUIDs with deterministic client IDs based on hashed visitor IP and User Agent, ensuring session integrity in Google Analytics without storing raw visitor data.
- **Browser Cache Prevention for Limited Links** — Force temporary `302` redirects automatically for single-use, max-visit, or expiring links to prevent browsers from caching redirects and bypassing tracking logic.
- **Proxy Detection Optimization** — Implemented an aggressive 800ms timeout for external proxy checkers and reduced cache time for transient rate-limit failures to 60 seconds.
## v3.0.0
- **Native App Linking (Mobile Auto-Open)** — Automatically match and redirect mobile visitors directly inside 24+ native mobile apps (such as WhatsApp, YouTube, TikTok, Instagram, Spotify, etc.) using custom schemes, complete with a glassmorphic redirect page and a live interactive matching preview widget.
- **Global Deep Linking (Universal Links & App Links)** — Easily serve iOS `apple-app-site-association` and Android `.well-known/assetlinks.json` configuration files directly from your root domain to support OS-level native integration (disabled by default, managed via Settings).
- **Central Retargeting Pixel Registry** — Introduced a premium Many-to-Many pixel management registry. Define pixels centrally (Meta Pixel, Google Tag, LinkedIn Insight, TikTok Pixel, Pinterest Tag) and easily associate them with short links via the Filament panel or the REST API.
- **Standalone Settings Page** — Relocated the Settings interface from a resource header sub-action to a standalone sidebar navigation page under the default plugin group.
- **Retargeting Pixel API** — The REST API now fully exposes the `pixels` relationship array parameter for registering and linking retargeting pixels programmatically.
- **Enhanced Browser Language Redirection** — Robust double-pass language targeting logic matching exact locales first (e.g. `en-US`, `zh-CN`) and falling back to base language codes (e.g. `en`, `zh`).
- **Custom Branded Expiry Pages** — Replaced raw 410 HTTP errors with a beautiful, fully localized, dark-mode compatible HTML expiry page displaying the Site Name, expired link details, and a homepage button.
## v2.0.0
- **Interactive QR Code Designer Branding Logo** — Upload custom brand logos inside the QR designer canvas in Filament. Configure logo sizing, margins, shapes (square/circle), and toggle dot backing removal to prevent dots overlapping with the logo.
- **Dedicated QR Code Scan Tracking** — Differentiates visitor clicks from physical QR code scans by dynamically appending source tags (`?source=qr`). Added a new database tracking column (`is_qr_scan` on visits, `qr_scans` on short URLs, and `qr_visits_count` on daily stats). Displays a dedicated scans counter badge in the Filament list table.
- **Browser Language Detection & Statistics** — Captures visitor browser preferred language headers (`browser_language` field) and aggregates them into the daily stats table. Displays a new "Top Languages" widget breakdown in the link statistics dashboard.
- **High-Traffic Performance Safeguards & Robust Rollbacks** — Atomic database transactions for buffered counter updates with fail-safe rollback that restores cache values in case of DB connection failures. Prevents N+1 queries by preloading request-wide counters in a single batch cache lookup.
- **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.
## v1.6.0
- **Google Safe Browsing Integration** — Automatic safety checks against Google's API during link creation or modification. Includes bypass settings, asynchronous checking option, and alert badges.
- **VPN / Proxy / Bot Filtering** — Detect and filter out VPN/proxy traffic and Tor nodes using external proxy detection APIs to keep traffic analytics clean.
- **Visitor World Map Widget** — Live interactive SVG world map showing clicks distribution per country, custom intensity highlighting, and hover details.
## 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.
## 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.
- **Developer REST API** — Full `GET /api/short-url/links`, `POST /api/short-url/links`, and `DELETE /api/short-url/links/{id}` endpoints with API Key authentication (managed via Settings UI). Supports creating links with all available attributes including pixels and webhooks.
- **Webhook System** — Real-time HTTP POST notifications on `visited`, `created`, `expired`, and `limit_reached` events. Configurable per-link or globally. Dispatched asynchronously via `SendWebhookJob` with 3-attempt retry policy and 10-second backoff.
- **Settings: API & Webhooks Tab** — New settings tab to manage global webhook URL, monitored event types, and developer API key management with name labels and per-key activation toggles.
## v1.4.0
- **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.
- **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.
## v1.2.0
- **Password-protected links** — Session-based unlock flow with a styled prompt page.
- **Redirect warning pages** — Interstitial security screen before external redirects.
- **Smart targeting** — Device-based, Country/Geo-based, and A/B weighted rotation rules per link.
- **Rate limiting** — Configurable per-IP redirect throttling with 429 responses.
- **Daily stats aggregation** — Nightly `short-url:aggregate-and-prune` command for scalable log management.
- **Extended Settings GUI** — New "Performance & Security" tab for aggregation and rate limiting configuration.
- **Polish translations** — Full `pl` locale support for all new features.

30
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,30 @@
# Contributing to Filament Short URL
Thank you for considering contributing to the Filament Short URL plugin!
## Development Setup
If you are modifying the plugin source code and need to recompile its stylesheet after changing Blade, PHP, or configuration files, follow these steps:
### 1. Recompile Plugin CSS (Tailwind CSS v4)
Run the Tailwind CSS CLI from the root folder of your host application to scan the plugin's views and compile the final stylesheet:
```bash
npx @tailwindcss/cli -i ./packages/filament-short-url/resources/css/plugin.css \
-o ./packages/filament-short-url/resources/dist/filament-short-url.css --minify
```
### 2. Re-publish compiled assets
After compiling, publish the updated CSS stylesheet to the public folder of your host application so the browser can load it:
```bash
php artisan filament:assets
```
## Running Tests
We enforce test coverage for all features. To run the tests from the root of your host application:
```bash
php artisan test --filter=ShortUrl
```

485
LICENSE
View File

@@ -1,21 +1,476 @@
MIT License
================================================================================
SOURCE-AVAILABLE & DUAL-COMMERCIAL LICENSE — VERSION 1.3
janczakb/filament-short-url (the "Software")
================================================================================
Copyright (c) 2026 Bartek Janczak
Copyright (c) 2026 Bartłomiej Janczak. All rights reserved.
Contact: barek122@gmail.com
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
IMPORTANT — READ CAREFULLY BEFORE DOWNLOADING, INSTALLING, OR USING THIS
SOFTWARE. BY TAKING ANY OF THOSE ACTIONS, YOU UNCONDITIONALLY ACCEPT EVERY
TERM AND CONDITION OF THIS AGREEMENT. IF YOU DO NOT ACCEPT, DO NOT DOWNLOAD,
INSTALL, OR USE THE SOFTWARE IN ANY WAY.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
This License is intended for professional and business use (B2B). You represent
that You are acquiring the Software for professional purposes and not as a
consumer acting outside Your trade, business, craft, or profession, to the
extent such qualification is relevant under applicable law.
================================================================================
SECTION 1 — DEFINITIONS
================================================================================
1.1 "Software" means the PHP/Laravel/Filament package published by Bartłomiej
Janczak under the identifier janczakb/filament-short-url, including all
source code, compiled assets, database migrations, configuration files,
documentation, and any future updates, patches, or versions thereof.
1.2 "Copyright Holder" means Bartłomiej Janczak, sole and exclusive author
and owner of all intellectual property rights in and to the Software,
except for third-party components governed by Section 6.4.
1.3 "You" (or "Your") means the individual or legal entity exercising rights
under this License.
1.4 "Official Distribution Channels" means distribution points operated or
expressly authorized in writing by the Copyright Holder for distributing
the Software to licensees, including without limitation:
(a) the official GitHub repository maintained by the Copyright Holder;
(b) the janczakb/filament-short-url package listing on Packagist.org;
(c) any mirror, release artifact, or update channel explicitly linked
from the official repository or Copyright Holder website.
Receiving a copy through an Official Distribution Channel at the Copyright
Holder's direction is not "redistribution" by You.
1.5 "Software Transfer" means, with respect to the Software or any portion
thereof (including unmodified vendor copies, source trees, archives,
Composer packages, Docker images, or deployment artifacts from which the
Software can be extracted):
(a) delivering, disclosing, publishing, or making accessible such copies
to any third party;
(b) granting any third party the right or practical ability to copy,
redistribute, sublicense, republish, or further transfer the Software;
(c) placing the Software in a repository, package index, shared drive,
client portal, or artifact store where the client or any third party
may obtain standalone copies independent of Your controlled runtime;
(d) licensing or selling access to the Software itself, rather than to
services or outcomes produced using the Software under this License.
For the avoidance of doubt, Software Transfer does NOT include:
(i) operating the Software on infrastructure controlled by You or Your
client where the client receives no standalone copy and no rights
to extract, republish, or sublicense the Software;
(ii) delivering an integrated application to a client under Section
1.7(b) when the client receives only runtime access and no rights
described in subsections (a) through (d) above.
1.6 "Distinct Client Engagement" means one separately contracted scope of work
for one end client identified in writing before deployment begins. Multiple
projects, environments, brands, or business units of the same client
constitute separate engagements unless expressly covered by one written
statement of work.
1.7 "Permitted Free Use" means use that satisfies ALL of Sections 1.7(a) or
(b), Section 1.12 (High-Volume Use limit), and every restriction in
Section 3:
(a) Personal, educational, research, or internal business use by a single
natural person or a single legal entity solely for that entity's own
internal operations, including staging and non-production environments
controlled by that same entity;
(b) Deployment of the Software for one Distinct Client Engagement as a
work-for-hire or freelance project, provided that:
(i) You remain the sole licensee and operator responsible for
compliance with this License;
(ii) the engagement uses no more than one (1) production environment
for that client unless a Commercial License expressly authorizes
more;
(iii) You do not perform any Software Transfer to the client or any
third party, except that the client may receive compiled or
integrated application access without standalone Software copies
or redistribution rights as described in Section 1.5;
(iv) the client does not resell, license, distribute, or operate the
Software as a product for third parties;
(v) Qualified Events attributable to that engagement do not exceed
the High-Volume Use threshold in Section 1.12.
1.8 "Commercial Distribution" means any act of selling, renting, leasing,
sublicensing, or otherwise transferring — for monetary gain, in-kind
consideration, or any other commercial benefit — the Software, or any
Derivative Work, or any Bundled Product, to one or more third parties.
For the avoidance of doubt, Commercial Distribution includes but is not
limited to:
(a) selling or licensing a website, web application, theme, template,
boilerplate, starter kit, or codebase that embeds or depends upon the
Software, whether the Software is disclosed or concealed;
(b) offering link-management, URL-shortening, QR-code, or redirect
services as a paid SaaS, subscription, or multi-tenant product in
which the Software's functionality is a material component;
(c) providing the Software to a client under a contract where the client
subsequently resells, licenses, or distributes the resulting product
to end users;
(d) performing any Software Transfer for consideration or as part of a
commercial deliverable.
1.9 "Derivative Work" means any work that is based upon, incorporates,
modifies, adapts, translates, or is otherwise derived from the Software
or any substantial part thereof, regardless of the programming language,
file format, or packaging used.
1.10 "Bundled Product" means any software application, plugin, extension,
theme, template, framework, boilerplate, starter kit, digital product, or
service offering in which the Software — in whole or in any part, modified
or unmodified — is included, embedded, required as a dependency, or
integrated in any manner, irrespective of whether the Software constitutes
the primary component or a minor ancillary element.
1.11 "Qualified Event" means each HTTP redirect response served through the
Software's redirect routes and each visit or click recorded by the
Software's built-in tracking, counters, analytics, or daily-stats
subsystems, whether or not visit logging is enabled for a given link.
Automated health checks, load tests, and crawler traffic that the Software
explicitly excludes from counters in its own code are excluded.
1.12 "High-Volume Use" means any deployment — whether internal, client,
single-instance, or multi-instance — that generates more than 1,000,000
(one million) Qualified Events in any calendar month attributable to
deployments operated under Your license. Attribution includes all
environments (production, staging, preview, and replicas) that You
control or cause to be operated for the same entity or engagement.
1.13 "Repository" means any version-control hosting service or platform,
including without limitation GitHub, GitLab, Bitbucket, Codeberg, or any
self-hosted equivalent, whether public, private, or restricted.
1.14 "Package Registry" means any software distribution index, including
without limitation Packagist, npm, PyPI, or any equivalent service.
================================================================================
SECTION 2 — GRANT OF FREE LICENSE
================================================================================
Subject strictly to the conditions and restrictions in Sections 3 and 4, the
Copyright Holder grants You a worldwide, royalty-free, non-exclusive,
non-sublicensable, non-transferable license to:
2.1 Obtain copies of the Software only through Official Distribution Channels
or other channels expressly authorized in writing by the Copyright Holder,
and install and use the Software solely for Permitted Free Use as defined
in Section 1.7. Merely downloading or installing a copy from an Official
Distribution Channel does not constitute redistribution by You.
2.2 Modify the Software solely for Your own private or internal use under
Section 1.7(a) or (b), provided that all such modifications:
(a) are kept strictly private and are never distributed, published,
shared, disclosed, or made accessible to any third party in any form;
(b) retain this entire License and all copyright notices without
alteration.
2.3 Make a reasonable number of backup copies and deployment artifacts
strictly necessary to operate Permitted Free Use, provided such copies
remain under Your control and are not subject to Software Transfer.
No rights are granted except those expressly set out in this Section 2.
All other rights are reserved exclusively by the Copyright Holder.
================================================================================
SECTION 3 — STRICT RESTRICTIONS AND PROHIBITIONS
================================================================================
The following acts are CATEGORICALLY AND UNCONDITIONALLY PROHIBITED unless
You hold a valid, current Commercial License issued by the Copyright Holder
under Section 5:
3.1 REDISTRIBUTION IN ANY FORM. You may not publish, upload, transmit,
broadcast, mirror, host, or otherwise make available the Software — in
whole or in any part, modified or unmodified — through any means,
including but not limited to:
(a) any public or private Repository, regardless of access controls,
except as expressly permitted in Section 3.1(A);
(b) any Package Registry, except Official Distribution Channels operated
by the Copyright Holder;
(c) file-sharing platforms, cloud storage, or peer-to-peer networks;
(d) email, instant messaging, or any other communication channel.
(A) CONTRIBUTION FORKS — SOLE EXCEPTION. Public fork(s) of the official
repository on GitHub are permitted exclusively to prepare and submit Pull
Request(s) to the original repository. Each such fork:
(i) must be created and maintained solely for contribution work;
(ii) must not be used for hosting, demonstration, resale, or any
independent product;
(iii) must reproduce this License in full without modification;
(iv) must be deleted or converted to private within thirty (30) calendar
days of the earlier of: (A) all related Pull Request(s) being
closed, merged, or rejected; or (B) ninety (90) calendar days of
inactivity on that fork. Failure to comply constitutes a material
breach of this License.
3.2 SOFTWARE TRANSFER. You may not perform any Software Transfer except as
expressly allowed without transfer in Section 1.5(i)(ii) and Section
1.7(b)(iii).
3.3 BUNDLING AND COMMERCIAL EXPLOITATION. You may not:
(a) include, embed, bundle, or integrate the Software, in whole or in any
part, into any Bundled Product that is or will be subject to
Commercial Distribution;
(b) sell, license, rent, or otherwise commercially transfer any
application, website, codebase, or digital product that contains or
depends upon the Software, even if the Software represents only a
minor or incidental component of the total offering;
(c) use the Software as a component of any product offered as a template,
boilerplate, or starter kit sold or licensed to multiple clients or
end users, whether individually priced or as part of a bundle or
subscription.
3.4 COMMERCIAL DISTRIBUTION. You may not engage in any act constituting
Commercial Distribution as defined in Section 1.8.
3.5 HIGH-VOLUME USE without a Commercial License. You may not operate any
system or service that constitutes High-Volume Use as defined in
Section 1.12, including internal business use under Section 1.7(a).
3.6 RELABELING AND MISATTRIBUTION. You may not:
(a) publish, distribute, or otherwise make available the Software under a
different package name, author name, or vendor identity;
(b) remove, obscure, alter, or misrepresent the copyright notice, license
text, or author attribution in any copy or portion of the Software;
(c) represent, imply, or permit others to believe that the Software was
created by anyone other than Bartłomiej Janczak.
3.7 SUBLICENSING AND TRANSFER OF LICENSE RIGHTS. You may not grant any
sublicense, assign any rights, or otherwise transfer this License or any
rights granted hereunder to any third party. Hosting the Software for a
client under Section 1.7(b) does not sublicense rights to the client.
3.8 CIRCUMVENTION. You may not structure any transaction, arrangement, or
technical implementation with the intent or effect of circumventing any
restriction in this Section 3, including without limitation by splitting a
Bundled Product into artificially separate components, using an
intermediary entity, masking the Software as a different dependency,
or separating repositories solely to evade transfer or redistribution
prohibitions.
3.9 COMPETING PRODUCTS. You may not use the Software, or any Derivative Work,
as the core of a substantially similar link-management, URL-shortening,
QR-code, or redirect-analytics product offered to third parties, whether
open or closed source, except under a Commercial License.
================================================================================
SECTION 4 — CONTRIBUTOR LICENSE AGREEMENT (CLA)
================================================================================
4.1 By submitting any contribution (including but not limited to Pull
Requests, patches, bug fixes, documentation edits, or any other
modification) to the Software or to the official repository, You
irrevocably grant to Bartłomiej Janczak a perpetual, worldwide,
royalty-free, non-exclusive, sublicensable license to use, reproduce,
modify, distribute, and sublicense Your contribution under any license
terms the Copyright Holder deems appropriate, including proprietary terms.
4.2 You represent and warrant that:
(a) You are the sole author of the contribution or have obtained all
rights necessary to grant the license in Section 4.1;
(b) the contribution does not infringe any third-party intellectual
property rights;
(c) the contribution is not subject to any license, agreement, or
obligation that would conflict with the grant in Section 4.1;
(d) if the contribution includes work created for an employer or client,
You have authority to submit it under Section 4.1.
4.3 The Copyright Holder retains the right to reject any contribution without
giving reasons and without incurring any obligation to the contributor.
================================================================================
SECTION 5 — COMMERCIAL LICENSING
================================================================================
5.1 Any use not expressly permitted by Section 2 — including Commercial
Distribution, Software Transfer beyond Section 1.5(i)(ii), Bundled
Product distribution, High-Volume Use, competing products under
Section 3.9, or any other commercial exploitation of the Software —
requires a separate Commercial License issued by the Copyright Holder
prior to such use.
5.2 To inquire about or obtain a Commercial License, contact:
Bartłomiej Janczak
E-mail: barek122@gmail.com
5.3 Commercial Licenses are issued at the Copyright Holder's sole discretion
and may be subject to a one-time fee, recurring royalty, revenue-sharing
arrangement, or other terms as agreed in a written instrument signed by
the Copyright Holder, including a qualified electronic signature or other
form of document with legal effect equivalent to writing under applicable
law. No Commercial License is effective until so signed or otherwise
authenticated by the Copyright Holder.
5.4 Any act of Commercial Distribution, prohibited Software Transfer,
Bundled Product distribution, High-Volume Use, or competing use under
Section 3.9 without a valid Commercial License:
(a) is null and void ab initio;
(b) constitutes a material breach of this License;
(c) constitutes direct infringement of the Copyright Holder's exclusive
rights under the Act of 4 February 1994 on Copyright and Related
Rights (Dz.U. 2022 poz. 2509, as amended) and applicable
international copyright treaties, including the Berne Convention and
the TRIPS Agreement;
(d) entitles the Copyright Holder to seek all available remedies,
including injunctive relief, actual damages, disgorgement of profits,
and statutory damages to the maximum extent permitted by applicable law.
================================================================================
SECTION 6 — INTELLECTUAL PROPERTY
================================================================================
6.1 The Software is licensed, not sold. Bartłomiej Janczak retains all right,
title, and interest in and to the Software, including all copyrights,
moral rights, trade secrets, and any other intellectual property rights,
worldwide, except as expressly granted in this License.
6.2 This License does not transfer to You any ownership interest in or to the
Software, any trademarks, trade names, or service marks of the Copyright
Holder, or any goodwill associated therewith.
6.3 You must preserve and reproduce in all permitted copies and portions of
the Software:
(a) this entire License text, unmodified;
(b) the copyright notice: "Copyright (c) 2026 Bartłomiej Janczak."
6.4 The Software incorporates third-party libraries (including but not limited
to Laravel, Filament, and their respective dependencies). Those components
are governed exclusively by their own licenses. This License applies only
to the original code authored by Bartłomiej Janczak within the
janczakb/filament-short-url package and does not modify or supersede the
terms of any third-party license.
6.5 PATENTS. No patent rights are granted except to the minimum extent
necessary for You to exercise the copyright permissions expressly granted
in Section 2 during Permitted Free Use or under a valid Commercial
License. All other patent rights are reserved.
================================================================================
SECTION 7 — TERMINATION
================================================================================
7.1 This License and all rights granted hereunder terminate automatically and
immediately, without notice and without judicial action, if You breach any
term or condition of this License.
7.2 Upon termination, You must:
(a) immediately cease all use, reproduction, and distribution of the
Software;
(b) permanently delete all copies, full or partial, of the Software in
Your possession or control, including backups, deployment artifacts,
and archived copies;
(c) upon request, certify in writing to the Copyright Holder that You have
complied with Section 7.2(b).
7.3 Sections 4, 5.4, 6, 7, 8, 9, and 10 survive termination of this License.
================================================================================
SECTION 8 — DISCLAIMER OF WARRANTIES
================================================================================
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
FITNESS FOR A PARTICULAR PURPOSE, TITLE, AND NON-INFRINGEMENT. THE COPYRIGHT
HOLDER DOES NOT WARRANT THAT THE SOFTWARE WILL MEET YOUR REQUIREMENTS, OPERATE
WITHOUT INTERRUPTION, OR BE FREE FROM ERRORS OR SECURITY VULNERABILITIES.
YOU ASSUME THE ENTIRE RISK AS TO THE QUALITY, PERFORMANCE, SECURITY, AND
RESULTS OF YOUR USE OF THE SOFTWARE. YOU ARE SOLELY RESPONSIBLE FOR ANY DAMAGE
TO YOUR SYSTEMS, DATA, BUSINESS, OR THIRD PARTIES ARISING FROM YOUR USE OR
INABILITY TO USE THE SOFTWARE.
================================================================================
SECTION 9 — LIMITATION OF LIABILITY (LICENSEE CLAIMS AGAINST COPYRIGHT HOLDER)
================================================================================
This Section limits claims that a Licensee (or any user acting under this
License) may bring AGAINST the Copyright Holder. It does NOT limit, reduce,
or cap any remedy, claim, or enforcement action that the Copyright Holder may
bring against You for breach of this License, copyright infringement, or
unauthorized use, including without limitation Sections 5.4, 7, and 10.8.
9.1 EXCLUSION OF DAMAGES. TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW,
THE COPYRIGHT HOLDER SHALL NOT BE LIABLE TO YOU FOR ANY DAMAGES OF ANY
KIND OR NATURE, WHETHER DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
CONSEQUENTIAL, OR PUNITIVE, INCLUDING BUT NOT LIMITED TO LOSS OF PROFITS,
REVENUE, GOODWILL, DATA, BUSINESS, OR OPPORTUNITY, ARISING OUT OF OR IN
CONNECTION WITH THIS LICENSE OR THE SOFTWARE, EVEN IF THE COPYRIGHT
HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
9.2 ZERO LIABILITY FOR PROFESSIONAL USE. WHERE PERMITTED FOR PROFESSIONAL OR
BUSINESS USE (B2B), THE COPYRIGHT HOLDER SHALL HAVE NO LIABILITY WHATSOEVER
TO YOU UNDER OR IN CONNECTION WITH THIS LICENSE.
9.3 MANDATORY-LAW FLOOR. IF A COMPETENT COURT FINALLY DETERMINES THAT ANY
PART OF SECTIONS 9.1 OR 9.2 CANNOT BE ENFORCED FOR A PARTICULAR CLAIM,
THE COPYRIGHT HOLDER'S TOTAL AGGREGATE LIABILITY FOR THAT CLAIM AND ALL
RELATED CLAIMS ARISING UNDER THIS LICENSE SHALL NOT EXCEED THE GREATER OF:
(a) EUR 0 (ZERO EURO); OR
(b) THE FEES ACTUALLY PAID BY YOU TO THE COPYRIGHT HOLDER FOR A VALID
COMMERCIAL LICENSE FOR THE SOFTWARE IN THE TWELVE (12) MONTHS
IMMEDIATELY PRECEDING THE EVENT GIVING RISE TO THE CLAIM;
AND IN ALL CASES ONLY TO THE MINIMUM EXTENT REQUIRED BY MANDATORY
APPLICABLE LAW. NOTHING IN THIS SECTION 9.3 EXPANDS LIABILITY BEYOND
WHAT APPLICABLE LAW REQUIRES.
9.4 EXCLUSIVE REMEDY. YOUR SOLE AND EXCLUSIVE REMEDY FOR DISSATISFACTION WITH
THE SOFTWARE OR ANY BREACH BY THE COPYRIGHT HOLDER OF THIS LICENSE IS TO
DISCONTINUE USE OF THE SOFTWARE AND DELETE ALL COPIES IN YOUR POSSESSION
OR CONTROL.
9.5 NO LIMITATION ON COPYRIGHT HOLDER ENFORCEMENT. For clarity: if You breach
this License, the Copyright Holder may pursue all remedies available under
this License and applicable law without regard to this Section 9. A person
who infringes or misuses the Software is not a beneficiary of this
limitation and may owe the Copyright Holder damages, injunctive relief,
disgorgement of profits, and other remedies far exceeding any amount
referenced in Section 9.3.
================================================================================
SECTION 10 — GENERAL PROVISIONS
================================================================================
10.1 GOVERNING LAW. This License is governed by and construed in accordance
with the substantive law of Poland, in particular the Act of 4 February
1994 on Copyright and Related Rights (Dz.U. 2022 poz. 2509, as amended),
without regard to its conflict-of-laws rules.
10.2 JURISDICTION. Any dispute arising out of or in connection with this
License shall be submitted to the exclusive jurisdiction of the competent
courts in Warsaw, Poland.
10.3 SEVERABILITY. If any provision of this License is held invalid, illegal,
or unenforceable by a court of competent jurisdiction, that provision
shall be modified to the minimum extent necessary to make it enforceable,
or if modification is not possible, severed from this License, while the
remaining provisions continue in full force and effect.
10.4 ENTIRE AGREEMENT. This License constitutes the entire agreement between
the parties with respect to the Software and supersedes all prior or
contemporaneous agreements, representations, or understandings relating
to the Software.
10.5 WAIVER. The Copyright Holder's failure to enforce any provision of this
License does not constitute a waiver of the right to enforce that
provision in the future.
10.6 MODIFICATIONS TO THIS LICENSE. The Copyright Holder reserves the right to
publish revised versions of this License. Each version will be identified
by a version number. The version under which You obtained the Software
governs Your use unless the Copyright Holder expressly states otherwise.
This is version 1.3, effective 8 June 2026. Version 1.3 supersedes
version 1.2 for copies obtained on or after its effective date.
10.7 LANGUAGE. This License is written in English. Any translation is provided
for convenience only; in the event of a conflict the English text prevails.
10.8 AUDIT. Upon reasonable prior written notice, the Copyright Holder may
request written confirmation that Your use remains within Permitted Free
Use, including monthly Qualified Event volumes. You must respond within
thirty (30) calendar days with good-faith information. Refusal to respond
or material misrepresentation constitutes a breach of this License.
================================================================================
END OF LICENSE
================================================================================

2067
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,98 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 600" role="img" aria-label="System overview">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1e1b4b"/>
<stop offset="45%" stop-color="#0f172a"/>
<stop offset="100%" stop-color="#164e63"/>
</linearGradient>
<radialGradient id="orb1" cx="20%" cy="15%" r="55%">
<stop offset="0%" stop-color="#a855f7" stop-opacity="0.55"/>
<stop offset="100%" stop-color="#a855f7" stop-opacity="0"/>
</radialGradient>
<radialGradient id="orb2" cx="85%" cy="25%" r="50%">
<stop offset="0%" stop-color="#38bdf8" stop-opacity="0.45"/>
<stop offset="100%" stop-color="#38bdf8" stop-opacity="0"/>
</radialGradient>
<radialGradient id="orb3" cx="50%" cy="90%" r="45%">
<stop offset="0%" stop-color="#2dd4bf" stop-opacity="0.35"/>
<stop offset="100%" stop-color="#2dd4bf" stop-opacity="0"/>
</radialGradient>
<linearGradient id="shine" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.45"/>
<stop offset="45%" stop-color="#ffffff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
</linearGradient>
<linearGradient id="edge" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.05"/>
<stop offset="50%" stop-color="#ffffff" stop-opacity="0.55"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.05"/>
</linearGradient>
<filter id="blur80" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="80"/>
</filter>
<filter id="shadow" x="-40%" y="-40%" width="180%" height="180%">
<feDropShadow dx="0" dy="16" stdDeviation="20" flood-color="#020617" flood-opacity="0.55"/>
</filter>
<marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
<path d="M0,0 L8,4 L0,8 Z" fill="#e2e8f0" fill-opacity="0.9"/>
</marker>
</defs>
<rect width="960" height="600" fill="url(#bg)"/>
<ellipse cx="180" cy="80" rx="220" ry="160" fill="url(#orb1)" filter="url(#blur80)"/>
<ellipse cx="820" cy="120" rx="200" ry="150" fill="url(#orb2)" filter="url(#blur80)"/>
<ellipse cx="480" cy="520" rx="260" ry="180" fill="url(#orb3)" filter="url(#blur80)"/>
<text x="480" y="44" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="28" font-weight="700" fill="#f8fafc" letter-spacing="-0.6">System overview</text>
<text x="480" y="72" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="14" fill="#cbd5e1" fill-opacity="0.85">Filament panel, public routes, core services, persistence</text>
<g filter="url(#shadow)">
<rect x="48" y="100" width="272" height="220" rx="26" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="48" y="100" width="272" height="220" rx="26" fill="url(#shine)" pointer-events="none"/>
<rect x="48" y="100" width="272" height="3" rx="1.5" fill="#c084fc" fill-opacity="0.85"/>
<text x="72" y="136" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="16" fill="#e2e8f0" font-weight="700">Filament admin</text>
<text x="72" y="158" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Panel layer</text>
<text x="72" y="188" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0">ShortUrlResource</text>
<text x="72" y="210" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0">Settings, domains, QR</text>
<text x="72" y="232" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0">Analytics and live feed</text>
<rect x="72" y="258" width="170" height="24" rx="12" fill="#ffffff" fill-opacity="0.08" stroke="#ffffff" stroke-opacity="0.35"/><text x="157.0" y="274.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="10" fill="#f1f5f9">scope_links_to_user</text>
</g>
<g filter="url(#shadow)">
<rect x="344" y="100" width="272" height="220" rx="26" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="344" y="100" width="272" height="220" rx="26" fill="url(#shine)" pointer-events="none"/>
<rect x="344" y="100" width="272" height="3" rx="1.5" fill="#38bdf8" fill-opacity="0.85"/>
<text x="368" y="136" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="16" fill="#e2e8f0" font-weight="700">Public HTTP</text>
<text x="368" y="158" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Edge of Laravel app</text>
<text x="368" y="188" text-anchor="start" font-family="ui-monospace,Menlo,monospace" font-size="11" fill="#bae6fd">GET /s/[key]</text>
<text x="368" y="210" text-anchor="start" font-family="ui-monospace,Menlo,monospace" font-size="11" fill="#bae6fd">GET /s-auth/[key]</text>
<text x="368" y="232" text-anchor="start" font-family="ui-monospace,Menlo,monospace" font-size="11" fill="#bae6fd">GET /api/short-url/*</text>
<text x="368" y="254" text-anchor="start" font-family="ui-monospace,Menlo,monospace" font-size="11" fill="#bae6fd">/[key] on custom domain</text>
<rect x="368" y="278" width="92" height="22" rx="11" fill="#ffffff" fill-opacity="0.08" stroke="#38bdf8" stroke-opacity="0.35"/><text x="414.0" y="293.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="10" fill="#f1f5f9">STATELESS</text>
</g>
<g filter="url(#shadow)">
<rect x="640" y="100" width="272" height="220" rx="26" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="640" y="100" width="272" height="220" rx="26" fill="url(#shine)" pointer-events="none"/>
<rect x="640" y="100" width="272" height="3" rx="1.5" fill="#2dd4bf" fill-opacity="0.85"/>
<text x="664" y="136" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="16" fill="#e2e8f0" font-weight="700">Package core</text>
<text x="664" y="158" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Runtime services</text>
<text x="664" y="188" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0">RedirectHandler</text>
<text x="664" y="210" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0">TrackShortUrlVisitJob</text>
<text x="664" y="232" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0">SendWebhookJob (HMAC)</text>
<text x="664" y="254" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0">HasStats and rollups</text>
</g><line x1="184" y1="320" x2="184" y2="352" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/><line x1="480" y1="320" x2="480" y2="352" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/><line x1="776" y1="320" x2="776" y2="352" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/>
<g filter="url(#shadow)">
<rect x="120" y="360" width="720" height="130" rx="28" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="120" y="360" width="720" height="130" rx="28" fill="url(#shine)" pointer-events="none"/>
<rect x="120" y="360" width="720" height="3" rx="1.5" fill="#fb923c" fill-opacity="0.85"/>
<text x="480" y="396" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="17" fill="#e2e8f0" font-weight="700">Persistence layer</text>
<text x="480" y="422" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#94a3b8">short_urls, visits, daily_stats, cache, optional Redis buffer</text>
<rect x="200" y="448" width="130" height="24" rx="12" fill="#ffffff" fill-opacity="0.08" stroke="#ffffff" stroke-opacity="0.35"/><text x="265.0" y="464.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="10" fill="#f1f5f9">SQL DB</text>
<rect x="350" y="448" width="110" height="24" rx="12" fill="#ffffff" fill-opacity="0.08" stroke="#ffffff" stroke-opacity="0.35"/><text x="405.0" y="464.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="10" fill="#f1f5f9">Redis</text>
<rect x="480" y="448" width="130" height="24" rx="12" fill="#ffffff" fill-opacity="0.08" stroke="#ffffff" stroke-opacity="0.35"/><text x="545.0" y="464.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="10" fill="#f1f5f9">Safe Browsing</text>
<rect x="630" y="448" width="110" height="24" rx="12" fill="#ffffff" fill-opacity="0.08" stroke="#ffffff" stroke-opacity="0.35"/><text x="685.0" y="464.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="10" fill="#f1f5f9">Link cache</text>
</g>
<g filter="url(#shadow)">
<rect x="48" y="512" width="864" height="56" rx="20" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="48" y="512" width="864" height="56" rx="20" fill="url(#shine)" pointer-events="none"/>
<text x="480" y="546" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="13" fill="#e9d5ff">Self-hosted Laravel plugin, not edge SaaS</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 9.6 KiB

View File

@@ -0,0 +1,109 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 680" role="img" aria-label="Redirect lifecycle">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1e1b4b"/>
<stop offset="45%" stop-color="#0f172a"/>
<stop offset="100%" stop-color="#164e63"/>
</linearGradient>
<radialGradient id="orb1" cx="20%" cy="15%" r="55%">
<stop offset="0%" stop-color="#a855f7" stop-opacity="0.55"/>
<stop offset="100%" stop-color="#a855f7" stop-opacity="0"/>
</radialGradient>
<radialGradient id="orb2" cx="85%" cy="25%" r="50%">
<stop offset="0%" stop-color="#38bdf8" stop-opacity="0.45"/>
<stop offset="100%" stop-color="#38bdf8" stop-opacity="0"/>
</radialGradient>
<radialGradient id="orb3" cx="50%" cy="90%" r="45%">
<stop offset="0%" stop-color="#2dd4bf" stop-opacity="0.35"/>
<stop offset="100%" stop-color="#2dd4bf" stop-opacity="0"/>
</radialGradient>
<linearGradient id="shine" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.45"/>
<stop offset="45%" stop-color="#ffffff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
</linearGradient>
<linearGradient id="edge" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.05"/>
<stop offset="50%" stop-color="#ffffff" stop-opacity="0.55"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.05"/>
</linearGradient>
<filter id="blur80" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="80"/>
</filter>
<filter id="shadow" x="-40%" y="-40%" width="180%" height="180%">
<feDropShadow dx="0" dy="16" stdDeviation="20" flood-color="#020617" flood-opacity="0.55"/>
</filter>
<marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
<path d="M0,0 L8,4 L0,8 Z" fill="#e2e8f0" fill-opacity="0.9"/>
</marker>
</defs>
<rect width="960" height="680" fill="url(#bg)"/>
<ellipse cx="180" cy="80" rx="220" ry="160" fill="url(#orb1)" filter="url(#blur80)"/>
<ellipse cx="820" cy="120" rx="200" ry="150" fill="url(#orb2)" filter="url(#blur80)"/>
<ellipse cx="480" cy="600" rx="260" ry="180" fill="url(#orb3)" filter="url(#blur80)"/>
<text x="480" y="44" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="28" font-weight="700" fill="#f8fafc" letter-spacing="-0.6">Redirect lifecycle</text>
<text x="480" y="72" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="14" fill="#cbd5e1" fill-opacity="0.85">GET /s/[key] from click to destination</text>
<g filter="url(#shadow)">
<rect x="280" y="96" width="400" height="54" rx="18" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="280" y="96" width="400" height="54" rx="18" fill="url(#shine)" pointer-events="none"/>
<rect x="280" y="96" width="400" height="3" rx="1.5" fill="#818cf8" fill-opacity="0.85"/>
<text x="480" y="120" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="13" fill="#e2e8f0" font-weight="600">Visitor click</text>
<text x="480" y="138" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Start of request</text>
</g><line x1="480" y1="150" x2="480" y2="168" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/>
<g filter="url(#shadow)">
<rect x="280" y="168" width="400" height="54" rx="18" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="280" y="168" width="400" height="54" rx="18" fill="url(#shine)" pointer-events="none"/>
<rect x="280" y="168" width="400" height="3" rx="1.5" fill="#fbbf24" fill-opacity="0.85"/>
<text x="480" y="192" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="13" fill="#e2e8f0" font-weight="600">Throttle only</text>
<text x="480" y="210" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">No web middleware, no session cookie</text>
</g><line x1="480" y1="222" x2="480" y2="240" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/>
<g filter="url(#shadow)">
<rect x="280" y="240" width="400" height="54" rx="18" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="280" y="240" width="400" height="54" rx="18" fill="url(#shine)" pointer-events="none"/>
<rect x="280" y="240" width="400" height="3" rx="1.5" fill="#a78bfa" fill-opacity="0.85"/>
<text x="480" y="264" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="13" fill="#e2e8f0" font-weight="600">findByKey(host)</text>
<text x="480" y="282" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Cache hit or single DB resolve</text>
</g><line x1="480" y1="294" x2="480" y2="312" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/>
<g filter="url(#shadow)">
<rect x="280" y="312" width="400" height="54" rx="18" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="280" y="312" width="400" height="54" rx="18" fill="url(#shine)" pointer-events="none"/>
<rect x="280" y="312" width="400" height="3" rx="1.5" fill="#f87171" fill-opacity="0.85"/>
<text x="480" y="336" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="13" fill="#e2e8f0" font-weight="600">Safe Browsing cache</text>
<text x="480" y="354" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">isSafeCached(destination) when enabled</text>
</g><line x1="480" y1="366" x2="480" y2="384" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/>
<g filter="url(#shadow)">
<rect x="280" y="384" width="400" height="54" rx="18" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="280" y="384" width="400" height="54" rx="18" fill="url(#shine)" pointer-events="none"/>
<rect x="280" y="384" width="400" height="3" rx="1.5" fill="#34d399" fill-opacity="0.85"/>
<text x="480" y="408" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="13" fill="#e2e8f0" font-weight="600">max_visits lock</text>
<text x="480" y="426" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">lockForUpdate plus isActive()</text>
</g><line x1="480" y1="456" x2="480" y2="476" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/><polygon points="480,476 410,514 550,514" fill="#ffffff" fill-opacity="0.12" stroke="#ffffff" stroke-opacity="0.35"/><text x="480" y="504" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0" font-weight="600">Response path</text>
<g filter="url(#shadow)">
<rect x="48" y="528" width="272" height="88" rx="20" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="48" y="528" width="272" height="88" rx="20" fill="url(#shine)" pointer-events="none"/>
<rect x="48" y="528" width="272" height="3" rx="1.5" fill="#fb923c" fill-opacity="0.85"/>
<text x="184" y="556" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="13" fill="#e2e8f0" font-weight="700">Password</text>
<text x="184" y="576" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="10" fill="#94a3b8">/s-auth/[key] + web</text>
</g>
<g filter="url(#shadow)">
<rect x="344" y="528" width="272" height="88" rx="20" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="344" y="528" width="272" height="88" rx="20" fill="url(#shine)" pointer-events="none"/>
<rect x="344" y="528" width="272" height="3" rx="1.5" fill="#facc15" fill-opacity="0.85"/>
<text x="480" y="556" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="13" fill="#e2e8f0" font-weight="700">Interstitial</text>
<text x="480" y="576" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="10" fill="#94a3b8">warning, pixels, cloak, OG</text>
</g>
<g filter="url(#shadow)">
<rect x="640" y="528" width="272" height="88" rx="20" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="640" y="528" width="272" height="88" rx="20" fill="url(#shine)" pointer-events="none"/>
<rect x="640" y="528" width="272" height="3" rx="1.5" fill="#4ade80" fill-opacity="0.85"/>
<text x="776" y="556" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="13" fill="#e2e8f0" font-weight="700">Simple 302</text>
<text x="776" y="576" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="10" fill="#94a3b8">TrackJob then redirect</text>
</g>
<g filter="url(#shadow)">
<rect x="120" y="636" width="720" height="48" rx="16" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="120" y="636" width="720" height="48" rx="16" fill="url(#shine)" pointer-events="none"/>
<text x="480" y="664" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#cbd5e1">TrackShortUrlVisitJob: GeoIP, VPN, visit, webhook, GA4 (sync default or async queue)</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 9.8 KiB

View File

@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 460" role="img" aria-label="Stateless vs stateful">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1e1b4b"/>
<stop offset="45%" stop-color="#0f172a"/>
<stop offset="100%" stop-color="#164e63"/>
</linearGradient>
<radialGradient id="orb1" cx="20%" cy="15%" r="55%">
<stop offset="0%" stop-color="#a855f7" stop-opacity="0.55"/>
<stop offset="100%" stop-color="#a855f7" stop-opacity="0"/>
</radialGradient>
<radialGradient id="orb2" cx="85%" cy="25%" r="50%">
<stop offset="0%" stop-color="#38bdf8" stop-opacity="0.45"/>
<stop offset="100%" stop-color="#38bdf8" stop-opacity="0"/>
</radialGradient>
<radialGradient id="orb3" cx="50%" cy="90%" r="45%">
<stop offset="0%" stop-color="#2dd4bf" stop-opacity="0.35"/>
<stop offset="100%" stop-color="#2dd4bf" stop-opacity="0"/>
</radialGradient>
<linearGradient id="shine" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.45"/>
<stop offset="45%" stop-color="#ffffff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
</linearGradient>
<linearGradient id="edge" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.05"/>
<stop offset="50%" stop-color="#ffffff" stop-opacity="0.55"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.05"/>
</linearGradient>
<filter id="blur80" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="80"/>
</filter>
<filter id="shadow" x="-40%" y="-40%" width="180%" height="180%">
<feDropShadow dx="0" dy="16" stdDeviation="20" flood-color="#020617" flood-opacity="0.55"/>
</filter>
<marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
<path d="M0,0 L8,4 L0,8 Z" fill="#e2e8f0" fill-opacity="0.9"/>
</marker>
</defs>
<rect width="960" height="460" fill="url(#bg)"/>
<ellipse cx="180" cy="80" rx="220" ry="160" fill="url(#orb1)" filter="url(#blur80)"/>
<ellipse cx="820" cy="120" rx="200" ry="150" fill="url(#orb2)" filter="url(#blur80)"/>
<ellipse cx="480" cy="400" rx="260" ry="180" fill="url(#orb3)" filter="url(#blur80)"/>
<text x="480" y="44" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="28" font-weight="700" fill="#f8fafc" letter-spacing="-0.6">Stateless vs stateful routes</text>
<text x="480" y="72" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="14" fill="#cbd5e1" fill-opacity="0.85">Lean clicks vs session-backed password unlock</text>
<g filter="url(#shadow)">
<rect x="48" y="96" width="416" height="280" rx="28" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="48" y="96" width="416" height="280" rx="28" fill="url(#shine)" pointer-events="none"/>
<rect x="48" y="96" width="416" height="3" rx="1.5" fill="#34d399" fill-opacity="0.85"/>
<text x="76" y="132" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="17" fill="#e2e8f0" font-weight="700">Every redirect click</text>
<rect x="76" y="148" width="88" height="22" rx="11" fill="#ffffff" fill-opacity="0.08" stroke="#ffffff" stroke-opacity="0.35"/><text x="120.0" y="163.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="10" fill="#f1f5f9">STATELESS</text>
<text x="76" y="188" text-anchor="start" font-family="ui-monospace,Menlo,monospace" font-size="12" fill="#e2e8f0">GET /s/[key]</text>
<text x="76" y="210" text-anchor="start" font-family="ui-monospace,Menlo,monospace" font-size="12" fill="#e2e8f0">GET /[key] on custom domain</text>
<text x="76" y="232" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0">Middleware: throttle:120,1</text>
<text x="76" y="254" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0">No cookies, no CSRF</text>
<rect x="128" y="284" width="200" height="24" rx="12" fill="#ffffff" fill-opacity="0.08" stroke="#ffffff" stroke-opacity="0.35"/><text x="228.0" y="300.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="10" fill="#f1f5f9">Fixed in v5.2</text>
</g>
<g filter="url(#shadow)">
<rect x="496" y="96" width="416" height="280" rx="28" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="496" y="96" width="416" height="280" rx="28" fill="url(#shine)" pointer-events="none"/>
<rect x="496" y="96" width="416" height="3" rx="1.5" fill="#60a5fa" fill-opacity="0.85"/>
<text x="524" y="132" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="17" fill="#e2e8f0" font-weight="700">Password unlock only</text>
<rect x="524" y="148" width="88" height="22" rx="11" fill="#ffffff" fill-opacity="0.08" stroke="#ffffff" stroke-opacity="0.35"/><text x="568.0" y="163.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="10" fill="#f1f5f9">SESSION</text>
<text x="524" y="188" text-anchor="start" font-family="ui-monospace,Menlo,monospace" font-size="12" fill="#e2e8f0">GET /s-auth/[key]</text>
<text x="524" y="210" text-anchor="start" font-family="ui-monospace,Menlo,monospace" font-size="12" fill="#e2e8f0">GET /auth/[key] on custom domain</text>
<text x="524" y="232" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0">Middleware: web + throttle</text>
<text x="524" y="254" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0">Unlock remembered per session</text>
<rect x="576" y="284" width="200" height="24" rx="12" fill="#ffffff" fill-opacity="0.08" stroke="#ffffff" stroke-opacity="0.35"/><text x="676.0" y="300.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="10" fill="#f1f5f9">By design</text>
</g>
<g filter="url(#shadow)">
<rect x="48" y="396" width="864" height="44" rx="16" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="48" y="396" width="864" height="44" rx="16" fill="url(#shine)" pointer-events="none"/>
<rect x="48" y="396" width="864" height="3" rx="1.5" fill="#f87171" fill-opacity="0.85"/>
<text x="480" y="424" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#fecaca">Old audit: web on /s/[key] sent cookies on every click. Republish config if middleware still includes web.</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 540" role="img" aria-label="Custom domain routing">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1e1b4b"/>
<stop offset="45%" stop-color="#0f172a"/>
<stop offset="100%" stop-color="#164e63"/>
</linearGradient>
<radialGradient id="orb1" cx="20%" cy="15%" r="55%">
<stop offset="0%" stop-color="#a855f7" stop-opacity="0.55"/>
<stop offset="100%" stop-color="#a855f7" stop-opacity="0"/>
</radialGradient>
<radialGradient id="orb2" cx="85%" cy="25%" r="50%">
<stop offset="0%" stop-color="#38bdf8" stop-opacity="0.45"/>
<stop offset="100%" stop-color="#38bdf8" stop-opacity="0"/>
</radialGradient>
<radialGradient id="orb3" cx="50%" cy="90%" r="45%">
<stop offset="0%" stop-color="#2dd4bf" stop-opacity="0.35"/>
<stop offset="100%" stop-color="#2dd4bf" stop-opacity="0"/>
</radialGradient>
<linearGradient id="shine" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.45"/>
<stop offset="45%" stop-color="#ffffff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
</linearGradient>
<linearGradient id="edge" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.05"/>
<stop offset="50%" stop-color="#ffffff" stop-opacity="0.55"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.05"/>
</linearGradient>
<filter id="blur80" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="80"/>
</filter>
<filter id="shadow" x="-40%" y="-40%" width="180%" height="180%">
<feDropShadow dx="0" dy="16" stdDeviation="20" flood-color="#020617" flood-opacity="0.55"/>
</filter>
<marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
<path d="M0,0 L8,4 L0,8 Z" fill="#e2e8f0" fill-opacity="0.9"/>
</marker>
</defs>
<rect width="960" height="540" fill="url(#bg)"/>
<ellipse cx="180" cy="80" rx="220" ry="160" fill="url(#orb1)" filter="url(#blur80)"/>
<ellipse cx="820" cy="120" rx="200" ry="150" fill="url(#orb2)" filter="url(#blur80)"/>
<ellipse cx="480" cy="480" rx="260" ry="180" fill="url(#orb3)" filter="url(#blur80)"/>
<text x="480" y="44" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="28" font-weight="700" fill="#f8fafc" letter-spacing="-0.6">Custom domains and domain_scope_id</text>
<text x="480" y="72" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="14" fill="#cbd5e1" fill-opacity="0.85">Same slug on different domains without collision</text>
<g filter="url(#shadow)">
<rect x="64" y="96" width="380" height="150" rx="24" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="64" y="96" width="380" height="150" rx="24" fill="url(#shine)" pointer-events="none"/>
<rect x="64" y="96" width="380" height="3" rx="1.5" fill="#c084fc" fill-opacity="0.85"/>
<text x="254" y="132" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="16" fill="#e2e8f0" font-weight="700">Default app domain</text>
<text x="254" y="160" text-anchor="middle" font-family="ui-monospace,Menlo,monospace" font-size="12" fill="#ddd6fe">app.test/s/promo</text>
<rect x="154" y="178" width="200" height="26" rx="13" fill="#ffffff" fill-opacity="0.08" stroke="#ffffff" stroke-opacity="0.35"/><text x="254.0" y="195.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="10" fill="#f1f5f9">domain_scope_id = 0</text>
</g>
<g filter="url(#shadow)">
<rect x="516" y="96" width="380" height="150" rx="24" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="516" y="96" width="380" height="150" rx="24" fill="url(#shine)" pointer-events="none"/>
<rect x="516" y="96" width="380" height="3" rx="1.5" fill="#38bdf8" fill-opacity="0.85"/>
<text x="706" y="132" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="16" fill="#e2e8f0" font-weight="700">Verified custom domain</text>
<text x="706" y="160" text-anchor="middle" font-family="ui-monospace,Menlo,monospace" font-size="12" fill="#bae6fd">links.brand.com/promo</text>
<rect x="586" y="178" width="240" height="26" rx="13" fill="#ffffff" fill-opacity="0.08" stroke="#ffffff" stroke-opacity="0.35"/><text x="706.0" y="195.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="10" fill="#f1f5f9">scope = custom_domain_id</text>
</g><line x1="254" y1="246" x2="254" y2="276" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/><line x1="706" y1="246" x2="706" y2="276" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/>
<g filter="url(#shadow)">
<rect x="180" y="276" width="600" height="62" rx="20" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="180" y="276" width="600" height="62" rx="20" fill="url(#shine)" pointer-events="none"/>
<rect x="180" y="276" width="600" height="3" rx="1.5" fill="#34d399" fill-opacity="0.85"/>
<text x="480" y="304" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="13" fill="#a7f3d0" font-weight="700">Composite unique: (url_key, domain_scope_id)</text>
<text x="480" y="324" text-anchor="middle" font-family="ui-monospace,Menlo,monospace" font-size="11" fill="#94a3b8">GET /links/exists?url_key=promo&amp;custom_domain_id=3</text>
</g>
<g filter="url(#shadow)">
<rect x="80" y="360" width="800" height="120" rx="24" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="80" y="360" width="800" height="120" rx="24" fill="url(#shine)" pointer-events="none"/>
<rect x="80" y="360" width="800" height="3" rx="1.5" fill="#fb923c" fill-opacity="0.85"/>
<text x="480" y="392" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="16" fill="#fed7aa" font-weight="700">Ownership enforcement</text>
<text x="480" y="418" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0">Filament domains scoped by user_id</text>
<text x="480" y="442" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0">API: CustomDomainValidator blocks foreign domain IDs</text>
<rect x="320" y="458" width="320" height="24" rx="12" fill="#ffffff" fill-opacity="0.08" stroke="#ffffff" stroke-opacity="0.35"/><text x="480.0" y="474.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="10" fill="#f1f5f9">Fixed: exists scope + ownership (v5.2)</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.9 KiB

View File

@@ -0,0 +1,85 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 460" role="img" aria-label="Targeting and A/B">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1e1b4b"/>
<stop offset="45%" stop-color="#0f172a"/>
<stop offset="100%" stop-color="#164e63"/>
</linearGradient>
<radialGradient id="orb1" cx="20%" cy="15%" r="55%">
<stop offset="0%" stop-color="#a855f7" stop-opacity="0.55"/>
<stop offset="100%" stop-color="#a855f7" stop-opacity="0"/>
</radialGradient>
<radialGradient id="orb2" cx="85%" cy="25%" r="50%">
<stop offset="0%" stop-color="#38bdf8" stop-opacity="0.45"/>
<stop offset="100%" stop-color="#38bdf8" stop-opacity="0"/>
</radialGradient>
<radialGradient id="orb3" cx="50%" cy="90%" r="45%">
<stop offset="0%" stop-color="#2dd4bf" stop-opacity="0.35"/>
<stop offset="100%" stop-color="#2dd4bf" stop-opacity="0"/>
</radialGradient>
<linearGradient id="shine" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.45"/>
<stop offset="45%" stop-color="#ffffff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
</linearGradient>
<linearGradient id="edge" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.05"/>
<stop offset="50%" stop-color="#ffffff" stop-opacity="0.55"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.05"/>
</linearGradient>
<filter id="blur80" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="80"/>
</filter>
<filter id="shadow" x="-40%" y="-40%" width="180%" height="180%">
<feDropShadow dx="0" dy="16" stdDeviation="20" flood-color="#020617" flood-opacity="0.55"/>
</filter>
<marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
<path d="M0,0 L8,4 L0,8 Z" fill="#e2e8f0" fill-opacity="0.9"/>
</marker>
</defs>
<rect width="960" height="460" fill="url(#bg)"/>
<ellipse cx="180" cy="80" rx="220" ry="160" fill="url(#orb1)" filter="url(#blur80)"/>
<ellipse cx="820" cy="120" rx="200" ry="150" fill="url(#orb2)" filter="url(#blur80)"/>
<ellipse cx="480" cy="400" rx="260" ry="180" fill="url(#orb3)" filter="url(#blur80)"/>
<text x="480" y="44" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="28" font-weight="700" fill="#f8fafc" letter-spacing="-0.6">Smart targeting and A/B split</text>
<text x="480" y="72" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="14" fill="#cbd5e1" fill-opacity="0.85">RedirectUrlResolver evaluates rules top to bottom</text>
<g filter="url(#shadow)">
<rect x="360" y="92" width="240" height="46" rx="23" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="360" y="92" width="240" height="46" rx="23" fill="url(#shine)" pointer-events="none"/>
<rect x="360" y="92" width="240" height="3" rx="1.5" fill="#a78bfa" fill-opacity="0.85"/>
<text x="480" y="122" text-anchor="middle" font-family="ui-monospace,Menlo,monospace" font-size="13" fill="#e2e8f0" font-weight="600">GET /s/[key]</text>
</g><line x1="480" y1="138" x2="480" y2="158" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/>
<g filter="url(#shadow)">
<rect x="300" y="158" width="360" height="56" rx="18" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="300" y="158" width="360" height="56" rx="18" fill="url(#shine)" pointer-events="none"/>
<rect x="300" y="158" width="360" height="3" rx="1.5" fill="#38bdf8" fill-opacity="0.85"/>
<text x="480" y="184" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="13" fill="#e2e8f0" font-weight="600">RedirectUrlResolver</text>
<text x="480" y="204" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">UA, Geo, language, rules</text>
</g><line x1="480" y1="214" x2="480" y2="238" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/><polygon points="480,238 400,272 560,272" fill="#ffffff" fill-opacity="0.12" stroke="#ffffff" stroke-opacity="0.35"/><text x="480" y="262" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0" font-weight="600">First matching rule?</text><line x1="400" y1="272" x2="160" y2="310" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/><line x1="560" y1="272" x2="800" y2="310" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/>
<g filter="url(#shadow)">
<rect x="48" y="310" width="272" height="100" rx="20" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="48" y="310" width="272" height="100" rx="20" fill="url(#shine)" pointer-events="none"/>
<rect x="48" y="310" width="272" height="3" rx="1.5" fill="#4ade80" fill-opacity="0.85"/>
<text x="184" y="338" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0" font-weight="700">Rule matched</text>
<text x="184" y="358" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="10" fill="#94a3b8">Use rule URL</text>
<text x="184" y="376" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="10" fill="#94a3b8">Nested A/B in rule</text>
</g>
<g filter="url(#shadow)">
<rect x="344" y="310" width="272" height="100" rx="20" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="344" y="310" width="272" height="100" rx="20" fill="url(#shine)" pointer-events="none"/>
<rect x="344" y="310" width="272" height="3" rx="1.5" fill="#facc15" fill-opacity="0.85"/>
<text x="480" y="338" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0" font-weight="700">Root A/B</text>
<text x="480" y="358" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="10" fill="#94a3b8">Weighted 2-5 URLs</text>
<text x="480" y="376" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="10" fill="#94a3b8">Variant on visit row</text>
</g>
<g filter="url(#shadow)">
<rect x="640" y="310" width="272" height="100" rx="20" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="640" y="310" width="272" height="100" rx="20" fill="url(#shine)" pointer-events="none"/>
<rect x="640" y="310" width="272" height="3" rx="1.5" fill="#f87171" fill-opacity="0.85"/>
<text x="776" y="338" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0" font-weight="700">No match</text>
<text x="776" y="358" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="10" fill="#94a3b8">destination_url</text>
<text x="776" y="376" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="10" fill="#94a3b8">UTM merge + query forward</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

@@ -0,0 +1,83 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 500" role="img" aria-label="Webhooks and HMAC">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1e1b4b"/>
<stop offset="45%" stop-color="#0f172a"/>
<stop offset="100%" stop-color="#164e63"/>
</linearGradient>
<radialGradient id="orb1" cx="20%" cy="15%" r="55%">
<stop offset="0%" stop-color="#a855f7" stop-opacity="0.55"/>
<stop offset="100%" stop-color="#a855f7" stop-opacity="0"/>
</radialGradient>
<radialGradient id="orb2" cx="85%" cy="25%" r="50%">
<stop offset="0%" stop-color="#38bdf8" stop-opacity="0.45"/>
<stop offset="100%" stop-color="#38bdf8" stop-opacity="0"/>
</radialGradient>
<radialGradient id="orb3" cx="50%" cy="90%" r="45%">
<stop offset="0%" stop-color="#2dd4bf" stop-opacity="0.35"/>
<stop offset="100%" stop-color="#2dd4bf" stop-opacity="0"/>
</radialGradient>
<linearGradient id="shine" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.45"/>
<stop offset="45%" stop-color="#ffffff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
</linearGradient>
<linearGradient id="edge" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.05"/>
<stop offset="50%" stop-color="#ffffff" stop-opacity="0.55"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.05"/>
</linearGradient>
<filter id="blur80" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="80"/>
</filter>
<filter id="shadow" x="-40%" y="-40%" width="180%" height="180%">
<feDropShadow dx="0" dy="16" stdDeviation="20" flood-color="#020617" flood-opacity="0.55"/>
</filter>
<marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
<path d="M0,0 L8,4 L0,8 Z" fill="#e2e8f0" fill-opacity="0.9"/>
</marker>
</defs>
<rect width="960" height="500" fill="url(#bg)"/>
<ellipse cx="180" cy="80" rx="220" ry="160" fill="url(#orb1)" filter="url(#blur80)"/>
<ellipse cx="820" cy="120" rx="200" ry="150" fill="url(#orb2)" filter="url(#blur80)"/>
<ellipse cx="480" cy="440" rx="260" ry="180" fill="url(#orb3)" filter="url(#blur80)"/>
<text x="480" y="44" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="28" font-weight="700" fill="#f8fafc" letter-spacing="-0.6">Webhooks and HMAC signing</text>
<text x="480" y="72" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="14" fill="#cbd5e1" fill-opacity="0.85">Global events, per-link URL override, SSRF-safe dispatch</text>
<g filter="url(#shadow)">
<rect x="60" y="96" width="360" height="140" rx="22" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="60" y="96" width="360" height="140" rx="22" fill="url(#shine)" pointer-events="none"/>
<rect x="60" y="96" width="360" height="3" rx="1.5" fill="#c084fc" fill-opacity="0.85"/>
<text x="240" y="128" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="15" fill="#e2e8f0" font-weight="700">Global webhook</text>
<text x="240" y="152" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Settings: URL, enabled, events</text>
<text x="240" y="174" text-anchor="middle" font-family="ui-monospace,Menlo,monospace" font-size="10" fill="#e2e8f0">visited | created | expired | limit</text>
<text x="240" y="196" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#ddd6fe">Signing secret required (v5.2)</text>
<rect x="120" y="208" width="240" height="22" rx="11" fill="#ffffff" fill-opacity="0.08" stroke="#ffffff" stroke-opacity="0.35"/><text x="240.0" y="223.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="10" fill="#f1f5f9">Events are global, not per-link</text>
</g>
<g filter="url(#shadow)">
<rect x="540" y="96" width="360" height="140" rx="22" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="540" y="96" width="360" height="140" rx="22" fill="url(#shine)" pointer-events="none"/>
<rect x="540" y="96" width="360" height="3" rx="1.5" fill="#38bdf8" fill-opacity="0.85"/>
<text x="720" y="128" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="15" fill="#e2e8f0" font-weight="700">Per-link webhook URL</text>
<text x="720" y="152" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">short_urls.webhook_url override</text>
<text x="720" y="174" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0">SSRF re-check in SendWebhookJob</text>
<text x="720" y="196" text-anchor="middle" font-family="ui-monospace,Menlo,monospace" font-size="10" fill="#e2e8f0">allow_redirects=false</text>
</g><line x1="480" y1="236" x2="480" y2="262" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/>
<g filter="url(#shadow)">
<rect x="200" y="262" width="560" height="54" rx="18" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="200" y="262" width="560" height="54" rx="18" fill="url(#shine)" pointer-events="none"/>
<rect x="200" y="262" width="560" height="3" rx="1.5" fill="#34d399" fill-opacity="0.85"/>
<text x="480" y="286" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="14" fill="#e2e8f0" font-weight="700">SendWebhookJob</text>
<text x="480" y="304" text-anchor="middle" font-family="ui-monospace,Menlo,monospace" font-size="10" fill="#94a3b8">Header: X-Short-Url-Signature sha256=... (HMAC body)</text>
</g>
<g filter="url(#shadow)">
<rect x="80" y="336" width="800" height="120" rx="24" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="80" y="336" width="800" height="120" rx="24" fill="url(#shine)" pointer-events="none"/>
<rect x="80" y="336" width="800" height="3" rx="1.5" fill="#fb923c" fill-opacity="0.85"/>
<text x="480" y="368" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="15" fill="#fed7aa" font-weight="700">Audit fixes (v5.2)</text>
<text x="480" y="394" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0">Before: optional secret allowed unsigned webhooks</text>
<text x="480" y="418" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0">After: Settings rejects enable without secret</text>
<text x="480" y="442" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Safe Browsing on save + redirect | lock_url_key server-side</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.1 KiB

View File

@@ -0,0 +1,116 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 540" role="img" aria-label="Stats and queue">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1e1b4b"/>
<stop offset="45%" stop-color="#0f172a"/>
<stop offset="100%" stop-color="#164e63"/>
</linearGradient>
<radialGradient id="orb1" cx="20%" cy="15%" r="55%">
<stop offset="0%" stop-color="#a855f7" stop-opacity="0.55"/>
<stop offset="100%" stop-color="#a855f7" stop-opacity="0"/>
</radialGradient>
<radialGradient id="orb2" cx="85%" cy="25%" r="50%">
<stop offset="0%" stop-color="#38bdf8" stop-opacity="0.45"/>
<stop offset="100%" stop-color="#38bdf8" stop-opacity="0"/>
</radialGradient>
<radialGradient id="orb3" cx="50%" cy="90%" r="45%">
<stop offset="0%" stop-color="#2dd4bf" stop-opacity="0.35"/>
<stop offset="100%" stop-color="#2dd4bf" stop-opacity="0"/>
</radialGradient>
<linearGradient id="shine" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.45"/>
<stop offset="45%" stop-color="#ffffff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
</linearGradient>
<linearGradient id="edge" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.05"/>
<stop offset="50%" stop-color="#ffffff" stop-opacity="0.55"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.05"/>
</linearGradient>
<filter id="blur80" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="80"/>
</filter>
<filter id="shadow" x="-40%" y="-40%" width="180%" height="180%">
<feDropShadow dx="0" dy="16" stdDeviation="20" flood-color="#020617" flood-opacity="0.55"/>
</filter>
<marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
<path d="M0,0 L8,4 L0,8 Z" fill="#e2e8f0" fill-opacity="0.9"/>
</marker>
</defs>
<rect width="960" height="540" fill="url(#bg)"/>
<ellipse cx="180" cy="80" rx="220" ry="160" fill="url(#orb1)" filter="url(#blur80)"/>
<ellipse cx="820" cy="120" rx="200" ry="150" fill="url(#orb2)" filter="url(#blur80)"/>
<ellipse cx="480" cy="480" rx="260" ry="180" fill="url(#orb3)" filter="url(#blur80)"/>
<text x="480" y="44" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="28" font-weight="700" fill="#f8fafc" letter-spacing="-0.6">Stats pipeline and queue modes</text>
<text x="480" y="72" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="14" fill="#cbd5e1" fill-opacity="0.85">Visits, aggregation cron, panel vs API stats gap</text>
<text x="480" y="98" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="15" fill="#e2e8f0" font-weight="700">Visit tracking</text>
<g filter="url(#shadow)">
<rect x="60" y="112" width="160" height="48" rx="16" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="60" y="112" width="160" height="48" rx="16" fill="url(#shine)" pointer-events="none"/>
<rect x="60" y="112" width="160" height="3" rx="1.5" fill="#38bdf8" fill-opacity="0.85"/>
<text x="140" y="142" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0" font-weight="600">Redirect</text>
</g><line x1="220" y1="136" x2="280" y2="136" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/>
<g filter="url(#shadow)">
<rect x="280" y="112" width="160" height="48" rx="16" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="280" y="112" width="160" height="48" rx="16" fill="url(#shine)" pointer-events="none"/>
<rect x="280" y="112" width="160" height="3" rx="1.5" fill="#818cf8" fill-opacity="0.85"/>
<text x="360" y="142" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0" font-weight="600">TrackJob</text>
</g><line x1="440" y1="136" x2="540" y2="136" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/>
<g filter="url(#shadow)">
<rect x="540" y="112" width="160" height="48" rx="16" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="540" y="112" width="160" height="48" rx="16" fill="url(#shine)" pointer-events="none"/>
<rect x="540" y="112" width="160" height="3" rx="1.5" fill="#38bdf8" fill-opacity="0.85"/>
<text x="620" y="142" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0" font-weight="600">visits table</text>
</g><line x1="700" y1="136" x2="740" y2="136" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/>
<g filter="url(#shadow)">
<rect x="740" y="112" width="120" height="48" rx="16" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="740" y="112" width="120" height="48" rx="16" fill="url(#shine)" pointer-events="none"/>
<rect x="740" y="112" width="120" height="3" rx="1.5" fill="#38bdf8" fill-opacity="0.85"/>
<text x="800" y="142" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0" font-weight="600">Buffer</text>
</g>
<g filter="url(#shadow)">
<rect x="120" y="180" width="340" height="110" rx="22" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="120" y="180" width="340" height="110" rx="22" fill="url(#shine)" pointer-events="none"/>
<rect x="120" y="180" width="340" height="3" rx="1.5" fill="#34d399" fill-opacity="0.85"/>
<text x="290" y="210" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="15" fill="#6ee7b7" font-weight="700">sync (default)</text>
<text x="290" y="234" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Job runs inside redirect</text>
<text x="290" y="256" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Works without queue worker</text>
<text x="290" y="278" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="10" fill="#64748b">GeoIP, webhook, GA4 add latency</text>
</g>
<g filter="url(#shadow)">
<rect x="500" y="180" width="340" height="110" rx="22" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="500" y="180" width="340" height="110" rx="22" fill="url(#shine)" pointer-events="none"/>
<rect x="500" y="180" width="340" height="3" rx="1.5" fill="#60a5fa" fill-opacity="0.85"/>
<text x="670" y="210" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="15" fill="#93c5fd" font-weight="700">redis / database</text>
<text x="670" y="234" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">302 sent first</text>
<text x="670" y="256" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Requires queue:work</text>
<text x="670" y="278" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="10" fill="#64748b">Settings - General</text>
</g><text x="480" y="318" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="14" fill="#e2e8f0" font-weight="700">Aggregation (schedule:run)</text>
<g filter="url(#shadow)">
<rect x="140" y="332" width="200" height="44" rx="14" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="140" y="332" width="200" height="44" rx="14" fill="url(#shine)" pointer-events="none"/>
<rect x="140" y="332" width="200" height="3" rx="1.5" fill="#f87171" fill-opacity="0.85"/>
<text x="240" y="360" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0">Raw visits 90d</text>
</g><line x1="340" y1="354" x2="400" y2="354" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/>
<g filter="url(#shadow)">
<rect x="400" y="332" width="240" height="44" rx="14" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="400" y="332" width="240" height="44" rx="14" fill="url(#shine)" pointer-events="none"/>
<rect x="400" y="332" width="240" height="3" rx="1.5" fill="#fbbf24" fill-opacity="0.85"/>
<text x="520" y="360" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0">AggregateAndPrune</text>
</g><line x1="640" y1="354" x2="700" y2="354" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/>
<g filter="url(#shadow)">
<rect x="700" y="332" width="200" height="44" rx="14" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="700" y="332" width="200" height="44" rx="14" fill="url(#shine)" pointer-events="none"/>
<rect x="700" y="332" width="200" height="3" rx="1.5" fill="#4ade80" fill-opacity="0.85"/>
<text x="800" y="360" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0">daily_stats</text>
</g>
<g filter="url(#shadow)">
<rect x="80" y="400" width="800" height="90" rx="24" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="80" y="400" width="800" height="90" rx="24" fill="url(#shine)" pointer-events="none"/>
<rect x="80" y="400" width="800" height="3" rx="1.5" fill="#818cf8" fill-opacity="0.85"/>
<text x="480" y="430" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="13" fill="#e2e8f0" font-weight="600">Filament: FilteredStatsCollector + cross-dimensional rollups</text>
<text x="480" y="454" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">API stats: date_from / date_to only (cross-filters = open gap)</text>
<text x="480" y="476" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="10" fill="#64748b">Long-range reports use rollups after raw prune</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -0,0 +1,143 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 560" role="img" aria-label="Audit trust summary">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1e1b4b"/>
<stop offset="45%" stop-color="#0f172a"/>
<stop offset="100%" stop-color="#164e63"/>
</linearGradient>
<radialGradient id="orb1" cx="20%" cy="15%" r="55%">
<stop offset="0%" stop-color="#a855f7" stop-opacity="0.55"/>
<stop offset="100%" stop-color="#a855f7" stop-opacity="0"/>
</radialGradient>
<radialGradient id="orb2" cx="85%" cy="25%" r="50%">
<stop offset="0%" stop-color="#38bdf8" stop-opacity="0.45"/>
<stop offset="100%" stop-color="#38bdf8" stop-opacity="0"/>
</radialGradient>
<radialGradient id="orb3" cx="50%" cy="90%" r="45%">
<stop offset="0%" stop-color="#2dd4bf" stop-opacity="0.35"/>
<stop offset="100%" stop-color="#2dd4bf" stop-opacity="0"/>
</radialGradient>
<linearGradient id="shine" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.45"/>
<stop offset="45%" stop-color="#ffffff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
</linearGradient>
<linearGradient id="edge" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.05"/>
<stop offset="50%" stop-color="#ffffff" stop-opacity="0.55"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.05"/>
</linearGradient>
<filter id="blur80" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="80"/>
</filter>
<filter id="shadow" x="-40%" y="-40%" width="180%" height="180%">
<feDropShadow dx="0" dy="16" stdDeviation="20" flood-color="#020617" flood-opacity="0.55"/>
</filter>
<marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
<path d="M0,0 L8,4 L0,8 Z" fill="#e2e8f0" fill-opacity="0.9"/>
</marker>
</defs>
<rect width="960" height="560" fill="url(#bg)"/>
<ellipse cx="180" cy="80" rx="220" ry="160" fill="url(#orb1)" filter="url(#blur80)"/>
<ellipse cx="820" cy="120" rx="200" ry="150" fill="url(#orb2)" filter="url(#blur80)"/>
<ellipse cx="480" cy="500" rx="260" ry="180" fill="url(#orb3)" filter="url(#blur80)"/>
<text x="480" y="44" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="28" font-weight="700" fill="#f8fafc" letter-spacing="-0.6">v5.2 audit trust summary</text>
<text x="480" y="72" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="14" fill="#cbd5e1" fill-opacity="0.85">Promise vs old reality vs current status</text>
<g filter="url(#shadow)">
<rect x="48" y="92" width="864" height="36" rx="14" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="48" y="92" width="864" height="36" rx="14" fill="url(#shine)" pointer-events="none"/>
<rect x="48" y="92" width="864" height="3" rx="1.5" fill="#ffffff" fill-opacity="0.85"/>
<text x="180" y="116" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0" font-weight="700">Audit promise</text>
<text x="440" y="116" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0" font-weight="700">Old reality</text>
<text x="730" y="116" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0" font-weight="700">v5.2 status</text>
</g>
<g filter="url(#shadow)">
<rect x="48" y="140" width="864" height="34" rx="10" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="48" y="140" width="864" height="34" rx="10" fill="url(#shine)" pointer-events="none"/>
<rect x="48" y="140" width="864" height="3" rx="1.5" fill="#34d399" fill-opacity="0.85"/>
<text x="64" y="162" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0">Sub-15ms / stateless</text>
<text x="330" y="162" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">web on /s/[key]</text>
<text x="600" y="162" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#34d399" font-weight="600">FIXED: throttle only</text>
</g>
<g filter="url(#shadow)">
<rect x="48" y="180" width="864" height="34" rx="10" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="48" y="180" width="864" height="34" rx="10" fill="url(#shine)" pointer-events="none"/>
<rect x="48" y="180" width="864" height="3" rx="1.5" fill="#34d399" fill-opacity="0.85"/>
<text x="64" y="202" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0">Multi-tenant panel</text>
<text x="330" y="202" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">All admins see all links</text>
<text x="600" y="202" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#34d399" font-weight="600">FIXED: LinkUserScope</text>
</g>
<g filter="url(#shadow)">
<rect x="48" y="220" width="864" height="34" rx="10" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="48" y="220" width="864" height="34" rx="10" fill="url(#shine)" pointer-events="none"/>
<rect x="48" y="220" width="864" height="3" rx="1.5" fill="#34d399" fill-opacity="0.85"/>
<text x="64" y="242" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0">API exists</text>
<text x="330" y="242" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Ignored domain scope</text>
<text x="600" y="242" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#34d399" font-weight="600">FIXED: domain_scope_id</text>
</g>
<g filter="url(#shadow)">
<rect x="48" y="260" width="864" height="34" rx="10" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="48" y="260" width="864" height="34" rx="10" fill="url(#shine)" pointer-events="none"/>
<rect x="48" y="260" width="864" height="3" rx="1.5" fill="#34d399" fill-opacity="0.85"/>
<text x="64" y="282" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0">Domain ownership</text>
<text x="330" y="282" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Foreign domain ID</text>
<text x="600" y="282" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#34d399" font-weight="600">FIXED: CustomDomainValidator</text>
</g>
<g filter="url(#shadow)">
<rect x="48" y="300" width="864" height="34" rx="10" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="48" y="300" width="864" height="34" rx="10" fill="url(#shine)" pointer-events="none"/>
<rect x="48" y="300" width="864" height="3" rx="1.5" fill="#34d399" fill-opacity="0.85"/>
<text x="64" y="322" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0">Safe Browsing</text>
<text x="330" y="322" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Save only</text>
<text x="600" y="322" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#34d399" font-weight="600">FIXED: redirect cache</text>
</g>
<g filter="url(#shadow)">
<rect x="48" y="340" width="864" height="34" rx="10" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="48" y="340" width="864" height="34" rx="10" fill="url(#shine)" pointer-events="none"/>
<rect x="48" y="340" width="864" height="3" rx="1.5" fill="#34d399" fill-opacity="0.85"/>
<text x="64" y="362" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0">Webhook HMAC</text>
<text x="330" y="362" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Optional secret</text>
<text x="600" y="362" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#34d399" font-weight="600">FIXED: required when on</text>
</g>
<g filter="url(#shadow)">
<rect x="48" y="380" width="864" height="34" rx="10" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="48" y="380" width="864" height="34" rx="10" fill="url(#shine)" pointer-events="none"/>
<rect x="48" y="380" width="864" height="3" rx="1.5" fill="#34d399" fill-opacity="0.85"/>
<text x="64" y="402" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0">lock_url_key</text>
<text x="330" y="402" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">UI disabled only</text>
<text x="600" y="402" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#34d399" font-weight="600">FIXED: LockedUrlKeyGuard</text>
</g>
<g filter="url(#shadow)">
<rect x="48" y="420" width="864" height="34" rx="10" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="48" y="420" width="864" height="34" rx="10" fill="url(#shine)" pointer-events="none"/>
<rect x="48" y="420" width="864" height="3" rx="1.5" fill="#fbbf24" fill-opacity="0.85"/>
<text x="64" y="442" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0">Per-link webhook events</text>
<text x="330" y="442" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Documented per-link</text>
<text x="600" y="442" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#fbbf24" font-weight="600">OPEN: global list</text>
</g>
<g filter="url(#shadow)">
<rect x="48" y="460" width="864" height="34" rx="10" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="48" y="460" width="864" height="34" rx="10" fill="url(#shine)" pointer-events="none"/>
<rect x="48" y="460" width="864" height="3" rx="1.5" fill="#fbbf24" fill-opacity="0.85"/>
<text x="64" y="482" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0">Default queue async</text>
<text x="330" y="482" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">README vs sync</text>
<text x="600" y="482" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#fbbf24" font-weight="600">BY DESIGN: sync default</text>
</g>
<g filter="url(#shadow)">
<rect x="48" y="500" width="864" height="34" rx="10" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="48" y="500" width="864" height="34" rx="10" fill="url(#shine)" pointer-events="none"/>
<rect x="48" y="500" width="864" height="3" rx="1.5" fill="#34d399" fill-opacity="0.85"/>
<text x="64" y="522" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0">Dub parity table</text>
<text x="330" y="522" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Overstated</text>
<text x="600" y="522" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#34d399" font-weight="600">FIXED: scoped compare</text>
</g>
<g filter="url(#shadow)">
<rect x="48" y="548" width="864" height="72" rx="20" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="48" y="548" width="864" height="72" rx="20" fill="url(#shine)" pointer-events="none"/>
<rect x="48" y="548" width="864" height="3" rx="1.5" fill="#818cf8" fill-opacity="0.85"/>
<text x="480" y="576" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="13" fill="#e9d5ff" font-weight="600">Product boundary: Laravel Filament plugin, not Dub clone</text>
<text x="480" y="598" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Open: OpenAPI, API stats filters, workspaces, conversion, load tests</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1,353 @@
#!/usr/bin/env python3
"""Generate liquid-glass architecture SVG diagrams for README."""
from pathlib import Path
OUT = Path(__file__).parent
GLASS_HEAD = """<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 {height}" role="img" aria-label="{label}">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1e1b4b"/>
<stop offset="45%" stop-color="#0f172a"/>
<stop offset="100%" stop-color="#164e63"/>
</linearGradient>
<radialGradient id="orb1" cx="20%" cy="15%" r="55%">
<stop offset="0%" stop-color="#a855f7" stop-opacity="0.55"/>
<stop offset="100%" stop-color="#a855f7" stop-opacity="0"/>
</radialGradient>
<radialGradient id="orb2" cx="85%" cy="25%" r="50%">
<stop offset="0%" stop-color="#38bdf8" stop-opacity="0.45"/>
<stop offset="100%" stop-color="#38bdf8" stop-opacity="0"/>
</radialGradient>
<radialGradient id="orb3" cx="50%" cy="90%" r="45%">
<stop offset="0%" stop-color="#2dd4bf" stop-opacity="0.35"/>
<stop offset="100%" stop-color="#2dd4bf" stop-opacity="0"/>
</radialGradient>
<linearGradient id="shine" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.45"/>
<stop offset="45%" stop-color="#ffffff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
</linearGradient>
<linearGradient id="edge" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.05"/>
<stop offset="50%" stop-color="#ffffff" stop-opacity="0.55"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.05"/>
</linearGradient>
<filter id="blur80" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="80"/>
</filter>
<filter id="shadow" x="-40%" y="-40%" width="180%" height="180%">
<feDropShadow dx="0" dy="16" stdDeviation="20" flood-color="#020617" flood-opacity="0.55"/>
</filter>
<marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
<path d="M0,0 L8,4 L0,8 Z" fill="#e2e8f0" fill-opacity="0.9"/>
</marker>
</defs>
<rect width="960" height="{height}" fill="url(#bg)"/>
<ellipse cx="180" cy="80" rx="220" ry="160" fill="url(#orb1)" filter="url(#blur80)"/>
<ellipse cx="820" cy="120" rx="200" ry="150" fill="url(#orb2)" filter="url(#blur80)"/>
<ellipse cx="480" cy="{orb_y}" rx="260" ry="180" fill="url(#orb3)" filter="url(#blur80)"/>
<text x="480" y="44" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="28" font-weight="700" fill="#f8fafc" letter-spacing="-0.6">{title}</text>
<text x="480" y="72" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="14" fill="#cbd5e1" fill-opacity="0.85">{subtitle}</text>
"""
GLASS_CARD = """
<g filter="url(#shadow)">
<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="{rx}" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="{rx}" fill="url(#shine)" pointer-events="none"/>
{accent}{content}
</g>"""
ACCENT_TOP = '<rect x="{x}" y="{y}" width="{w}" height="3" rx="1.5" fill="{color}" fill-opacity="0.85"/>\n '
FOOT = """
</svg>
"""
def card(x, y, w, h, content, rx=26, accent=None):
accent_html = ""
if accent:
accent_html = ACCENT_TOP.format(x=x, y=y, w=w, color=accent)
return GLASS_CARD.format(x=x, y=y, w=w, h=h, rx=rx, accent=accent_html, content=content)
def t(x, y, text, size=12, weight="400", fill="#e2e8f0", anchor="start", mono=False):
fam = "ui-monospace,Menlo,monospace" if mono else "system-ui,-apple-system,Segoe UI,sans-serif"
w = f' font-weight="{weight}"' if weight != "400" else ""
return f'<text x="{x}" y="{y}" text-anchor="{anchor}" font-family="{fam}" font-size="{size}" fill="{fill}"{w}>{text}</text>'
def line(x1, y1, x2, y2):
return f'<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/>'
def pill(x, y, w, h, text, stroke="#ffffff"):
return f'<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="{h//2}" fill="#ffffff" fill-opacity="0.08" stroke="{stroke}" stroke-opacity="0.35"/><text x="{x+w/2}" y="{y+h/2+4}" text-anchor="middle" font-family="system-ui,sans-serif" font-size="10" fill="#f1f5f9">{text}</text>'
def diagram_01():
h, oy = 600, 520
body = GLASS_HEAD.format(height=h, label="System overview", title="System overview", subtitle="Filament panel, public routes, core services, persistence", orb_y=oy)
body += card(48, 100, 272, 220, "\n ".join([
t(72, 136, "Filament admin", 16, "700"),
t(72, 158, "Panel layer", 11, fill="#94a3b8"),
t(72, 188, "ShortUrlResource", 12),
t(72, 210, "Settings, domains, QR", 12),
t(72, 232, "Analytics and live feed", 12),
pill(72, 258, 170, 24, "scope_links_to_user"),
]), accent="#c084fc")
body += card(344, 100, 272, 220, "\n ".join([
t(368, 136, "Public HTTP", 16, "700"),
t(368, 158, "Edge of Laravel app", 11, fill="#94a3b8"),
t(368, 188, "GET /s/[key]", 11, mono=True, fill="#bae6fd"),
t(368, 210, "GET /s-auth/[key]", 11, mono=True, fill="#bae6fd"),
t(368, 232, "GET /api/short-url/*", 11, mono=True, fill="#bae6fd"),
t(368, 254, "/[key] on custom domain", 11, mono=True, fill="#bae6fd"),
pill(368, 278, 92, 22, "STATELESS", stroke="#38bdf8"),
]), accent="#38bdf8")
body += card(640, 100, 272, 220, "\n ".join([
t(664, 136, "Package core", 16, "700"),
t(664, 158, "Runtime services", 11, fill="#94a3b8"),
t(664, 188, "RedirectHandler", 12),
t(664, 210, "TrackShortUrlVisitJob", 12),
t(664, 232, "SendWebhookJob (HMAC)", 12),
t(664, 254, "HasStats and rollups", 12),
]), accent="#2dd4bf")
body += line(184, 320, 184, 352) + line(480, 320, 480, 352) + line(776, 320, 776, 352)
body += card(120, 360, 720, 130, "\n ".join([
t(480, 396, "Persistence layer", 17, "700", anchor="middle"),
t(480, 422, "short_urls, visits, daily_stats, cache, optional Redis buffer", 12, anchor="middle", fill="#94a3b8"),
pill(200, 448, 130, 24, "SQL DB"),
pill(350, 448, 110, 24, "Redis"),
pill(480, 448, 130, 24, "Safe Browsing"),
pill(630, 448, 110, 24, "Link cache"),
]), accent="#fb923c", rx=28)
body += card(48, 512, 864, 56, t(480, 546, "Self-hosted Laravel plugin, not edge SaaS", 13, anchor="middle", fill="#e9d5ff"), rx=20)
return body + FOOT
def diagram_02():
h, oy = 680, 600
body = GLASS_HEAD.format(height=h, label="Redirect lifecycle", title="Redirect lifecycle", subtitle="GET /s/[key] from click to destination", orb_y=oy)
steps = [
("Visitor click", "Start of request", "#818cf8"),
("Throttle only", "No web middleware, no session cookie", "#fbbf24"),
("findByKey(host)", "Cache hit or single DB resolve", "#a78bfa"),
("Safe Browsing cache", "isSafeCached(destination) when enabled", "#f87171"),
("max_visits lock", "lockForUpdate plus isActive()", "#34d399"),
]
y = 96
for i, (title, sub, color) in enumerate(steps):
body += card(280, y, 400, 54, "\n ".join([
t(480, y + 24, title, 13, "600", anchor="middle"),
t(480, y + 42, sub, 11, anchor="middle", fill="#94a3b8"),
]), accent=color, rx=18)
if i < len(steps) - 1:
body += line(480, y + 54, 480, y + 72)
y += 72
body += line(480, y, 480, y + 20)
body += '<polygon points="480,{0} 410,{1} 550,{1}" fill="#ffffff" fill-opacity="0.12" stroke="#ffffff" stroke-opacity="0.35"/>'.format(y + 20, y + 58)
body += t(480, y + 48, "Response path", 12, "600", anchor="middle")
y2 = y + 72
branches = [
(48, "Password", "/s-auth/[key] + web", "#fb923c"),
(344, "Interstitial", "warning, pixels, cloak, OG", "#facc15"),
(640, "Simple 302", "TrackJob then redirect", "#4ade80"),
]
for x, title, sub, color in branches:
body += card(x, y2, 272, 88, "\n ".join([
t(x + 136, y2 + 28, title, 13, "700", anchor="middle"),
t(x + 136, y2 + 48, sub, 10, anchor="middle", fill="#94a3b8"),
]), accent=color, rx=20)
body += card(120, y2 + 108, 720, 48, t(480, y2 + 136, "TrackShortUrlVisitJob: GeoIP, VPN, visit, webhook, GA4 (sync default or async queue)", 11, anchor="middle", fill="#cbd5e1"), rx=16)
return body + FOOT
def diagram_03():
h, oy = 460, 400
body = GLASS_HEAD.format(height=h, label="Stateless vs stateful", title="Stateless vs stateful routes", subtitle="Lean clicks vs session-backed password unlock", orb_y=oy)
for x, title, badge, lines, accent in [
(48, "Every redirect click", "STATELESS", ["GET /s/[key]", "GET /[key] on custom domain", "Middleware: throttle:120,1", "No cookies, no CSRF"], "#34d399"),
(496, "Password unlock only", "SESSION", ["GET /s-auth/[key]", "GET /auth/[key] on custom domain", "Middleware: web + throttle", "Unlock remembered per session"], "#60a5fa"),
]:
content = [t(x + 28, 132, title, 17, "700"), pill(x + 28, 148, 88, 22, badge)]
cy = 188
for ln in lines:
content.append(t(x + 28, cy, ln, 12, mono=("/" in ln)))
cy += 22
content.append(pill(x + 80, cy + 8, 200, 24, "Fixed in v5.2" if x == 48 else "By design"))
body += card(x, 96, 416, 280, "\n ".join(content), accent=accent, rx=28)
body += card(48, 396, 864, 44, t(480, 424, "Old audit: web on /s/[key] sent cookies on every click. Republish config if middleware still includes web.", 11, anchor="middle", fill="#fecaca"), rx=16, accent="#f87171")
return body + FOOT
def diagram_04():
h, oy = 540, 480
body = GLASS_HEAD.format(height=h, label="Custom domain routing", title="Custom domains and domain_scope_id", subtitle="Same slug on different domains without collision", orb_y=oy)
body += card(64, 96, 380, 150, "\n ".join([
t(254, 132, "Default app domain", 16, "700", anchor="middle"),
t(254, 160, "app.test/s/promo", 12, anchor="middle", mono=True, fill="#ddd6fe"),
pill(154, 178, 200, 26, "domain_scope_id = 0"),
]), accent="#c084fc", rx=24)
body += card(516, 96, 380, 150, "\n ".join([
t(706, 132, "Verified custom domain", 16, "700", anchor="middle"),
t(706, 160, "links.brand.com/promo", 12, anchor="middle", mono=True, fill="#bae6fd"),
pill(586, 178, 240, 26, "scope = custom_domain_id"),
]), accent="#38bdf8", rx=24)
body += line(254, 246, 254, 276) + line(706, 246, 706, 276)
body += card(180, 276, 600, 62, "\n ".join([
t(480, 304, "Composite unique: (url_key, domain_scope_id)", 13, "700", anchor="middle", fill="#a7f3d0"),
t(480, 324, "GET /links/exists?url_key=promo&amp;custom_domain_id=3", 11, anchor="middle", mono=True, fill="#94a3b8"),
]), accent="#34d399", rx=20)
body += card(80, 360, 800, 120, "\n ".join([
t(480, 392, "Ownership enforcement", 16, "700", anchor="middle", fill="#fed7aa"),
t(480, 418, "Filament domains scoped by user_id", 12, anchor="middle"),
t(480, 442, "API: CustomDomainValidator blocks foreign domain IDs", 12, anchor="middle"),
pill(320, 458, 320, 24, "Fixed: exists scope + ownership (v5.2)"),
]), accent="#fb923c", rx=24)
return body + FOOT
def diagram_05():
h, oy = 460, 400
body = GLASS_HEAD.format(height=h, label="Targeting and A/B", title="Smart targeting and A/B split", subtitle="RedirectUrlResolver evaluates rules top to bottom", orb_y=oy)
body += card(360, 92, 240, 46, t(480, 122, "GET /s/[key]", 13, "600", anchor="middle", mono=True), rx=23, accent="#a78bfa")
body += line(480, 138, 480, 158)
body += card(300, 158, 360, 56, "\n ".join([
t(480, 184, "RedirectUrlResolver", 13, "600", anchor="middle"),
t(480, 204, "UA, Geo, language, rules", 11, anchor="middle", fill="#94a3b8"),
]), rx=18, accent="#38bdf8")
body += line(480, 214, 480, 238)
body += '<polygon points="480,238 400,272 560,272" fill="#ffffff" fill-opacity="0.12" stroke="#ffffff" stroke-opacity="0.35"/>'
body += t(480, 262, "First matching rule?", 12, "600", anchor="middle")
body += line(400, 272, 160, 310) + line(560, 272, 800, 310)
for x, title, lines, color in [
(48, "Rule matched", ["Use rule URL", "Nested A/B in rule"], "#4ade80"),
(344, "Root A/B", ["Weighted 2-5 URLs", "Variant on visit row"], "#facc15"),
(640, "No match", ["destination_url", "UTM merge + query forward"], "#f87171"),
]:
body += card(x, 310, 272, 100, "\n ".join([t(x + 136, 338, title, 12, "700", anchor="middle")] + [t(x + 136, 358 + i * 18, ln, 10, anchor="middle", fill="#94a3b8") for i, ln in enumerate(lines)]), accent=color, rx=20)
return body + FOOT
def diagram_06():
h, oy = 500, 440
body = GLASS_HEAD.format(height=h, label="Webhooks and HMAC", title="Webhooks and HMAC signing", subtitle="Global events, per-link URL override, SSRF-safe dispatch", orb_y=oy)
body += card(60, 96, 360, 140, "\n ".join([
t(240, 128, "Global webhook", 15, "700", anchor="middle"),
t(240, 152, "Settings: URL, enabled, events", 11, anchor="middle", fill="#94a3b8"),
t(240, 174, "visited | created | expired | limit", 10, anchor="middle", mono=True),
t(240, 196, "Signing secret required (v5.2)", 11, anchor="middle", fill="#ddd6fe"),
pill(120, 208, 240, 22, "Events are global, not per-link"),
]), accent="#c084fc", rx=22)
body += card(540, 96, 360, 140, "\n ".join([
t(720, 128, "Per-link webhook URL", 15, "700", anchor="middle"),
t(720, 152, "short_urls.webhook_url override", 11, anchor="middle", fill="#94a3b8"),
t(720, 174, "SSRF re-check in SendWebhookJob", 11, anchor="middle"),
t(720, 196, "allow_redirects=false", 10, anchor="middle", mono=True),
]), accent="#38bdf8", rx=22)
body += line(480, 236, 480, 262)
body += card(200, 262, 560, 54, "\n ".join([
t(480, 286, "SendWebhookJob", 14, "700", anchor="middle"),
t(480, 304, "Header: X-Short-Url-Signature sha256=... (HMAC body)", 10, anchor="middle", mono=True, fill="#94a3b8"),
]), accent="#34d399", rx=18)
body += card(80, 336, 800, 120, "\n ".join([
t(480, 368, "Audit fixes (v5.2)", 15, "700", anchor="middle", fill="#fed7aa"),
t(480, 394, "Before: optional secret allowed unsigned webhooks", 12, anchor="middle"),
t(480, 418, "After: Settings rejects enable without secret", 12, anchor="middle"),
t(480, 442, "Safe Browsing on save + redirect | lock_url_key server-side", 11, anchor="middle", fill="#94a3b8"),
]), accent="#fb923c", rx=24)
return body + FOOT
def diagram_07():
h, oy = 540, 480
body = GLASS_HEAD.format(height=h, label="Stats and queue", title="Stats pipeline and queue modes", subtitle="Visits, aggregation cron, panel vs API stats gap", orb_y=oy)
body += t(480, 98, "Visit tracking", 15, "700", anchor="middle")
boxes = [(60, "Redirect"), (280, "TrackJob"), (540, "visits table"), (740, "Buffer")]
for i, (x, label) in enumerate(boxes):
body += card(x, 112, 160 if x < 740 else 120, 48, t(x + (80 if x < 740 else 60), 142, label, 11, "600", anchor="middle"), rx=16, accent="#818cf8" if i == 1 else "#38bdf8")
if i < len(boxes) - 1:
nx = boxes[i + 1][0]
body += line(x + (160 if x < 740 else 120), 136, nx, 136)
body += card(120, 180, 340, 110, "\n ".join([
t(290, 210, "sync (default)", 15, "700", anchor="middle", fill="#6ee7b7"),
t(290, 234, "Job runs inside redirect", 11, anchor="middle", fill="#94a3b8"),
t(290, 256, "Works without queue worker", 11, anchor="middle", fill="#94a3b8"),
t(290, 278, "GeoIP, webhook, GA4 add latency", 10, anchor="middle", fill="#64748b"),
]), accent="#34d399", rx=22)
body += card(500, 180, 340, 110, "\n ".join([
t(670, 210, "redis / database", 15, "700", anchor="middle", fill="#93c5fd"),
t(670, 234, "302 sent first", 11, anchor="middle", fill="#94a3b8"),
t(670, 256, "Requires queue:work", 11, anchor="middle", fill="#94a3b8"),
t(670, 278, "Settings - General", 10, anchor="middle", fill="#64748b"),
]), accent="#60a5fa", rx=22)
body += t(480, 318, "Aggregation (schedule:run)", 14, "700", anchor="middle")
body += card(140, 332, 200, 44, t(240, 360, "Raw visits 90d", 11, anchor="middle"), rx=14, accent="#f87171")
body += line(340, 354, 400, 354)
body += card(400, 332, 240, 44, t(520, 360, "AggregateAndPrune", 11, anchor="middle"), rx=14, accent="#fbbf24")
body += line(640, 354, 700, 354)
body += card(700, 332, 200, 44, t(800, 360, "daily_stats", 11, anchor="middle"), rx=14, accent="#4ade80")
body += card(80, 400, 800, 90, "\n ".join([
t(480, 430, "Filament: FilteredStatsCollector + cross-dimensional rollups", 13, "600", anchor="middle"),
t(480, 454, "API stats: date_from / date_to only (cross-filters = open gap)", 11, anchor="middle", fill="#94a3b8"),
t(480, 476, "Long-range reports use rollups after raw prune", 10, anchor="middle", fill="#64748b"),
]), accent="#818cf8", rx=24)
return body + FOOT
def diagram_08():
h, oy = 560, 500
body = GLASS_HEAD.format(height=h, label="Audit trust summary", title="v5.2 audit trust summary", subtitle="Promise vs old reality vs current status", orb_y=oy)
rows = [
("Sub-15ms / stateless", "web on /s/[key]", "FIXED: throttle only", "#34d399"),
("Multi-tenant panel", "All admins see all links", "FIXED: LinkUserScope", "#34d399"),
("API exists", "Ignored domain scope", "FIXED: domain_scope_id", "#34d399"),
("Domain ownership", "Foreign domain ID", "FIXED: CustomDomainValidator", "#34d399"),
("Safe Browsing", "Save only", "FIXED: redirect cache", "#34d399"),
("Webhook HMAC", "Optional secret", "FIXED: required when on", "#34d399"),
("lock_url_key", "UI disabled only", "FIXED: LockedUrlKeyGuard", "#34d399"),
("Per-link webhook events", "Documented per-link", "OPEN: global list", "#fbbf24"),
("Default queue async", "README vs sync", "BY DESIGN: sync default", "#fbbf24"),
("Dub parity table", "Overstated", "FIXED: scoped compare", "#34d399"),
]
y = 92
body += card(48, y, 864, 36, "\n ".join([
t(180, y + 24, "Audit promise", 12, "700", anchor="middle"),
t(440, y + 24, "Old reality", 12, "700", anchor="middle"),
t(730, y + 24, "v5.2 status", 12, "700", anchor="middle"),
]), rx=14, accent="#ffffff")
y += 48
for promise, old, status, color in rows:
body += card(48, y, 864, 34, "\n ".join([
t(64, y + 22, promise, 11),
t(330, y + 22, old, 11, fill="#94a3b8"),
t(600, y + 22, status, 11, "600", fill=color),
]), rx=10, accent=color if "FIXED" in status else "#fbbf24")
y += 40
body += card(48, y + 8, 864, 72, "\n ".join([
t(480, y + 36, "Product boundary: Laravel Filament plugin, not Dub clone", 13, "600", anchor="middle", fill="#e9d5ff"),
t(480, y + 58, "Open: OpenAPI, API stats filters, workspaces, conversion, load tests", 11, anchor="middle", fill="#94a3b8"),
]), rx=20, accent="#818cf8")
return body + FOOT
DIAGRAMS = [
("01-system-overview.svg", diagram_01),
("02-redirect-lifecycle.svg", diagram_02),
("03-stateless-vs-stateful.svg", diagram_03),
("04-custom-domain-routing.svg", diagram_04),
("05-targeting-and-ab.svg", diagram_05),
("06-webhooks-and-security.svg", diagram_06),
("07-stats-and-queue.svg", diagram_07),
("08-audit-trust-summary.svg", diagram_08),
]
if __name__ == "__main__":
for name, fn in DIAGRAMS:
path = OUT / name
path.write_text(fn(), encoding="utf-8")
print(f"Wrote {name}")

BIN
art/logo-fism.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
art/plugin-filament-v2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

BIN
art/preview.webm Normal file

Binary file not shown.

BIN
art/v3_1r.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

BIN
art/v3_2r.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

BIN
art/v3_3r.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 KiB

BIN
art/v3_4r.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

BIN
art/v3_5r.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 511 KiB

View File

@@ -4,11 +4,11 @@
"description": "A professional Short URL manager plugin for Filament v5 with QR code design, visit tracking, geo-IP detection, and analytics.",
"keywords": ["filament", "short-url", "qr-code", "analytics", "tracking", "laravel"],
"homepage": "https://github.com/janczakb/filament-short-url",
"license": "MIT",
"license": "proprietary",
"authors": [
{
"name": "Bartek Janczak",
"email": "hello@bjanczak.dev",
"email": "barek122@gmail.com",
"role": "Developer"
}
],

12190
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
<?php
use App\Models\User;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit;
@@ -14,6 +15,16 @@ return [
*/
'route_prefix' => env('SHORT_URL_PREFIX', 's'),
/*
|--------------------------------------------------------------------------
| Site Name Override
|--------------------------------------------------------------------------
| The brand or site name displayed on the password prompt, redirect warnings,
| and pixel loading screens. Falls back to config('app.name') if empty.
|
*/
'site_name' => env('SHORT_URL_SITE_NAME'),
/*
|--------------------------------------------------------------------------
| Default Redirect Status Code
@@ -32,6 +43,35 @@ 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),
/*
|--------------------------------------------------------------------------
| Custom Domains
|--------------------------------------------------------------------------
| enforce_dns_on_activate: when true, activating a domain (or changing its
| hostname while active) requires a passing DNS check against app.url.
*/
'custom_domains' => [
'enforce_dns_on_activate' => env('SHORT_URL_CUSTOM_DOMAIN_ENFORCE_DNS', true),
],
/*
|--------------------------------------------------------------------------
| Geo-IP Settings
@@ -60,6 +100,7 @@ return [
*/
'tracking' => [
'enabled' => true,
'anonymize_ips' => env('SHORT_URL_ANONYMIZE_IPS', false),
'fields' => [
'ip_address' => true,
'browser' => true,
@@ -68,6 +109,7 @@ return [
'operating_system_version' => true,
'referer_url' => true,
'device_type' => true,
'browser_language' => true,
],
],
@@ -89,10 +131,36 @@ return [
| Queue Connection
|--------------------------------------------------------------------------
| Visit tracking is dispatched to a queue for ultra-fast redirects.
| Set to null to run synchronously (not recommended in production).
| Default is `sync` (no background worker required). Switch to `database` or
| `redis` in Settings or via SHORT_URL_QUEUE when you run a queue worker.
*/
'queue_connection' => env('SHORT_URL_QUEUE', 'sync'),
/*
|--------------------------------------------------------------------------
| Queue Name
|--------------------------------------------------------------------------
| The queue name to which visit tracking and counter jobs are dispatched.
|
*/
'queue_name' => env('SHORT_URL_QUEUE_NAME', 'default'),
/*
|--------------------------------------------------------------------------
| Redis (Settings override when Queue Connection = redis)
|--------------------------------------------------------------------------
| Defaults from .env; overridden at runtime from Settings → General when
| queue_connection is redis. Used for queue driver, visit counters, stats,
| and live feed — independent of CACHE_STORE.
*/
'redis' => [
'host' => env('REDIS_HOST', '127.0.0.1'),
'port' => (int) env('REDIS_PORT', 6379),
'password' => env('REDIS_PASSWORD'),
'database' => (int) env('REDIS_DB', 0),
'prefix' => env('REDIS_PREFIX', ''),
],
/*
|--------------------------------------------------------------------------
| Redirect Cache TTL
@@ -121,14 +189,12 @@ return [
| Default design settings for newly generated QR codes.
*/
'qr_defaults' => [
'size' => 300,
'margin' => 1,
'dot_style' => 'square',
'foreground_color' => '#000000',
'background_color' => '#ffffff',
'gradient_enabled' => false,
'gradient_from' => '#000000',
'gradient_to' => '#ffffff',
'gradient_from' => '#4f46e5',
'gradient_to' => '#06b6d4',
'gradient_type' => 'linear',
],
@@ -157,17 +223,279 @@ return [
*/
'trust_cdn_headers' => (bool) env('SHORT_URL_TRUST_CDN_HEADERS', false),
/*
|--------------------------------------------------------------------------
| Developer REST API
|--------------------------------------------------------------------------
| Enable programmatic access to short URLs via /api/short-url/* endpoints.
| API keys are managed in the Filament settings panel.
|
*/
'api_enabled' => env('SHORT_URL_API_ENABLED', false),
/*
|--------------------------------------------------------------------------
| VPN & Proxy Detection
|--------------------------------------------------------------------------
| Detect VPN, proxy, and Tor connections on incoming visits.
|
*/
'vpn_detection' => [
'enabled' => env('SHORT_URL_VPN_DETECTION', false),
'driver' => env('SHORT_URL_VPN_DRIVER', 'ip-api'),
'vpnapi_key' => env('SHORT_URL_VPNAPI_KEY'),
'block_action' => env('SHORT_URL_VPN_BLOCK_ACTION', 'flag_only'),
'cache_ttl' => 86400,
'timeout' => 2,
],
/*
|--------------------------------------------------------------------------
| Google Safe Browsing
|--------------------------------------------------------------------------
| Block phishing and malware URLs at creation time.
|
*/
'safe_browsing' => [
'enabled' => env('SHORT_URL_SAFE_BROWSING', false),
'api_key' => env('SHORT_URL_SAFE_BROWSING_KEY'),
'check_on_redirect' => env('SHORT_URL_SAFE_BROWSING_ON_REDIRECT', true),
'redirect_cache_ttl' => (int) env('SHORT_URL_SAFE_BROWSING_REDIRECT_CACHE_TTL', 3600),
],
/*
|--------------------------------------------------------------------------
| Click Deduplication
|--------------------------------------------------------------------------
| Ignore repeated clicks from the same IP within the configured window.
|
*/
'click_deduplication' => [
'enabled' => env('SHORT_URL_CLICK_DEDUP', false),
'hours' => (int) env('SHORT_URL_CLICK_DEDUP_HOURS', 1),
],
/*
|--------------------------------------------------------------------------
| Data Pruning & Aggregation
|--------------------------------------------------------------------------
| To keep the database clean and fast, raw visit logs can be aggregated
| into daily statistics and pruned after a retention period.
|
*/
'pruning' => [
'enabled' => env('SHORT_URL_PRUNING_ENABLED', true),
'retention_days' => env('SHORT_URL_PRUNING_DAYS', 90),
],
/*
|--------------------------------------------------------------------------
| Rate Limiting
|--------------------------------------------------------------------------
| Protect redirect routes from bot abuse and brute force by enabling
| rate limiting.
|
*/
'rate_limiting' => [
'enabled' => env('SHORT_URL_RATE_LIMITING', false),
'max_attempts' => env('SHORT_URL_RATE_LIMIT_MAX', 60),
'decay_seconds' => env('SHORT_URL_RATE_LIMIT_DECAY', 60),
],
/*
|--------------------------------------------------------------------------
| 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
|--------------------------------------------------------------------------
| The middleware list applied to the short URL redirect route.
| By default, standard web middleware and rate limiting are applied.
| Middleware applied to the main short URL redirect route (/s/{key}).
| Intentionally excludes the "web" group (sessions/cookies) for a lean
| hot path. Password routes always load "web" separately (/s-auth/{key}).
| Add "web" here only if your app requires session on every redirect.
|
*/
'middleware' => [
'web',
'throttle:120,1',
],
/*
|--------------------------------------------------------------------------
| Link ownership scoping
|--------------------------------------------------------------------------
| When true, authenticated Filament users only see links they created
| (short_urls.user_id). Disable for shared admin panels.
|
*/
'scope_links_to_user' => env('SHORT_URL_SCOPE_TO_USER', true),
/*
|--------------------------------------------------------------------------
| Max visits pessimistic locking
|--------------------------------------------------------------------------
| When remaining slots are above this threshold, use a lock-free UPDATE path.
| Near the cap, a row lock is used to stay exact under concurrent traffic.
*/
'max_visits_pessimistic_remaining' => (int) env('SHORT_URL_MAX_VISITS_PESSIMISTIC_REMAINING', 5),
/*
|--------------------------------------------------------------------------
| Live feed (SSE)
|--------------------------------------------------------------------------
*/
'live_feed' => [
'sse_interval_seconds' => (int) env('SHORT_URL_LIVE_FEED_SSE_INTERVAL', 3),
'sse_max_duration_seconds' => (int) env('SHORT_URL_LIVE_FEED_SSE_MAX_DURATION', 120),
// When the cache store is Redis, SSE blocks on pub/sub instead of sleep-polling.
'use_redis_push' => env('SHORT_URL_LIVE_FEED_REDIS_PUSH', true),
],
/*
|--------------------------------------------------------------------------
| Webhook signing
|--------------------------------------------------------------------------
| Require a signing secret before dispatching global webhooks.
|
*/
'webhook_signing_required' => env('SHORT_URL_WEBHOOK_SIGNING_REQUIRED', true),
/*
|--------------------------------------------------------------------------
| Deep Linking v2.1
|--------------------------------------------------------------------------
| Universal Links (iOS) and App Links (Android) configuration.
|
*/
'deep_linking' => [
'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' => User::class,
'name_column' => 'name',
'email_column' => 'email',
'avatar_column' => 'avatar_url', // can be attribute/method on model or null to auto-detect HasAvatar
],
/*
|--------------------------------------------------------------------------
| Bot Detection
|--------------------------------------------------------------------------
| Patterns used to identify crawlers and link-preview bots. Specific tokens
| avoid false positives from real browsers (e.g. Chrome never matches "googlebot").
|
*/
'bot_detection' => [
'user_agent_contains' => [
'facebookexternalhit',
'meta-externalagent',
'meta-externalfetcher',
'meta-externalads',
'meta-webindexer',
'Googlebot',
'Google-InspectionTool',
'GoogleOther',
'Storebot-Google',
'bingbot',
'BingPreview',
'DuckDuckBot',
'Slurp',
'Baiduspider',
'YandexBot',
'Applebot',
'Applebot-Extended',
'Twitterbot',
'LinkedInBot',
'linkedinbot',
'Slackbot',
'Slack-ImgProxy',
'Discordbot',
'TelegramBot',
'WhatsApp',
'Pinterestbot',
'Embedly',
'Iframely',
'SkypeUriPreview',
'facebookcatalog',
'MetaInspector',
'vkShare',
'Tumblr',
'redditbot',
'Snap URL Preview',
'Snapchat',
'ChatGPT-User',
'Claude-Web',
'anthropic-ai',
'PerplexityBot',
'Bytespider',
'GPTBot',
'OAI-SearchBot',
'HeadlessChrome',
'Go-http-client',
'python-requests',
'axios/',
'GuzzleHttp',
'PostmanRuntime',
'Insomnia',
'Scrapy',
'FeedBurner',
'W3C_Validator',
'Validator.nu',
'PocketParser',
'BitlyBot',
'rogerbot',
'SemrushBot',
'AhrefsBot',
'MJ12bot',
'DotBot',
'PetalBot',
'Sogou',
'ia_archiver',
'archive.org_bot',
'Wayback',
'UptimeRobot',
'StatusCake',
'Pingdom',
'GTmetrix',
'Bluesky',
'thirdLandingPageFeInfra',
'ShortLinkTranslate',
],
'user_agent_regex' => [
'/bot[\s\/;\)]/i',
'/crawler[\s\/;\)]/i',
'/spider[\s\/;\)]/i',
'/\bscraper\b/i',
'/\bslurp\b/i',
'/\bpreview\b/i',
'/^curl\//i',
'/^wget\//i',
'/\bPHP\/[\d.]+/i',
],
'referer_contains' => [
'url.emailprotection.link',
'urlsand.com',
'statics.teams.cdn.office.net',
'security-za.m.mimecastprotect.com',
'deref-mail.com',
'deref-gmx.com',
],
'verify_google_bot_ip' => env('SHORT_URL_VERIFY_GOOGLEBOT_IP', false),
'debug_secret' => env('SHORT_URL_BOT_DEBUG_SECRET'),
],
];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,37 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('short_url_visits', function (Blueprint $table): void {
$table->string('city', 100)->nullable();
$table->string('referer_host', 255)->nullable()->index();
$table->string('utm_source', 100)->nullable()->index();
$table->string('utm_medium', 100)->nullable()->index();
$table->string('utm_campaign', 100)->nullable()->index();
$table->string('utm_term', 100)->nullable();
$table->string('utm_content', 100)->nullable();
});
}
public function down(): void
{
Schema::table('short_url_visits', function (Blueprint $table): void {
$table->dropColumn([
'city',
'referer_host',
'utm_source',
'utm_medium',
'utm_campaign',
'utm_term',
'utm_content',
]);
});
}
};

View File

@@ -1,29 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('short_urls', function (Blueprint $table): void {
$table->string('password', 255)->nullable();
$table->boolean('show_warning_page')->default(false);
$table->json('targeting_rules')->nullable();
});
}
public function down(): void
{
Schema::table('short_urls', function (Blueprint $table): void {
$table->dropColumn([
'password',
'show_warning_page',
'targeting_rules',
]);
});
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('short_urls', function (Blueprint $table) {
$table->unsignedBigInteger('user_id')->nullable()->after('id')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('short_urls', function (Blueprint $table) {
$table->dropColumn('user_id');
});
}
};

View File

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

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('short_urls', function (Blueprint $table) {
$table->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('custom_domain_id');
});
}
};

View File

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

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('short_urls', function (Blueprint $table) {
$table->string('og_title')->nullable()->after('folder_id');
$table->text('og_description')->nullable()->after('og_title');
$table->string('og_image')->nullable()->after('og_description');
$table->boolean('is_cloaked')->default(false)->after('og_image');
$table->boolean('do_index')->default(false)->after('is_cloaked');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('short_urls', function (Blueprint $table) {
$table->dropColumn(['og_title', 'og_description', 'og_image', 'is_cloaked', 'do_index']);
});
}
};

View File

@@ -0,0 +1,48 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('short_urls', function (Blueprint $table) {
$table->string('external_id')->nullable()->unique();
$table->string('utm_source')->nullable();
$table->string('utm_medium')->nullable();
$table->string('utm_campaign')->nullable();
$table->string('utm_term')->nullable();
$table->string('utm_content')->nullable();
$table->string('ref')->nullable();
$table->boolean('public_stats_enabled')->default(false);
$table->string('public_stats_password')->nullable();
});
Schema::table('short_url_visits', function (Blueprint $table) {
$table->index(['short_url_id', 'ip_hash'], 'short_url_visits_url_ip_hash_index');
});
}
public function down(): void
{
Schema::table('short_url_visits', function (Blueprint $table) {
$table->dropIndex('short_url_visits_url_ip_hash_index');
});
Schema::table('short_urls', function (Blueprint $table) {
$table->dropColumn([
'external_id',
'utm_source',
'utm_medium',
'utm_campaign',
'utm_term',
'utm_content',
'ref',
'public_stats_enabled',
'public_stats_password',
]);
});
}
};

View File

@@ -0,0 +1,63 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('short_urls', function (Blueprint $table) {
$table->unsignedBigInteger('domain_scope_id')->default(0);
});
DB::table('short_urls')->whereNotNull('custom_domain_id')->update([
'domain_scope_id' => DB::raw('custom_domain_id'),
]);
Schema::table('short_urls', function (Blueprint $table) {
$table->dropUnique(['url_key']);
$table->unique(['url_key', 'domain_scope_id'], 'short_urls_url_key_domain_scope_unique');
$table->index(['user_id', 'is_archived', 'id'], 'short_urls_user_archived_id_index');
});
Schema::table('short_urls', function (Blueprint $table) {
$table->foreign('custom_domain_id')
->references('id')
->on('short_url_custom_domains')
->nullOnDelete();
});
Schema::table('short_url_visits', function (Blueprint $table) {
$table->index(['short_url_id', 'id'], 'short_url_visits_url_id_index');
});
Schema::table('short_url_daily_stats', function (Blueprint $table) {
$table->json('utm_terms')->nullable();
$table->json('utm_contents')->nullable();
$table->json('browser_versions')->nullable();
$table->json('os_versions')->nullable();
});
}
public function down(): void
{
Schema::table('short_url_daily_stats', function (Blueprint $table) {
$table->dropColumn(['utm_terms', 'utm_contents', 'browser_versions', 'os_versions']);
});
Schema::table('short_url_visits', function (Blueprint $table) {
$table->dropIndex('short_url_visits_url_id_index');
});
Schema::table('short_urls', function (Blueprint $table) {
$table->dropForeign(['custom_domain_id']);
$table->dropIndex('short_urls_user_archived_id_index');
$table->dropUnique('short_urls_url_key_domain_scope_unique');
$table->unique('url_key');
$table->dropColumn('domain_scope_id');
});
}
};

View File

@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('short_url_daily_stats', function (Blueprint $table) {
$table->unsignedInteger('all_visits_count')->default(0)->after('visits_count');
$table->unsignedInteger('bot_visits_count')->default(0)->after('all_visits_count');
$table->unsignedInteger('proxy_visits_count')->default(0)->after('bot_visits_count');
});
}
public function down(): void
{
Schema::table('short_url_daily_stats', function (Blueprint $table) {
$table->dropColumn(['all_visits_count', 'bot_visits_count', 'proxy_visits_count']);
});
}
};

View File

@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('short_url_daily_stats', function (Blueprint $table) {
$table->json('cross_dimensional_stats')->nullable();
$table->json('cross_filter_pairs')->nullable();
$table->json('filter_qr_counts')->nullable();
});
}
public function down(): void
{
Schema::table('short_url_daily_stats', function (Blueprint $table) {
$table->dropColumn(['cross_dimensional_stats', 'cross_filter_pairs', 'filter_qr_counts']);
});
}
};

9
phpstan.neon.dist Normal file
View File

@@ -0,0 +1,9 @@
includes:
- vendor/larastan/larastan/extension.neon
parameters:
paths:
- src
level: 5
tmpDir: build/phpstan
checkMissingIterableValueType: false

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

935
resources/dist/filament-short-url.js vendored Normal file
View File

@@ -0,0 +1,935 @@
if (!window.QrHelper) {
window.QrHelper = {
scriptPromise: null,
loadScript() {
if (window.QRCodeStyling) return Promise.resolve();
if (this.scriptPromise) return this.scriptPromise;
this.scriptPromise = new Promise((resolve, reject) => {
const s = document.createElement('script');
s.id = 'qr-styling-script-tag';
s.src = '/js/janczakb/filament-short-url/qr-code-styling.js';
s.onload = resolve;
s.onerror = reject;
document.head.appendChild(s);
});
return this.scriptPromise;
},
processLogo(logo, logoShape, logoMargin) {
if (!logo) return Promise.resolve('');
const processImage = (img, hasCrossOrigin) => {
try {
const canvas = document.createElement('canvas');
canvas.width = 1200;
canvas.height = 1200;
const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
const isCircle = logoShape === 'circle';
const targetDim = 1200;
ctx.save();
if (isCircle) {
ctx.beginPath();
ctx.arc(600, 600, targetDim / 2, 0, 2 * Math.PI);
ctx.clip();
const scale = Math.max(targetDim / img.width, targetDim / img.height);
const w = img.width * scale;
const h = img.height * scale;
const x = (1200 - w) / 2;
const y = (1200 - h) / 2;
ctx.drawImage(img, x, y, w, h);
} else {
ctx.beginPath();
const offset = (1200 - targetDim) / 2;
const radius = 144 * (targetDim / 1200);
if (typeof ctx.roundRect === 'function') {
ctx.roundRect(offset, offset, targetDim, targetDim, radius);
} else {
ctx.moveTo(offset + radius, offset);
ctx.lineTo(offset + targetDim - radius, offset);
ctx.quadraticCurveTo(offset + targetDim, offset, offset + targetDim, offset + radius);
ctx.lineTo(offset + targetDim, offset + targetDim - radius);
ctx.quadraticCurveTo(offset + targetDim, offset + targetDim, offset + targetDim - radius, offset + targetDim);
ctx.lineTo(offset + radius, offset + targetDim);
ctx.quadraticCurveTo(offset, offset + targetDim, offset, offset + targetDim - radius);
ctx.lineTo(offset, offset + radius);
ctx.quadraticCurveTo(offset, offset, offset + radius, offset);
ctx.closePath();
}
ctx.clip();
const scale = Math.max(targetDim / img.width, targetDim / img.height);
const w = img.width * scale;
const h = img.height * scale;
const x = (1200 - w) / 2;
const y = (1200 - h) / 2;
ctx.drawImage(img, x, y, w, h);
}
ctx.restore();
return canvas.toDataURL('image/png');
} catch (e) {
if (hasCrossOrigin) throw e;
return logo;
}
};
return new Promise((resolve) => {
const img = new Image();
img.crossOrigin = 'anonymous';
const srcUrl = (logo.startsWith('data:') || logo.startsWith('blob:'))
? logo
: logo + (logo.includes('?') ? '&' : '?') + 't=' + new Date().getTime();
img.onload = () => {
try {
resolve(processImage(img, true));
} catch (e) {
loadWithoutCORS();
}
};
const loadWithoutCORS = () => {
const imgRetry = new Image();
imgRetry.onload = () => {
try {
resolve(processImage(imgRetry, false));
} catch (err) {
resolve(logo);
}
};
imgRetry.onerror = () => {
resolve('');
};
imgRetry.src = srcUrl;
};
img.onerror = loadWithoutCORS;
img.src = srcUrl;
});
},
buildOptions(state, url, size) {
const isGrad = state.colorMode === 'gradient';
const dotsOptions = isGrad
? { type: state.dotStyle, gradient: { type: state.gradientType,
colorStops: [{ offset: 0, color: state.gradientFrom }, { offset: 1, color: state.gradientTo }] } }
: { type: state.dotStyle, color: state.fgColor };
const mainColor = isGrad ? state.gradientFrom : state.fgColor;
const eyeSq = state.eyeConfigEnabled
? { type: state.eyeSquareStyle, color: state.eyeColor }
: { type: state.dotStyle === 'dots' ? 'dot' : 'square', color: mainColor };
const eyeDt = state.eyeConfigEnabled
? { type: state.eyeDotStyle, color: state.eyeColor }
: { type: state.dotStyle === 'dots' ? 'dot' : 'square', color: mainColor };
const options = {
type: 'svg',
width: size, height: size,
data: url, margin: 1,
dotsOptions,
backgroundOptions: state.bgTransparent ? { color: 'rgba(0,0,0,0)' } : { color: state.bgColor },
cornersSquareOptions: eyeSq,
cornersDotOptions: eyeDt,
qrOptions: { errorCorrectionLevel: state.logo ? 'H' : 'M' },
};
if (state.processedLogo) {
options.image = state.processedLogo;
options.imageOptions = {
crossOrigin: 'anonymous',
hideBackgroundDots: false, // Override to false so we can apply our mask instead of grid clearing
imageSize: parseFloat(state.logoSize) || 0.3,
margin: 0, // Override to 0 so the logo size remains constant
logoShape: state.logoShape,
actualMargin: state.logoMargin // Keep the actual margin here for our mask processor
};
}
return options;
},
render(el, opts, fixIds = false) {
if (!window.QRCodeStyling || !el) return null;
el.innerHTML = '';
const qr = new window.QRCodeStyling(opts);
qr.append(el);
const promise = qr._svgDrawingPromise || Promise.resolve();
qr.drawingPromise = promise.then(() => {
const svg = el.querySelector('svg');
if (svg) {
const prefix = 'qr-' + Math.random().toString(36).substr(2, 9);
if (fixIds) {
const elementsWithId = svg.querySelectorAll('[id]');
const idMap = new Map();
elementsWithId.forEach(elem => {
const oldId = elem.id;
const newId = prefix + '-' + oldId;
elem.id = newId;
idMap.set(oldId, newId);
});
const attrsToUpdate = ['fill', 'stroke', 'clip-path', 'filter'];
attrsToUpdate.forEach(attrName => {
svg.querySelectorAll(`[${attrName}]`).forEach(elem => {
const val = elem.getAttribute(attrName);
if (val && val.includes('url(')) {
const match = val.match(/url\(\s*['"]?#([^'"]+?)['"]?\s*\)/);
if (match && match[1]) {
const oldId = match[1];
if (idMap.has(oldId)) {
elem.setAttribute(attrName, `url(#${idMap.get(oldId)})`);
}
}
}
});
});
svg.querySelectorAll('use, image').forEach(elem => {
['href', 'xlink:href'].forEach(attrName => {
const val = elem.getAttribute(attrName);
if (val && val.startsWith('#')) {
const oldId = val.substring(1);
if (idMap.has(oldId)) {
elem.setAttribute(attrName, `#${idMap.get(oldId)}`);
}
}
});
});
}
// Custom shape clearing for logo background dots via SVG mask
const logoShape = opts.imageOptions?.logoShape;
if (logoShape) {
const width = parseFloat(svg.getAttribute('width')) || opts.width || 300;
const height = parseFloat(svg.getAttribute('height')) || opts.height || 300;
const imageSize = opts.imageOptions.imageSize || 0.3;
const logoMargin = parseFloat(opts.imageOptions.actualMargin) || 0;
// Scale the margin proportionally to the canvas size (default base width is 280)
const scaledMargin = logoMargin * (width / 280);
// Calculate exact logo size matching QRCodeStyling's internal drawQR/drawImage hidden dots logic
let hideXDots = 7;
let dotSize = 9;
if (qr._qr) {
const e = qr._qr.getModuleCount();
const r = width - 2 * (opts.margin || 0);
dotSize = Math.floor(r / e);
const errorCorrectionLevel = opts.qrOptions?.errorCorrectionLevel || 'M';
const coefficients = { L: 0.07, M: 0.15, Q: 0.25, H: 0.3 };
const coeff = coefficients[errorCorrectionLevel] || 0.15;
const c = imageSize * coeff;
const l = Math.floor(c * e * e);
hideXDots = Math.floor(Math.sqrt(l));
if (hideXDots <= 0) hideXDots = 1;
const maxHiddenAxisDots = e - 14;
if (maxHiddenAxisDots > 0 && hideXDots > maxHiddenAxisDots) {
hideXDots = maxHiddenAxisDots;
}
if (hideXDots % 2 === 0) {
hideXDots--;
}
if (hideXDots < 1) hideXDots = 1;
} else {
// Fallback estimate for standard QR version 3
hideXDots = Math.floor(Math.sqrt(imageSize * 0.3 * 29 * 29));
if (hideXDots % 2 === 0) hideXDots--;
dotSize = width / 29;
}
const libraryClearedSize = hideXDots * dotSize;
const clearedSize = libraryClearedSize + 2 * scaledMargin;
const defs = svg.querySelector('defs') || svg.appendChild(document.createElementNS('http://www.w3.org/2000/svg', 'defs'));
if (defs) {
const mask = document.createElementNS('http://www.w3.org/2000/svg', 'mask');
mask.setAttribute('id', `${prefix}-qr-logo-mask`);
const maskBg = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
maskBg.setAttribute('x', '0');
maskBg.setAttribute('y', '0');
maskBg.setAttribute('width', width.toString());
maskBg.setAttribute('height', height.toString());
maskBg.setAttribute('fill', 'white');
mask.appendChild(maskBg);
if (logoShape === 'circle') {
const maskCut = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
maskCut.setAttribute('cx', (width / 2).toString());
maskCut.setAttribute('cy', (height / 2).toString());
maskCut.setAttribute('r', (clearedSize / 2).toString());
maskCut.setAttribute('fill', 'black');
mask.appendChild(maskCut);
} else {
const maskCut = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
const x = width / 2 - clearedSize / 2;
const y = height / 2 - clearedSize / 2;
maskCut.setAttribute('x', x.toString());
maskCut.setAttribute('y', y.toString());
maskCut.setAttribute('width', clearedSize.toString());
maskCut.setAttribute('height', clearedSize.toString());
maskCut.setAttribute('rx', (clearedSize * 0.12).toString());
maskCut.setAttribute('ry', (clearedSize * 0.12).toString());
maskCut.setAttribute('fill', 'black');
mask.appendChild(maskCut);
}
defs.appendChild(mask);
// Find the dots element and wrap it in a masked group
const dotsElement = svg.querySelector('[clip-path*="clip-path-dot-color"]');
if (dotsElement) {
const parent = dotsElement.parentNode;
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
g.setAttribute('mask', `url(#${prefix}-qr-logo-mask)`);
parent.replaceChild(g, dotsElement);
g.appendChild(dotsElement);
}
}
}
const newSvg = el.querySelector('svg');
if (newSvg) {
const w = newSvg.getAttribute('width') || opts.width || 300;
const h = newSvg.getAttribute('height') || opts.height || 300;
newSvg.setAttribute('viewBox', '0 0 ' + w + ' ' + h);
newSvg.style.width = '100%';
newSvg.style.height = '100%';
newSvg.style.maxWidth = '100%';
newSvg.style.maxHeight = '100%';
}
}
});
return qr;
}
};
window.QrHelper.loadScript();
}
document.addEventListener('alpine:init', () => {
Alpine.data('shortUrlQrPreview', (config) => ({
qrInstance: null,
url: '',
processedLogo: '',
dotStyle: config.options.dot_style ?? 'square',
colorMode: config.options.color_mode ?? 'solid',
fgColor: config.options.foreground_color ?? '#000000',
gradientFrom: config.options.gradient_from ?? '#4f46e5',
gradientTo: config.options.gradient_to ?? '#06b6d4',
gradientType: config.options.gradient_type ?? 'linear',
bgTransparent: !!config.options.bg_transparent,
bgColor: config.options.background_color ?? '#ffffff',
eyeConfigEnabled: !!config.options.eye_config_enabled,
eyeSquareStyle: config.options.eye_square_style ?? 'square',
eyeDotStyle: config.options.eye_dot_style ?? 'square',
eyeColor: config.options.eye_color ?? '#000000',
logo: config.logo ?? '',
logoSize: config.options.logo_size ?? 0.55,
logoMargin: config.options.logo_margin ?? 8,
logoHideBackground: true,
logoShape: config.options.logo_shape ?? 'square',
domains: config.domains,
defaultDomain: config.defaultDomain,
routePrefix: config.routePrefix,
defaultUrlKey: config.defaultUrlKey,
protocol: config.protocol || 'https',
getLogoUrl(logoFile) {
if (!logoFile) return '';
let logoPath = null;
if (typeof logoFile === 'string') {
logoPath = logoFile;
} else if (Array.isArray(logoFile)) {
logoPath = logoFile[0];
} else if (logoFile && typeof logoFile === 'object') {
const vals = Object.values(logoFile);
if (vals.length > 0) {
logoPath = vals[0];
}
}
if (!logoPath || typeof logoPath !== 'string') return '';
if (/^(https?:|data:)/.test(logoPath)) {
return logoPath;
}
if (logoPath.startsWith('short-urls/')) {
return `/storage/${logoPath}`;
}
return `/short-url/logo/${logoPath.split('/').pop()}`;
},
init() {
this.designUpdatedHandler = (event) => {
// Livewire v4 dispatches plain objects; v3 wrapped them in an array.
const payload = Array.isArray(event.detail) ? event.detail[0] : event.detail;
if (payload?.options) {
this.applyQrDesign(payload.options, payload.logo ?? null);
}
};
window.addEventListener('qr-design-updated', this.designUpdatedHandler);
this.$nextTick(() => {
const optionsInput = document.getElementById('qr-options-json-input');
if (optionsInput && optionsInput.value && optionsInput.value !== '[object Object]') {
try {
const val = JSON.parse(optionsInput.value);
if (val && typeof val === 'object') {
this.applyState(val);
}
} catch (err) {
console.error('QR Preview: Error parsing initial options:', err);
}
}
const logoInput = document.getElementById('qr-logo-path-input');
if (logoInput && logoInput.value) {
this.logo = this.getLogoUrl(logoInput.value);
}
this.updateUrl();
window.QrHelper.loadScript().then(() => {
this.updateProcessedLogo().then(() => {
this.render();
});
});
});
const container = this.$el.closest('form') ||
this.$el.closest('.fi-modal-window') ||
this.$el.closest('.fi-layout') ||
document;
this.inputHandler = (e) => {
if (e.target) {
const name = e.target.name || '';
const id = e.target.id || '';
if (name.includes('url_key') || id.includes('url_key') ||
name.includes('custom_domain_id') || id.includes('custom_domain_id')) {
this.updateUrl();
this.render();
}
}
};
container.addEventListener('input', this.inputHandler);
container.addEventListener('change', this.inputHandler);
},
destroy() {
if (this.designUpdatedHandler) {
window.removeEventListener('qr-design-updated', this.designUpdatedHandler);
}
const container = this.$el.closest('form') ||
this.$el.closest('.fi-modal-window') ||
this.$el.closest('.fi-layout') ||
document;
if (this.inputHandler) {
container.removeEventListener('input', this.inputHandler);
container.removeEventListener('change', this.inputHandler);
}
},
applyState(val) {
if (val.dot_style !== undefined) this.dotStyle = val.dot_style;
if (val.color_mode !== undefined) this.colorMode = val.color_mode;
if (val.foreground_color !== undefined) this.fgColor = val.foreground_color;
if (val.gradient_from !== undefined) this.gradientFrom = val.gradient_from;
if (val.gradient_to !== undefined) this.gradientTo = val.gradient_to;
if (val.gradient_type !== undefined) this.gradientType = val.gradient_type;
if (val.bg_transparent !== undefined) this.bgTransparent = !!val.bg_transparent;
if (val.background_color !== undefined) this.bgColor = val.background_color;
if (val.eye_config_enabled !== undefined) this.eyeConfigEnabled = !!val.eye_config_enabled;
if (val.eye_square_style !== undefined) this.eyeSquareStyle = val.eye_square_style;
if (val.eye_dot_style !== undefined) this.eyeDotStyle = val.eye_dot_style;
if (val.eye_color !== undefined) this.eyeColor = val.eye_color;
if (val.logo_size !== undefined) this.logoSize = val.logo_size;
if (val.logo_margin !== undefined) this.logoMargin = val.logo_margin;
this.logoHideBackground = true;
if (val.logo_shape !== undefined) this.logoShape = val.logo_shape;
if (val.logo !== undefined) {
this.logo = this.getLogoUrl(val.logo);
}
},
updateProcessedLogo() {
return window.QrHelper.processLogo(this.logo, this.logoShape, this.logoMargin).then(res => {
this.processedLogo = res;
});
},
calculateUrl() {
const keyInput = document.querySelector('[name$="url_key"]') || document.querySelector('[id$="url_key"]');
const key = keyInput?.value || this.defaultUrlKey || 'preview';
const domainSelect = document.querySelector('[name$="custom_domain_id"]') || document.querySelector('[id$="custom_domain_id"]');
const domainId = domainSelect?.value || '';
let domain = this.defaultDomain;
if (domainId && this.domains[domainId]) {
domain = this.domains[domainId];
}
if (this.routePrefix && !domainId) {
return `${this.protocol}://${domain}/${this.routePrefix}/${key}`;
}
return `${this.protocol}://${domain}/${key}`;
},
updateUrl() {
this.url = this.calculateUrl();
},
buildOptions() {
return window.QrHelper.buildOptions(this, this.url, 200);
},
render() {
const el = this.$refs.sidebarQrCanvas;
if (el) {
this.qrInstance = window.QrHelper.render(el, this.buildOptions(), true);
}
},
applyQrDesign(opts, logo = null) {
if (!opts) return;
this.applyState(opts);
// Update logo if provided (path stored in qr_logo field)
if (logo !== null) {
this.logo = this.getLogoUrl(logo);
}
this.updateProcessedLogo().then(() => {
this.updateUrl();
this.render();
});
}
}));
/**
* Alpine component powering the live QR preview inside the designer modal.
*
* Architecture
* ────────────
* Filament / Livewire v4 stores each action's form data at a container path
* such as "mountedActions.1.data". The full container object is not exposed
* via $wire.$get, but every scalar leaf (e.g. "mountedActions.1.data.dot_style")
* resolves correctly. We discover the containerPath once from the nearest
* filamentSchemaComponent ancestor, then register one $wire.$watch per design
* field. These are purely client-side reactive subscriptions — zero network
* overhead. All change callbacks are funnelled through a 50 ms debounce so
* that a single user interaction producing N field changes yields one QR render.
*/
const QR_FIELDS = Object.freeze([
'dot_style', 'color_mode',
'foreground_color', 'gradient_from', 'gradient_to', 'gradient_type',
'bg_transparent', 'background_color',
'eye_config_enabled', 'eye_square_style', 'eye_dot_style', 'eye_color',
'logo_shape', 'logo_size', 'logo_margin', 'logo_hide_background', 'logo_file',
]);
Alpine.data('shortUrlQrDesignerPreview', (config) => ({
// ── Public config ─────────────────────────────────────────────────────
url: config.url,
// ── QR visual state ───────────────────────────────────────────────────
dotStyle: 'square', colorMode: 'solid',
fgColor: '#000000',
gradientFrom: '#4f46e5', gradientTo: '#06b6d4', gradientType: 'linear',
bgTransparent: false, bgColor: '#ffffff',
eyeConfigEnabled: false,
eyeSquareStyle: 'square', eyeDotStyle: 'square', eyeColor: '#000000',
logo: '', processedLogo: '',
logoSize: 0.55, logoMargin: 8, logoHideBackground: true, logoShape: 'square',
// ── Internal ──────────────────────────────────────────────────────────
_activePondFile: null,
qrInstance: null,
_containerPath: null,
_fieldWatchers: [], // $wire.$watch unsubscribe callbacks
_debounceTimer: null,
_onFileAdd: null,
_onFileRemove: null,
_accordionHandler: null,
// ── Lifecycle ─────────────────────────────────────────────────────────
init() {
this._containerPath = this._resolveContainerPath();
if (!this._containerPath) {
return; // no form context — nothing to preview
}
// Register one client-side $wire.$watch per design field.
// Livewire v4's $wire.$watch is reactive and carries zero network cost.
for (const field of QR_FIELDS) {
const unwatch = this.$wire.$watch(
`${this._containerPath}.${field}`,
() => this._scheduleRender()
);
this._fieldWatchers.push(unwatch);
}
// FilePond file-picker events (logo upload)
const modal = this.$el.closest('.fi-modal-window');
if (modal) {
this._onFileAdd = (e) => {
console.log('[QR Preview] FilePond:addfile event triggered', e.detail);
const fileItem = e.detail?.file;
if (!fileItem) return;
if (fileItem.origin === 3) {
console.log('[QR Preview] Ignoring initial server file in addfile event');
return;
}
const file = fileItem.file;
if (file) {
console.log('[QR Preview] Found local file in event:', file);
this._activePondFile = file;
if (this.logo && this.logo.startsWith('blob:')) {
URL.revokeObjectURL(this.logo);
}
this.logo = URL.createObjectURL(file);
this._processLogo().then(() => {
console.log('[QR Preview] Local logo processed, rendering...');
this.render();
});
} else {
console.log('[QR Preview] No file in event, falling back to fields read...');
const data = this._readFields();
if (data) this._resolveLogo(data.logo_file).then(() => this.render());
}
};
this._onFileRemove = () => {
console.log('[QR Preview] FilePond:removefile event triggered');
this._activePondFile = null;
if (this.logo && this.logo.startsWith('blob:')) {
URL.revokeObjectURL(this.logo);
}
this.logo = '';
this.processedLogo = '';
this.render();
};
modal.addEventListener('FilePond:addfile', this._onFileAdd);
modal.addEventListener('FilePond:removefile', this._onFileRemove);
}
// Accordion: opening one section collapses all sibling sections.
this._initAccordion(modal);
// Initial paint — deferred so Livewire has hydrated all field values.
this.$nextTick(() => this._refresh());
},
destroy() {
this._fieldWatchers.forEach(unwatch => unwatch?.());
this._fieldWatchers = [];
clearTimeout(this._debounceTimer);
const modal = this.$el.closest('.fi-modal-window');
if (modal) {
if (this._onFileAdd) modal.removeEventListener('FilePond:addfile', this._onFileAdd);
if (this._onFileRemove) modal.removeEventListener('FilePond:removefile', this._onFileRemove);
if (this._accordionHandler) modal.removeEventListener('click', this._accordionHandler);
}
},
// ── Private helpers ───────────────────────────────────────────────────
/**
* Extract the Filament schema containerPath from the nearest ancestor element
* carrying a filamentSchemaComponent x-data declaration.
*/
_resolveContainerPath() {
const ancestor = this.$el.closest('[x-data*="containerPath"]');
if (!ancestor) return null;
const m = (ancestor.getAttribute('x-data') ?? '').match(
/containerPath\s*:\s*['"]([^'"]+)['"]/
);
return m?.[1] ?? null;
},
/**
* Accordion behavior for the left-panel sections.
*
* Filament's <x-filament::section> already handles the
* `collapse-section.window` event natively (see support section blade).
* We detect which section was just OPENED and dispatch that event for
* every other sibling fi-section so they auto-collapse.
*/
_initAccordion(container) {
if (!container) return;
this._accordionHandler = (e) => {
const header = e.target.closest('.fi-section-header');
if (!header) return;
// The inner <section class="fi-section"> is where Alpine isCollapsed lives
const clickedInner = header.closest('.fi-section');
if (!clickedInner) return;
// Wait for Alpine to apply the fi-collapsed class
this.$nextTick(() => {
// Only collapse siblings when this section just OPENED (not collapsed)
if (clickedInner.classList.contains('fi-collapsed')) return;
// Walk up to outer wrapper: div.fi-sc-section — that has the ID
// that Filament uses as collapseId in x-on:collapse-section.window
container.querySelectorAll('.fi-sc-section').forEach(outerWrapper => {
const innerSection = outerWrapper.querySelector(':scope > .fi-section, :scope > x-filament\.section > .fi-section, .fi-section');
if (!innerSection || innerSection === clickedInner) return;
if (innerSection.classList.contains('fi-collapsed')) return; // already collapsed
// collapseId matches the outer wrapper's id attribute
const collapseId = outerWrapper.id;
if (collapseId) {
window.dispatchEvent(
new CustomEvent('collapse-section', { detail: { id: collapseId } })
);
}
});
});
};
container.addEventListener('click', this._accordionHandler);
},
/** Coalesce rapid multi-field changes into a single render. */
_scheduleRender() {
clearTimeout(this._debounceTimer);
this._debounceTimer = setTimeout(() => this._refresh(), 50);
},
_refresh() {
if (!this.$el?.isConnected) return;
const data = this._readFields();
if (data) this.applyActionData(data);
},
/**
* Read every design field via individual $wire.$get leaf-path calls.
*
* Livewire v4's $wire proxy resolves scalar leaf values correctly, but does
* not expose the container object itself through $get. Reading fields one by
* one is therefore the canonical approach for nested action form data.
*
* "dot_style" acts as the mount probe: undefined means the action form has
* not yet been fully hydrated by Livewire.
*/
_readFields() {
const path = this._containerPath;
if (!path) return null;
const g = (field) => {
try { return this.$wire.$get(`${path}.${field}`); } catch { return undefined; }
};
const dotStyle = g('dot_style');
if (dotStyle === undefined) return null; // form not ready yet
return {
dot_style: dotStyle,
color_mode: g('color_mode'),
foreground_color: g('foreground_color'),
gradient_from: g('gradient_from'),
gradient_to: g('gradient_to'),
gradient_type: g('gradient_type'),
bg_transparent: g('bg_transparent'),
background_color: g('background_color'),
eye_config_enabled: g('eye_config_enabled'),
eye_square_style: g('eye_square_style'),
eye_dot_style: g('eye_dot_style'),
eye_color: g('eye_color'),
logo_shape: g('logo_shape'),
logo_size: g('logo_size'),
logo_margin: g('logo_margin'),
logo_hide_background: g('logo_hide_background'),
logo_file: g('logo_file'),
};
},
// ── State application ─────────────────────────────────────────────────
applyActionData(data) {
this.dotStyle = data.dot_style ?? 'square';
this.colorMode = data.color_mode ?? 'solid';
this.fgColor = data.foreground_color ?? '#000000';
this.gradientFrom = data.gradient_from ?? '#4f46e5';
this.gradientTo = data.gradient_to ?? '#06b6d4';
this.gradientType = data.gradient_type ?? 'linear';
this.bgTransparent = !!data.bg_transparent;
this.bgColor = data.background_color ?? '#ffffff';
this.eyeConfigEnabled = !!data.eye_config_enabled;
this.eyeSquareStyle = data.eye_square_style ?? 'square';
this.eyeDotStyle = data.eye_dot_style ?? 'square';
this.eyeColor = data.eye_color ?? '#000000';
this.logoShape = data.logo_shape ?? 'square';
this.logoSize = parseFloat(data.logo_size) || 0.55;
this.logoMargin = parseFloat(data.logo_margin) || 8;
this.logoHideBackground = true;
this._resolveLogo(data.logo_file).then(() => this.render());
},
_resolveLogo(logoFile) {
console.log('[QR Preview] Resolving logo with:', logoFile);
if (this._activePondFile) {
console.log('[QR Preview] Using active FilePond file:', this._activePondFile);
if (!this.logo || !this.logo.startsWith('blob:')) {
this.logo = URL.createObjectURL(this._activePondFile);
}
return this._processLogo();
}
let logoPath = null;
if (typeof logoFile === 'string') {
logoPath = logoFile;
} else if (Array.isArray(logoFile)) {
logoPath = logoFile[0];
} else if (logoFile && typeof logoFile === 'object') {
const vals = Object.values(logoFile);
if (vals.length > 0) {
logoPath = vals[0];
}
}
if (logoPath && typeof logoPath === 'string') {
if (this.logo && this.logo.startsWith('blob:')) {
URL.revokeObjectURL(this.logo);
}
this.logo = /^(https?:|data:)/.test(logoPath)
? logoPath
: `/short-url/logo/${logoPath.split('/').pop()}`;
console.log('[QR Preview] Resolved logo URL from path:', this.logo);
return this._processLogo();
}
console.log('[QR Preview] No logo file to resolve.');
if (this.logo && this.logo.startsWith('blob:')) {
URL.revokeObjectURL(this.logo);
}
this.logo = '';
this.processedLogo = '';
return Promise.resolve();
},
_processLogo() {
console.log('[QR Preview] Processing logo image:', this.logo);
return window.QrHelper
.processLogo(this.logo, this.logoShape, this.logoMargin)
.then(result => {
this.processedLogo = result;
console.log('[QR Preview] Processed logo data URI length:', result ? result.length : 0);
});
},
// ── Public API ────────────────────────────────────────────────────────
buildOptions() {
return window.QrHelper.buildOptions(this, this.url, 280);
},
render() {
const canvas = this.$refs.qrCanvas;
if (!canvas) {
console.log('[QR Preview] Canvas ref not found!');
return;
}
console.log('[QR Preview] Rendering QR with options:', this.buildOptions());
window.QrHelper.loadScript().then(() => {
this.qrInstance = window.QrHelper.render(canvas, this.buildOptions(), true);
});
},
download(extension) {
const exportOpts = window.QrHelper.buildOptions(this, this.url, 2000);
// Render to a temporary container to apply our custom post-processing (like circular clip-path)
const tempDiv = document.createElement('div');
tempDiv.style.display = 'none';
document.body.appendChild(tempDiv);
window.QrHelper.loadScript().then(() => {
const qr = window.QrHelper.render(tempDiv, exportOpts, true);
if (!qr) {
if (tempDiv.parentNode) document.body.removeChild(tempDiv);
return;
}
qr.drawingPromise.then(() => {
const svg = tempDiv.querySelector('svg');
if (!svg) {
if (tempDiv.parentNode) document.body.removeChild(tempDiv);
return;
}
if (extension === 'svg') {
const svgString = new XMLSerializer().serializeToString(svg);
const svgBlob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
const svgUrl = URL.createObjectURL(svgBlob);
const a = document.createElement('a');
a.href = svgUrl;
a.download = 'qr-code.svg';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(svgUrl);
if (tempDiv.parentNode) document.body.removeChild(tempDiv);
} else {
// For PNG, draw the modified SVG to a canvas
const svgString = new XMLSerializer().serializeToString(svg);
const svgBlob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
const svgUrl = URL.createObjectURL(svgBlob);
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = 2000;
canvas.height = 2000;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
canvas.toBlob((blob) => {
const pngUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = pngUrl;
a.download = 'qr-code.png';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(pngUrl);
URL.revokeObjectURL(svgUrl);
if (tempDiv.parentNode) document.body.removeChild(tempDiv);
}, 'image/png');
};
img.src = svgUrl;
}
});
});
},
}));
});

294
resources/dist/meta-scraper.js vendored Normal file
View File

@@ -0,0 +1,294 @@
window.addEventListener('error', function (e) {
var errorData = {
message: e.message,
file: e.filename,
line: e.lineno,
col: e.colno,
stack: e.error ? e.error.stack : ''
};
fetch('/short-url/log-error', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
},
body: JSON.stringify(errorData)
}).catch(function () {});
});
window.fsuDispatchScraping = function (isScraping) {
window.dispatchEvent(new CustomEvent(isScraping ? 'fsu-scraping-start' : 'fsu-scraping-end'));
};
window.fsuResolveFormApi = function ($get, $set, $el) {
if (typeof $get === 'function' && typeof $set === 'function') {
return {
get: $get,
set: function (path, value) {
$set(path, value, false, true);
},
};
}
var host = $el || null;
if (host && typeof host.closest === 'function') {
var componentEl = host.closest('[x-data*="filamentSchemaComponent"]');
if (componentEl && typeof Alpine !== 'undefined') {
var data = Alpine.$data(componentEl);
if (data && typeof data.$get === 'function' && typeof data.$set === 'function') {
return {
get: data.$get.bind(data),
set: function (path, value) {
data.$set(path, value, false, true);
},
};
}
}
}
return null;
};
window.fsuIsPasswordProtected = function (api) {
if (!api || typeof api.get !== 'function') {
return false;
}
return !!(api.get('password_active_flag') || api.get('password'));
};
window.fsuHasManualOgImage = function (get) {
if (typeof get !== 'function') {
return false;
}
var image = get('og_image');
if (!image) {
return false;
}
if (typeof image === 'string') {
return image.trim() !== '';
}
if (Array.isArray(image)) {
return image.length > 0;
}
if (typeof image === 'object') {
return Object.keys(image).length > 0;
}
return false;
};
window.fsuLockScrape = function (inputEl) {
if (inputEl) {
inputEl.dataset.fsuScrapeLocked = 'true';
return;
}
document.querySelectorAll('[data-fsu-destination-url]').forEach(function (el) {
el.dataset.fsuScrapeLocked = 'true';
});
};
window.fsuUnlockScrape = function (inputEl) {
if (inputEl) {
delete inputEl.dataset.fsuScrapeLocked;
return;
}
document.querySelectorAll('[data-fsu-destination-url]').forEach(function (el) {
delete el.dataset.fsuScrapeLocked;
});
};
window.fsuIsScrapeLocked = function (inputEl, get) {
if (inputEl && inputEl.dataset.fsuScrapeLocked === 'true') {
return true;
}
return window.fsuHasManualOgImage(get);
};
window.fsuGetScrapedUrl = function (inputEl) {
return inputEl && inputEl.dataset.scrapedUrl ? inputEl.dataset.scrapedUrl : null;
};
window.fsuMarkDestinationScraped = function (inputEl, url) {
if (inputEl && url) {
inputEl.dataset.scrapedUrl = url;
}
};
window.fsuClearDestinationScrape = function (inputEl) {
if (inputEl) {
delete inputEl.dataset.scrapedUrl;
}
};
window.fsuShouldSkipScrape = function (inputEl, url, get) {
if (window.fsuIsScrapeLocked(inputEl, get)) {
return true;
}
return window.fsuGetScrapedUrl(inputEl) === url;
};
window.fsuStopScraping = function (api) {
window.fsuDispatchScraping(false);
window.dispatchEvent(new CustomEvent('fsu-og-image-updated'));
if (api && typeof api.set === 'function') {
api.set('is_scraping', false);
}
};
window.fsuOnManualOgImage = function ($get, $set) {
var api = window.fsuResolveFormApi($get, $set);
if (api) {
api.set('og_image_scraped', null);
}
window.fsuStopScraping(api);
window.fsuLockScrape();
};
window.fsuClearOgMetadata = function (api) {
if (!api || typeof api.set !== 'function') {
return;
}
api.set('og_title', null);
api.set('og_description', null);
api.set('og_image_scraped', null);
};
window.fsuRetryScrapeAfterImageRemoved = function ($get, $set) {
var api = window.fsuResolveFormApi($get, $set);
var dest = api ? api.get('destination_url') : null;
if (!dest || window.fsuHasManualOgImage(api ? api.get : $get)) {
return;
}
window.fsuUnlockScrape();
window.fsuClearOgMetadata(api);
document.querySelectorAll('[data-fsu-destination-url]').forEach(function (el) {
window.fsuClearDestinationScrape(el);
window.fsuScrape(dest, $get, $set, el);
});
};
window.fsuScrape = function (val, $get, $set, $el) {
if (!val) {
return;
}
try {
var url = new URL(val);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return;
}
} catch (_) {
return;
}
var api = window.fsuResolveFormApi($get, $set, $el);
if (!api) {
return;
}
if (window.fsuIsPasswordProtected(api)) {
window.fsuStopScraping(api);
return;
}
if (window.fsuShouldSkipScrape($el, val, api.get)) {
window.fsuStopScraping(api);
return;
}
window.fsuDispatchScraping(true);
api.set('is_scraping', true);
fetch('/short-url/scrape-meta?url=' + encodeURIComponent(val))
.then(function (r) {
return r.ok ? r.json() : null;
})
.then(function (data) {
if (window.fsuIsPasswordProtected(api)) {
window.fsuStopScraping(api);
return;
}
if (window.fsuIsScrapeLocked($el, api.get)) {
window.fsuStopScraping(api);
return;
}
if (data && (data.title || data.description || data.image)) {
if (data.title && !api.get('og_title')) {
api.set('og_title', data.title);
}
if (data.description && !api.get('og_description')) {
api.set('og_description', data.description);
}
if (data.image && !window.fsuHasManualOgImage(api.get)) {
api.set('og_image_scraped', data.image);
window.fsuLockScrape($el);
} else {
window.fsuStopScraping(api);
}
window.fsuMarkDestinationScraped($el, val);
} else {
window.fsuStopScraping(api);
}
})
.catch(function () {
window.fsuStopScraping(api);
});
};
window.fsuInitScrape = function ($get, $el) {
var api = window.fsuResolveFormApi($get, null, $el);
if (!$el || !api) {
return;
}
var dest = api.get('destination_url');
if (!dest) {
return;
}
if (window.fsuHasManualOgImage(api.get)) {
window.fsuLockScrape($el);
window.fsuMarkDestinationScraped($el, dest);
return;
}
if (
!window.fsuGetScrapedUrl($el)
&& (api.get('og_title') || api.get('og_description') || api.get('og_image_scraped'))
) {
window.fsuMarkDestinationScraped($el, dest);
}
};

2
resources/dist/qr-code-styling.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,253 @@
<?php
return [
'AF' => 'Afghanistan',
'AX' => 'Åland Islands',
'AL' => 'Albania',
'DZ' => 'Algeria',
'AS' => 'American Samoa',
'AD' => 'Andorra',
'AO' => 'Angola',
'AI' => 'Anguilla',
'AQ' => 'Antarctica',
'AG' => 'Antigua and Barbuda',
'AR' => 'Argentina',
'AM' => 'Armenia',
'AW' => 'Aruba',
'AU' => 'Australia',
'AT' => 'Austria',
'AZ' => 'Azerbaijan',
'BS' => 'Bahamas',
'BH' => 'Bahrain',
'BD' => 'Bangladesh',
'BB' => 'Barbados',
'BY' => 'Belarus',
'BE' => 'Belgium',
'BZ' => 'Belize',
'BJ' => 'Benin',
'BM' => 'Bermuda',
'BT' => 'Bhutan',
'BO' => 'Bolivia',
'BQ' => 'Bonaire, Sint Eustatius and Saba',
'BA' => 'Bosnia and Herzegovina',
'BW' => 'Botswana',
'BV' => 'Bouvet Island',
'BR' => 'Brazil',
'IO' => 'British Indian Ocean Territory',
'BN' => 'Brunei Darussalam',
'BG' => 'Bulgaria',
'BF' => 'Burkina Faso',
'BI' => 'Burundi',
'CV' => 'Cabo Verde',
'KH' => 'Cambodia',
'CM' => 'Cameroon',
'CA' => 'Canada',
'KY' => 'Cayman Islands',
'CF' => 'Central African Republic',
'TD' => 'Chad',
'CL' => 'Chile',
'CN' => 'China',
'CX' => 'Christmas Island',
'CC' => 'Cocos (Keeling) Islands',
'CO' => 'Colombia',
'KM' => 'Comoros',
'CD' => 'Congo (Democratic Republic)',
'CG' => 'Congo (Republic)',
'CK' => 'Cook Islands',
'CR' => 'Costa Rica',
'CI' => 'Côte d\'Ivoire',
'HR' => 'Croatia',
'CU' => 'Cuba',
'CW' => 'Curaçao',
'CY' => 'Cyprus',
'CZ' => 'Czech Republic',
'DK' => 'Denmark',
'DJ' => 'Djibouti',
'DM' => 'Dominica',
'DO' => 'Dominican Republic',
'EC' => 'Ecuador',
'EG' => 'Egypt',
'SV' => 'El Salvador',
'GQ' => 'Equatorial Guinea',
'ER' => 'Eritrea',
'EE' => 'Estonia',
'SZ' => 'Eswatini',
'ET' => 'Ethiopia',
'FK' => 'Falkland Islands',
'FO' => 'Faroe Islands',
'FJ' => 'Fiji',
'FI' => 'Finland',
'FR' => 'France',
'GF' => 'French Guiana',
'PF' => 'French Polynesia',
'TF' => 'French Southern Territories',
'GA' => 'Gabon',
'GM' => 'Gambia',
'GE' => 'Georgia',
'DE' => 'Germany',
'GH' => 'Ghana',
'GI' => 'Gibraltar',
'GR' => 'Greece',
'GL' => 'Greenland',
'GD' => 'Grenada',
'GP' => 'Guadeloupe',
'GU' => 'Guam',
'GT' => 'Guatemala',
'GG' => 'Guernsey',
'GN' => 'Guinea',
'GW' => 'Guinea-Bissau',
'GY' => 'Guyana',
'HT' => 'Haiti',
'HM' => 'Heard Island and McDonald Islands',
'VA' => 'Holy See',
'HN' => 'Honduras',
'HK' => 'Hong Kong',
'HU' => 'Hungary',
'IS' => 'Iceland',
'IN' => 'India',
'ID' => 'Indonesia',
'IR' => 'Iran',
'IQ' => 'Iraq',
'IE' => 'Ireland',
'IM' => 'Isle of Man',
'IL' => 'Israel',
'IT' => 'Italy',
'JM' => 'Jamaica',
'JP' => 'Japan',
'JE' => 'Jersey',
'JO' => 'Jordan',
'KZ' => 'Kazakhstan',
'KE' => 'Kenya',
'KI' => 'Kiribati',
'KP' => 'North Korea',
'KR' => 'South Korea',
'KW' => 'Kuwait',
'KG' => 'Kyrgyzstan',
'LA' => 'Lao People\'s Democratic Republic',
'LV' => 'Latvia',
'LB' => 'Lebanon',
'LS' => 'Lesotho',
'LR' => 'Liberia',
'LY' => 'Libya',
'LI' => 'Liechtenstein',
'LT' => 'Lithuania',
'LU' => 'Luxembourg',
'MO' => 'Macao',
'MG' => 'Madagascar',
'MW' => 'Malawi',
'MY' => 'Malaysia',
'MV' => 'Maldives',
'ML' => 'Mali',
'MT' => 'Malta',
'MH' => 'Marshall Islands',
'MQ' => 'Martinique',
'MR' => 'Mauritania',
'MU' => 'Mauritius',
'YT' => 'Mayotte',
'MX' => 'Mexico',
'FM' => 'Micronesia',
'MD' => 'Moldova',
'MC' => 'Monaco',
'MN' => 'Mongolia',
'ME' => 'Montenegro',
'MS' => 'Montserrat',
'MA' => 'Morocco',
'MZ' => 'Mozambique',
'MM' => 'Myanmar',
'NA' => 'Namibia',
'NR' => 'Nauru',
'NP' => 'Nepal',
'NL' => 'Netherlands',
'NC' => 'New Caledonia',
'NZ' => 'New Zealand',
'NI' => 'Nicaragua',
'NE' => 'Niger',
'NG' => 'Nigeria',
'NU' => 'Niue',
'NF' => 'Norfolk Island',
'MK' => 'North Macedonia',
'MP' => 'Northern Mariana Islands',
'NO' => 'Norway',
'OM' => 'Oman',
'PK' => 'Pakistan',
'PW' => 'Palau',
'PS' => 'Palestine',
'PA' => 'Panama',
'PG' => 'Papua New Guinea',
'PY' => 'Paraguay',
'PE' => 'Peru',
'PH' => 'Philippines',
'PN' => 'Pitcairn',
'PL' => 'Poland',
'PT' => 'Portugal',
'PR' => 'Puerto Rico',
'QA' => 'Qatar',
'RE' => 'Réunion',
'RO' => 'Romania',
'RU' => 'Russian Federation',
'RW' => 'Rwanda',
'BL' => 'Saint Barthélemy',
'SH' => 'Saint Helena, Ascension and Tristan da Cunha',
'KN' => 'Saint Kitts and Nevis',
'LC' => 'Saint Lucia',
'MF' => 'Saint Martin (French part)',
'PM' => 'Saint Pierre and Miquelon',
'VC' => 'Saint Vincent and the Grenadines',
'WS' => 'Samoa',
'SM' => 'San Marino',
'ST' => 'Sao Tome and Principe',
'SA' => 'Saudi Arabia',
'SN' => 'Senegal',
'RS' => 'Serbia',
'SC' => 'Seychelles',
'SL' => 'Sierra Leone',
'SG' => 'Singapore',
'SX' => 'Sint Maarten (Dutch part)',
'SK' => 'Slovakia',
'SI' => 'Slovenia',
'SB' => 'Solomon Islands',
'SO' => 'Somalia',
'ZA' => 'South Africa',
'GS' => 'South Georgia and the South Sandwich Islands',
'SS' => 'South Sudan',
'ES' => 'Spain',
'LK' => 'Sri Lanka',
'SD' => 'Sudan',
'SR' => 'Suriname',
'SJ' => 'Svalbard and Jan Mayen',
'SE' => 'Sweden',
'CH' => 'Switzerland',
'SY' => 'Syrian Arab Republic',
'TW' => 'Taiwan',
'TJ' => 'Tajikistan',
'TZ' => 'Tanzania',
'TH' => 'Thailand',
'TL' => 'Timor-Leste',
'TG' => 'Togo',
'TK' => 'Tokelau',
'TO' => 'Tonga',
'TT' => 'Trinidad and Tobago',
'TN' => 'Tunisia',
'TR' => 'Turkey',
'TM' => 'Turkmenistan',
'TC' => 'Turks and Caicos Islands',
'TV' => 'Tuvalu',
'UG' => 'Uganda',
'UA' => 'Ukraine',
'AE' => 'United Arab Emirates',
'GB' => 'United Kingdom',
'UM' => 'United States Minor Outlying Islands',
'US' => 'United States',
'UY' => 'Uruguay',
'UZ' => 'Uzbekistan',
'VU' => 'Vanuatu',
'VE' => 'Venezuela',
'VN' => 'Viet Nam',
'VG' => 'Virgin Islands (British)',
'VI' => 'Virgin Islands (U.S.)',
'WF' => 'Wallis and Futuna',
'EH' => 'Western Sahara',
'YE' => 'Yemen',
'ZM' => 'Zambia',
'ZW' => 'Zimbabwe',
];

View File

@@ -1,16 +1,35 @@
<?php
return [
'navigation_label' => 'Short Links',
'navigation_group' => 'Tools',
'navigation_label' => 'Links',
'navigation_group' => 'URL Shortener',
'resource_title' => 'Short URL',
'empty_state_heading' => 'No links yet',
'empty_state_description' => 'Start creating short links for your marketing campaigns, referral programs, and more.',
'empty_state_action' => 'Create link',
'empty_state_pixel_heading' => 'No pixels yet',
'empty_state_pixel_description' => 'Add tracking pixels from Meta, Google, TikTok and more to retarget visitors who click your links.',
'empty_state_pixel_action' => 'Create pixel',
// Form Tabs
'tab_link' => 'Link Details',
'tab_tracking' => 'Tracking Settings',
'tab_tracking' => 'Tracking',
'tab_qr_design' => 'QR Code Design',
'tab_app_linking' => 'App Linking',
// App Linking
'form_section_app_linking' => 'App Linking / Deep Links',
'auto_open_app_mobile' => 'Auto open app on mobile',
'auto_open_app_mobile_helper' => 'Enable this if you want your link to automatically open as an app when accessed on mobile.',
'supported_operating_systems' => 'Supported Operating Systems',
'supported_apps' => 'Supported Apps',
'supported_apps_helper' => 'If you do not find your app here, you can use the Targeting feature to redirect the user to your app with your custom deep link.',
'matched' => 'Matched',
// Link Form Fields
'destination_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',
@@ -24,6 +43,71 @@ return [
'forward_query_params' => 'Forward Query Parameters',
'forward_query_params_helper' => 'Append incoming query parameters (like UTM tags) to the destination URL.',
'expires_at' => 'Expires At',
'activated_at' => 'Active From',
'max_visits' => 'Max Visits Limit',
'max_visits_helper' => 'Optional limit of total visits allowed before this link is deactivated.',
'max_visits_no_limit' => 'No limit',
'max_visits_suffix' => 'visits',
'number_stepper_decrease' => 'Decrease value',
'number_stepper_increase' => 'Increase value',
'expiration_redirect_url' => 'Expiration Redirect URL',
'expiration_redirect_url_helper' => 'Fallback URL to redirect to when the link is expired or inactive (leaves blank for 410 Gone error page).',
'form_section_validity' => 'Validity & Limits',
'use_date_validity' => 'Set Date Range Limits',
'use_date_validity_helper' => 'Schedule when the link becomes active and when it should stop working.',
'validity_tab_intro' => 'Control when your link is available and how many times it can be clicked. Leave everything off for a link that stays open indefinitely.',
'validity_schedule_card_title' => 'Schedule availability',
'validity_schedule_card_subtitle' => 'Optional start and end dates for campaigns, launches, or limited-time offers.',
'validity_schedule_empty_title' => 'Always available',
'validity_schedule_empty_desc' => 'Turn on the switch above to set an activation date, expiration date, or both.',
'validity_schedule_timeline_label' => 'Active window',
'validity_activated_at_helper' => 'Leave empty to activate immediately when saved.',
'validity_expires_at_helper' => 'Optional. After this moment the link stops redirecting.',
'validity_single_use_toggle' => 'Enable single-use mode',
'validity_single_use_active_note' => 'Single-use is on — the link deactivates after the first successful visit. Max visits limit is ignored.',
'validity_max_visits_locked' => 'Unavailable while single-use is enabled',
'tracking_visit_card_title' => 'Visit analytics',
'tracking_visit_card_subtitle' => 'Collect click statistics for this link — disable for privacy-first or redirect-only links.',
'tracking_visit_empty_title' => 'Tracking disabled',
'tracking_visit_empty_desc' => 'Turn on the switch above to record visits and configure which data points are stored.',
'tracking_fields_identity_title' => 'Identity & source',
'tracking_fields_device_title' => 'Device & browser',
'track_ip_desc' => 'IP address for geo insights and abuse signals.',
'track_referer_desc' => 'Referring page URL when the visitor arrives from another site.',
'track_browser_desc' => 'Browser family such as Chrome, Safari, or Firefox.',
'track_browser_version_desc' => 'Exact browser version string from the user agent.',
'track_os_desc' => 'Operating system name such as iOS, Windows, or Android.',
'track_os_version_desc' => 'OS version reported by the visitor device.',
'track_device_type_desc' => 'Device category — desktop, mobile, tablet, or bot.',
'track_browser_language_desc' => 'Preferred language from the Accept-Language header.',
'tracking_utm_card_title' => 'Campaign parameters',
'tracking_utm_card_subtitle' => 'Append UTM tags to the destination URL. Changes sync instantly with the link target.',
'tracking_ga_card_title' => 'Google Analytics 4',
'tracking_ga_card_subtitle' => 'Optional Measurement ID for server-side redirect events via GA4 Measurement Protocol.',
'link_destination_card_title' => 'Destination',
'link_destination_card_subtitle' => 'Send visitors to one URL or split traffic across A/B test variants.',
'link_short_url_card_title' => 'Short link',
'link_short_url_card_subtitle' => 'Choose a domain and customize the back-half of your short URL.',
'link_behavior_card_title' => 'Behavior',
'link_behavior_card_subtitle' => 'Control whether the link is active and how incoming query parameters are handled.',
'link_status_desc' => 'When disabled, visitors see an error page instead of being redirected.',
'link_tags_card_title' => 'Tags',
'link_tags_card_subtitle' => 'Add up to five labels to organize, filter, and report on links.',
'link_notes_card_title' => 'Internal notes',
'link_notes_card_subtitle' => 'Private admin memo — never shown to visitors.',
'link_notes_placeholder' => 'Add a reminder for your team…',
'targeting_rules_card_title' => 'Conditional redirects',
'targeting_rules_card_subtitle' => 'Send visitors to different URLs based on device, country, language, or platform.',
'targeting_rules_empty_title' => 'No targeting rules yet',
'targeting_rules_empty_desc' => 'Add a rule below to override the default destination when specific conditions match.',
'add_targeting_rule' => 'Add rule',
'targeting_rule_conditions_title' => 'When to apply',
'targeting_rule_destination_title' => 'Redirect to',
'filter_duplicate_error' => 'Each filter type (Device, Platform, Country, Language) can only be added once.',
'app_linking_card_title' => 'App linking',
'app_linking_card_subtitle' => 'Open the native mobile app instead of the browser when a matching app is detected.',
'app_linking_empty_title' => 'App auto-open disabled',
'app_linking_empty_desc' => 'Turn on the switch above to preview and enable deep-link behavior on mobile devices.',
'notes' => 'Internal Notes',
'ga_tracking_id' => 'Google Analytics 4 Measurement ID',
'ga_tracking_id_helper' => 'Optional G-XXXXXXXXXX ID to track redirects server-side.',
@@ -51,6 +135,7 @@ return [
'track_os_version' => 'Track OS Version',
'track_device_type' => 'Track Device Type (desktop/mobile/tablet)',
'track_referer' => 'Track Referer URL',
'track_browser_language' => 'Track Browser Language',
// QR Design Fields
'qr_size' => 'QR Code Size (px)',
@@ -85,14 +170,35 @@ return [
'qr_label_eye_dot_style' => 'Eye Dot Style',
'qr_label_eye_color' => 'Eye Color',
'qr_label_preview' => 'Preview',
'qr_option_none' => 'None',
'qr_option_square' => 'Square',
'qr_option_dots' => 'Dots',
'qr_option_dot' => 'Dot',
'qr_option_rounded' => 'Rounded',
'qr_option_classy' => 'Classy',
'qr_option_classy_rounded' => 'Classy Rounded',
'qr_option_extra_rounded' => 'Extra Rounded',
'qr_option_dot' => 'Dot',
'qr_chart_visits_label' => 'Visits',
'qr_label_dots_background' => 'Dots & Background',
'qr_label_custom_eye_config' => 'Custom Eye Config',
'qr_label_logo_overlay' => 'Logo & Icon Overlay',
'qr_label_drag_drop_upload' => 'Drag & drop or click to upload logo',
'qr_label_upload_supports' => 'Supports PNG, JPG, SVG up to 10MB',
'qr_label_drag_drop_replace' => 'Drag & drop or click to replace',
'qr_label_uploading_logo' => 'Uploading logo...',
'qr_logo_upload_parse_error' => 'Error parsing server response!',
'qr_logo_upload_error' => 'Error during logo file upload!',
'qr_logo_upload_connection_error' => 'Connection error during logo file upload!',
'qr_label_remove_logo' => 'Remove logo',
'qr_label_logo_shape' => 'Logo Shape',
'qr_label_logo_size' => 'Logo Size',
'qr_label_logo_margin' => 'Logo Margin',
'qr_label_clear_dots' => 'Clear Dots Behind Logo',
'qr_label_live_preview' => 'Live Preview',
'qr_option_circle' => 'Circle',
'qr_label_png' => 'PNG',
'qr_label_svg' => 'SVG',
'qr_save_design' => 'Save Design',
// Table Columns
'col_short_url' => 'Short URL',
@@ -108,11 +214,23 @@ return [
'action_qr' => 'QR Code',
'action_edit' => 'Edit URL',
'action_delete' => 'Delete URL',
'url_key_change_confirmation_heading' => 'Replace link?',
'url_key_change_confirmation' => "You've modified the back-half of this link's URL. Saving these changes will update the key. Any references to the original link will break.",
'action_share' => 'Share Link',
'action_move' => 'Move',
'share_title' => 'Share Link',
'share_description' => 'Share this short link via:',
'share_copy' => 'Copy',
'share_copied' => 'Short link copied to clipboard!',
'qr_modal_helper' => 'Scan, copy, or download your custom QR code.',
'qr_download_svg' => 'Download SVG',
'qr_download_png' => 'Download PNG',
'success_modal_title' => 'Your link & QR code are ready!',
'success_modal_subtitle' => 'Time to get some clicks 🎉',
'success_modal_helper' => 'Copy and share manually or choose a platform.',
'open_link' => 'Open link',
'close_button' => 'Close',
'dont_show_again' => "Don't show sharing options after creating a link",
// Stats Page
'stats_title' => 'Statistics',
@@ -121,6 +239,7 @@ return [
'stats_card_total' => 'Total Visits',
'stats_card_unique' => 'Unique Visitors',
'stats_card_today' => 'Today',
'stats_card_today_clicks' => 'Clicks Today',
'stats_card_week' => 'This Week',
'stats_card_month' => 'This Month',
'stats_chart_title' => 'Visits — Last 30 Days',
@@ -138,6 +257,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',
@@ -160,14 +281,53 @@ return [
'settings_section_ga4' => 'GA4 Measurement Protocol',
'settings_ga4_description' => 'Configure server-side Google Analytics 4 event tracking via the Measurement Protocol. Events are sent in the background without blocking the redirect.',
'settings_site_name' => 'Site Name Override',
'settings_site_name_helper' => 'Custom brand/site name displayed on redirect prompt screens. If empty, falls back to config("app.name").',
'settings_route_prefix' => 'Route Prefix',
'settings_route_prefix_helper' => 'URL segment before the short key, e.g. "/s/abc123". Change requires a config:clear.',
'settings_route_prefix_helper' => 'URL segment before the short key, e.g. "/s/abc123". Leave empty to serve links directly from the root domain (e.g. "domain.com/abc123"). Change requires a config:clear.',
'settings_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.',
'settings_cache_ttl' => 'Redirect Cache TTL',
'settings_cache_ttl_helper' => 'Seconds to cache resolved short URL records. Set to 0 to disable (not recommended in production).',
'settings_queue_connection' => 'Queue Connection',
'settings_queue_connection_helper' => 'Laravel queue connection used for async visit tracking. Use "sync" for synchronous (slower redirects), or "redis"/"sqs" for production.',
'settings_queue_connection_helper' => 'Laravel queue connection for async visit tracking. Each driver has different requirements — see the info box below when you change this value.',
'settings_redis_test' => 'Test Redis connection',
'settings_redis_test_ok' => 'Redis connection OK',
'settings_redis_test_fail' => 'Redis connection failed',
'settings_queue_worker_test' => 'Test queue worker',
'settings_queue_worker_test_ok' => 'Queue worker OK',
'settings_queue_worker_test_fail' => 'Queue worker not responding',
'settings_section_redis' => 'Redis Connection',
'settings_section_redis_description' => 'These values override database.redis and queue.connections.redis at runtime when Queue Connection is redis. Save settings after changing them.',
'settings_redis_host' => 'Redis Host',
'settings_redis_host_helper' => 'Hostname or IP of the Redis server used for queues, counters, stats, and live feed.',
'settings_redis_port' => 'Redis Port',
'settings_redis_port_helper' => 'Default: 6379.',
'settings_redis_password' => 'Redis Password',
'settings_redis_password_helper' => 'Leave empty if Redis has no password. Stored securely in settings.',
'settings_redis_database' => 'Redis Database',
'settings_redis_database_helper' => 'Logical Redis DB index (015). Default: 0.',
'settings_redis_key_prefix' => 'Key Prefix',
'settings_redis_key_prefix_helper' => 'Optional prefix for all plugin Redis keys. Leave empty to use config default.',
'settings_queue_worker_command' => '<strong>Queue Worker Required:</strong> Run a worker on the same connection and queue name as above (independent of <code>QUEUE_CONNECTION</code> in <code>.env</code>): <code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">php artisan queue:work :connection --queue=:queue</code>',
'settings_queue_mode_desc_redis' => '<strong>redis</strong> — production mode: async jobs, dedicated Redis counters/stats/live feed (phpredis or Predis), auto counter buffering. Configure Redis below (overrides .env at runtime). Use the test buttons before saving.',
'settings_queue_mode_desc_database' => '<strong>database</strong> — async visit jobs stored in the <code>jobs</code> table. No dedicated Redis counters unless you enable buffering manually.',
'settings_queue_mode_desc_sqs' => '<strong>sqs</strong> — async jobs via AWS SQS. Requires valid AWS credentials in <code>config/queue.php</code>.',
'settings_queue_mode_desc_beanstalkd' => '<strong>beanstalkd</strong> — async jobs via Beanstalkd. Requires a running beanstalkd server.',
'settings_queue_mode_desc_deferred' => '<strong>deferred</strong> — jobs run after the HTTP response in the same PHP process. No separate worker, but adds post-response latency under load.',
'settings_queue_mode_desc_background' => '<strong>background</strong> — Laravel background driver: jobs run after the response without a queue worker (similar to deferred).',
'settings_queue_mode_desc_failover' => '<strong>failover</strong> — uses your Laravel failover chain in <code>config/queue.php</code>. Ensure at least one backend in the chain is reachable.',
'settings_queue_mode_desc_default' => '<strong>:connection</strong> — async visit tracking via this Laravel queue connection. Ensure it is configured in <code>config/queue.php</code>.',
'settings_queue_mode_info_sync' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="text-sm text-neutral-800 dark:text-neutral-300"><strong>sync</strong> — visits are recorded during the redirect (no queue worker). Best for local dev and low traffic. Optional manual counter buffering via the toggle below.</div></div>',
'settings_geoip_enabled' => 'Enable Geo-IP Detection',
'settings_geoip_enabled_helper' => 'Detect and record the visitor\'s country on each visit.',
@@ -177,11 +337,12 @@ return [
'settings_geoip_driver_maxmind' => 'MaxMind Local DB (offline, zero latency)',
'settings_geoip_driver_ipapi' => 'ip-api.com (free API, 45 req/min limit)',
'settings_geoip_cache_ttl' => 'Geo-IP Result Cache TTL',
'settings_geoip_cache_ttl_helper' => 'Seconds to cache the resolved country per hashed IP. 86400 = 24 hours.',
'settings_geoip_cache_ttl_helper' => 'Time (in seconds) to cache the resolved location per visitor IP. Prevents querying APIs/databases repeatedly on subsequent clicks from the same user. Default: 86400 (24 hours). Max: 31536000 (1 year).',
'settings_geoip_timeout' => 'API Timeout',
'settings_geoip_timeout_helper' => 'Maximum seconds to wait for an external Geo-IP API response before giving up.',
'settings_maxmind_path' => 'MaxMind Database Path',
'settings_maxmind_path_helper' => 'Absolute path to your GeoLite2-Country.mmdb or GeoIP2-Country.mmdb file. Required when driver is set to MaxMind.',
'settings_maxmind_info_callout' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm.75-11.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm0 2.5a.75.75 0 0 1 .75.75v4a.75.75 0 0 1-1.5 0v-4a.75.75 0 0 1 .75-.75Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>How to configure MaxMind (Offline Geo-IP):</strong><br>1. Register for a free account at <a href="https://www.maxmind.com" target="_blank" class="underline text-primary-600 dark:text-primary-400">maxmind.com</a>.<br>2. Download the free <strong>GeoLite2 Country</strong> or <strong>GeoLite2 City</strong> database in binary format (file extension <code class="px-1 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">.mmdb</code>).<br>3. Upload the file to your server, e.g. under the path <code class="px-1 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">storage/geoip/GeoLite2-Country.mmdb</code>.<br>4. Paste the absolute path below and click the <strong>Verify file</strong> button on the right side of the field.</span></div></div>',
'settings_maxmind_verify' => 'Verify file',
'settings_maxmind_verify_ok' => '✅ File found & readable',
'settings_maxmind_verify_fail' => '❌ File not found or not readable',
@@ -195,19 +356,28 @@ return [
'settings_ga4_verify_ok' => '✅ API Secret is valid — GA4 connection successful',
'settings_ga4_verify_fail' => '❌ Invalid API Secret — GA4 rejected the request',
'settings_ga4_verify_empty' => 'Please enter an API Secret first.',
'settings_ga4_verify_measurement_id' => 'Measurement ID for connection test',
'settings_ga4_verify_measurement_id_helper' => 'Use the exact G-XXXXXXXXXX from the same GA4 data stream as your API secret. Required unless you use Firebase App ID.',
'settings_ga4_verify_measurement_required' => 'Enter a Measurement ID (G-XXXXXXXXXX) or Firebase App ID to test the connection.',
'settings_ga4_verify_error' => '⚠️ Connection error — could not reach GA4',
'settings_section_buffering' => 'Visit Counters Buffering',
'settings_buffering_enabled' => 'Buffer Visit Counts in Cache',
'settings_buffering_helper' => 'When enabled, visit count increments are temporarily buffered in the application cache and must be flushed periodically to the database via "php artisan short-url:sync-counters". This prevents row-locking issues and performance degradation under high-traffic spikes.',
'settings_buffering_redis_auto' => 'Automatically enabled with Redis queue. Visit counters, today stats, and live feed use the dedicated queue Redis connection (phpredis or Predis) — independent of CACHE_STORE. Schedule short-url:sync-counters every minute.',
'settings_buffering_worker_info' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm.75-11.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm0 2.5a.75.75 0 0 1 .75.75v4a.75.75 0 0 1-1.5 0v-4a.75.75 0 0 1 .75-.75Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full [&_kbd]:bg-background-light dark:[&_kbd]:bg-background-dark [_code]:!text-current [_kbd]:!text-current [_a]:!text-current [_a]:border-current [_strong]:!text-current text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Required Cron Task (Scheduler):</strong> You have enabled click counter buffering. You must add the following task to your server\'s crontab:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1</code><br>Command run automatically in the background by the scheduler:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">php artisan short-url:sync-counters</code></span></div></div>',
// CDN Trust Settings
'settings_trust_cdn_headers' => 'Trust CDN & Proxy Headers',
'settings_trust_cdn_headers_helper' => 'Enable this if your app sits behind a CDN (like Cloudflare, AWS CloudFront) or a reverse proxy. This allows extracting the real client IP and country code from CDN headers. Warning: only enable this if you are actually behind a proxy, otherwise client IP addresses can be spoofed!',
'settings_trust_cdn_headers_info_callout' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-amber-200 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/20" data-callout-type="warning"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-amber-800 dark:text-amber-300" aria-label="Warning"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" /></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full text-amber-900 dark:text-amber-200" data-component-part="callout-content"><span><strong>Warning (IP Address Security):</strong> You have enabled trust for proxy headers. Enable this option <u>only</u> if your website is behind:<br>• <strong>Cloudflare</strong> (reads from CF-Connecting-IP)<br>• <strong>AWS CloudFront</strong> or another CDN system<br>• <strong>Nginx/Apache reverse proxy</strong> forwarding X-Forwarded-For.<br><br><strong>Turn this option OFF</strong> if your server connects directly to users. If left ON without a proxy, malicious users can easily spoof their IP address by sending a custom header.</span></div></div>',
'settings_geoip_headers_warning' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-amber-200 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/20" data-callout-type="warning"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-amber-800 dark:text-amber-300" aria-label="Warning"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" /></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full text-amber-900 dark:text-amber-200" data-component-part="callout-content"><span><strong>Proxy Header Trust Disabled:</strong> You selected <em>CDN Headers</em> as the Geo-IP driver, but the option <strong>"Trust CDN & Proxy Headers"</strong> in the <em>General</em> tab is disabled. Location detection will not function until you enable it.</span></div></div>',
'settings_geoip_headers_info' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-blue-200 bg-blue-50 dark:border-blue-700 dark:bg-blue-950/20" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-blue-800 dark:text-blue-300" aria-label="Info"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-7-4a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM9 9a.75.75 0 0 0 0 1.5h.253a.25.25 0 0 1 .244.304l-.459 2.066A1.75 1.75 0 0 0 10.747 15H11a.75.75 0 0 0 0-1.5h-.253a.25.25 0 0 1-.244-.304l.459-2.066A1.75 1.75 0 0 0 9.253 9H9Z" clip-rule="evenodd" /></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full text-blue-900 dark:text-blue-200" data-component-part="callout-content"><span><strong>CDN Headers driver:</strong> Saving settings with this driver automatically enables <strong>Trust CDN &amp; Proxy Headers</strong>. When CDN country headers are missing, the plugin falls back to MaxMind and then ip-api.com (rate-limited) instead of returning empty geo data.</span></div></div>',
// Stats View & Export Localization
'stats_filter_visited_from' => 'Visited From',
'stats_filter_visited_until' => 'Visited Until',
'stats_filter_counted_in_stats' => 'Stats-counted visits only',
'stats_action_export' => 'Export CSV',
'stats_csv_time' => 'Time',
'stats_csv_ip' => 'IP Address',
@@ -216,6 +386,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',
@@ -225,8 +404,11 @@ return [
'form_section_options' => 'Options',
'form_section_notes' => 'Internal Notes',
'form_section_tracking' => 'Visit Tracking',
'form_section_tracking_desc' => 'Enable or disable visit tracking and statistics for this short link.',
'form_section_tracked_fields' => 'Tracked Fields',
'form_section_tracked_fields_desc' => 'Choose which metrics are collected for this link — useful for analytics depth and privacy.',
'form_section_analytics' => 'Third-Party Analytics',
'form_section_analytics_desc' => 'Integrate this link with Google Analytics by providing a data stream identifier.',
// New Dashboard Analytics Keys
'stats_card_top_source' => 'Top UTM Source',
@@ -239,16 +421,48 @@ 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
'tab_targeting' => 'Targeting & Security',
'tab_targeting' => 'Targeting',
'tab_password' => 'Password',
'tab_validity' => 'Expiration',
'expiration_dates_section_title' => 'Date & Time Expiration',
'visit_limits_section_title' => 'Visit Limits',
'security_section_title' => 'Security Controls',
'password' => 'Access Password',
'password_helper' => 'Require visitors to enter a password before being redirected.',
'password_card_title' => 'Password protection',
'password_card_subtitle' => 'Require visitors to enter a password before being redirected.',
'warning_page_card_title' => 'Redirect warning page',
'warning_page_card_subtitle' => 'Show an intermediate screen before redirecting visitors to the destination.',
'warning_page_empty_title' => 'Warning page disabled',
'warning_page_empty_desc' => 'Turn on the switch above to show a safety screen before external redirects.',
'warning_page_active_title' => 'Warning page enabled',
'confirm_password' => 'Confirm Password',
'new_password' => 'New Password',
'change_password' => 'Change Password',
'remove_password' => 'Remove Password',
'password_status_active' => 'Password protection is enabled.',
'password_status_active_desc' => 'This link is protected. Visitors must enter the correct password to continue.',
'password_empty_state_title' => 'No protection',
'password_empty_state_desc' => 'Add a password to restrict access to this short link to selected people only.',
'password_settings_section' => 'Password settings',
'password_change_short' => 'Change',
'password_remove_short' => 'Remove',
'save_password' => 'Save password',
'expiration_dates_section_desc' => 'Control when the link is available. Schedule a campaign start or automatic end.',
'visit_limits_section_desc' => 'Cap total clicks. Useful for one-time tickets or limited offers.',
'set_password' => 'Set Password',
'cancel' => 'Cancel',
'confirm' => 'Confirm',
'password_required_error' => 'Password is required.',
'password_mismatch_error' => 'Passwords do not match.',
'show_warning_page' => 'Show Redirect Warning Page',
'show_warning_page_helper' => 'Show an intermediate screen warning visitors about external redirection (NSFW/phishing protection).',
'targeting_type' => 'Targeting Strategy',
@@ -256,24 +470,53 @@ return [
'targeting_type_device' => 'Device-Based Redirects',
'targeting_type_country' => 'Country-Based (Geo-IP) Redirects',
'targeting_type_rotation' => 'A/B Split Rotation',
'targeting_type_language' => 'Browser Language-Based Redirects',
'device_targeting_rules' => 'Device Rules',
'country_targeting_rules' => 'Country Rules',
'language_targeting_rules' => 'Language Rules',
'rotation_targeting_rules' => 'Rotation Targets',
'device_mobile' => 'Mobile Destination URL',
'device_tablet' => 'Tablet Destination URL',
'device_desktop' => 'Desktop Destination URL',
'country_code' => 'Country Code',
'language_code' => 'Language Code',
'rotation_url' => 'Destination URL',
'rotation_weight' => 'Traffic Weight',
'rotation_weight_helper' => 'Percentage of traffic to route to this URL (e.g. 50 for 50%). Weights are balanced proportionally.',
'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.',
'outbound_url_blocked' => 'This outbound URL is not allowed.',
// New Advanced Targeting Builder
'targeting_rules' => 'Targeting Rules',
'add_filter' => 'Add a filter',
'match' => 'Match',
'match_or' => 'Any of the following filters (OR)',
'match_and' => 'All the following filters (AND)',
'filter_device' => 'Device',
'filter_platform' => 'Platform',
'filter_country' => 'Country',
'filter_language' => 'Browser Language',
'direct_to_url' => 'Direct to URL',
'select_devices' => 'Select devices',
'select_platforms' => 'Select operating systems',
'select_countries' => 'Select countries',
'select_languages' => 'Select browser languages',
'device_desktop_label' => 'Desktop',
'device_mobile_label' => 'Smartphone',
'device_tablet_label' => 'Tablet',
// New Settings Page Fields
'settings_tab_advanced' => 'Performance & Security',
'settings_section_aggregation' => 'High-Traffic Log Management',
'settings_retention_days' => 'Raw Log Retention Period',
'settings_retention_days_helper' => 'Select the duration for which you want to keep detailed raw click logs before they are deleted.',
'settings_aggregation_enabled' => 'Enable Automatic Daily Pruning & Aggregation',
'settings_aggregation_enabled_helper' => 'When enabled, the plugin automatically registers a daily task in the scheduler (at 02:00) to aggregate visits into daily statistics and delete raw logs older than the chosen retention period.',
'settings_aggregation_enabled' => 'Enable raw visit log pruning',
'settings_aggregation_enabled_helper' => 'The daily 02:00 job always aggregates visits into daily statistics. This toggle controls only whether raw visit rows older than the retention period are deleted.',
'retention_30_days' => '30 Days',
'retention_60_days' => '60 Days',
'retention_90_days' => '90 Days',
@@ -283,11 +526,50 @@ return [
'settings_section_rate_limiting' => 'Rate Limiting / Bot Protection',
'settings_rate_limiting_enabled' => 'Enable Limit Protection',
'settings_rate_limiting_enabled_helper' => 'Limit the rate of redirects per client IP address.',
'settings_rate_limiting_route_info' => '<div class="callout my-2 px-4 py-3 rounded-xl border border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30 text-sm text-blue-900 dark:text-blue-200"><strong>Two layers:</strong> redirect routes also use the package middleware throttle (default <code>120/min</code> per IP). When you enable the limiter below, <em>both</em> can return HTTP 429 — the stricter one wins first.</div>',
'settings_rate_limiting_max_attempts' => 'Max Redirects Allowed',
'settings_rate_limiting_max_attempts_helper' => 'Maximum allowed redirection requests within the decay window.',
'settings_rate_limiting_decay_seconds' => 'Decay Window (Seconds)',
'settings_rate_limiting_decay_seconds_helper' => 'Time period for the rate limiter.',
// Queue Settings Additions
'settings_queue_name' => 'Queue Name',
'settings_queue_name_helper' => 'The target queue to which tracking and buffering sync jobs are dispatched. Default: "default".',
'settings_queue_worker_info' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm.75-11.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm0 2.5a.75.75 0 0 1 .75.75v4a.75.75 0 0 1-1.5 0v-4a.75.75 0 0 1 .75-.75Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full [&_kbd]:bg-background-light dark:[&_kbd]:bg-background-dark [_code]:!text-current [_kbd]:!text-current [_a]:!text-current [_a]:border-current [_strong]:!text-current text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Queue Worker Required:</strong> The selected connection runs asynchronously. Run a worker that listens on the same connection and queue name as above (this is independent of <code>QUEUE_CONNECTION</code> in <code>.env</code>):<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">php artisan queue:work :connection --queue=:queue</code></span></div></div>',
'settings_geoip_stats_cache_ttl' => 'Stats Dashboard Cache TTL',
'settings_geoip_stats_cache_ttl_helper' => 'Time (in seconds) to cache stats calculation on the dashboard. When viewing charts and logs, the system loads calculated numbers from cache instead of querying millions of rows from SQL on every refresh. Default: 300 (5 minutes). Max: 86400 (24 hours).',
// Default Tracking Tab
'settings_tab_tracking_defaults' => 'Default Tracking',
'settings_section_tracking_defaults' => 'Default Visitor Tracking Settings',
'settings_section_tracking_defaults_helper' => 'These settings govern the default tracking status for newly created short URLs.',
'settings_track_visits_default' => 'Enable Tracking by Default',
'settings_track_ip_default' => 'Default IP Address Tracking',
'settings_track_browser_default' => 'Default Browser Tracking',
'settings_track_browser_version_default' => 'Default Browser Version Tracking',
'settings_track_os_default' => 'Default OS Tracking',
'settings_track_os_version_default' => 'Default OS Version Tracking',
'settings_track_referer_default' => 'Default Referer URL Tracking',
'settings_track_device_type_default' => 'Default Device Type Tracking',
'settings_track_browser_language_default' => 'Default Browser Language Tracking',
'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',
'settings_section_qr_defaults' => 'Default QR Code Design Options',
'settings_section_qr_defaults_helper' => 'Configure standard design styling automatically loaded for newly generated QR codes.',
'settings_qr_size' => 'Default Size (px)',
'settings_qr_margin' => 'Default Margin',
'settings_qr_dot_style' => 'Default Dot Style',
'settings_qr_foreground_color' => 'Default Foreground Color',
'settings_qr_background_color' => 'Default Background Color',
'settings_qr_gradient_enabled' => 'Enable Gradient by Default',
'settings_qr_gradient_from' => 'Gradient Color From',
'settings_qr_gradient_to' => 'Gradient Color To',
'settings_qr_gradient_type' => 'Default Gradient Type',
// Views
'password_title' => 'Password Required',
'password_description' => 'This link is password-protected. Please enter the correct password to continue.',
@@ -298,4 +580,410 @@ return [
'warning_description' => 'You are leaving this secure portal and being redirected to an external target link. Please ensure you trust the address below:',
'warning_btn_continue' => 'Continue to Destination',
'warning_btn_back' => 'Go Back',
'pixel_loading_title' => 'Connecting...',
'pixel_loading_description' => 'Preparing your connection and forwarding you now.',
// Marketing & API Tab
'tab_marketing' => 'Marketing & API',
'marketing_pixels_title' => 'Retargeting Pixels (Client-Side)',
'marketing_pixels_desc' => 'Add ad tracking scripts to capture visitor cookies and build remarketing lists.',
'marketing_pixels_empty_title' => 'No pixels attached',
'marketing_pixels_empty_desc' => 'Select one or more retargeting pixels to fire client-side tracking scripts when visitors click this link.',
'marketing_pixels_select_placeholder' => 'Choose retargeting pixels…',
'marketing_webhook_empty_title' => 'No webhook configured',
'marketing_webhook_empty_desc' => 'Add a destination URL to receive real-time HTTP POST notifications on each click.',
'pixel_meta' => 'Meta Pixel ID',
'pixel_google' => 'Google Tag / GA4 ID',
'pixel_linkedin' => 'LinkedIn Partner ID',
'marketing_webhooks_title' => 'Link Webhook Integration',
'marketing_webhooks_desc' => 'Configure a custom destination URL to send a real-time HTTP POST notification on each click.',
'webhook_url' => 'Dedicated Webhook URL',
'webhook_helper_alert' => 'Once configured, each visit to this short link triggers a real-time HTTP POST request to this URL. The payload contains detailed click metadata in JSON format (URL key, destination, device, browser, operating system, geo, referrer, UTM parameters, and QR scan flag). IP addresses are not included in webhook payloads.',
'webhook_show_payload' => 'Show example JSON payload',
'webhook_payload_modal_title' => 'Example webhook payload',
'webhook_payload_modal_desc' => 'Sample JSON body for the visited event. Actual values depend on each click.',
'webhook_payload_copy' => 'Copy payload to clipboard',
// Settings tab additions
'settings_tab_developer' => 'API & Webhooks',
'settings_section_rest_api' => 'REST API Access',
'settings_api_enabled' => 'Enable Developer REST API',
'settings_api_enabled_helper' => 'Allow external systems to create, list, and delete short URLs via the REST API. Disable to block all /api/short-url/* endpoints with a 503 response.',
'settings_section_global_webhook' => 'Global Webhook Configuration',
'settings_global_webhook_enabled' => 'Enable Global Webhook',
'settings_global_webhook_enabled_helper' => 'When enabled, the plugin will dispatch HTTP POST notifications for all short link events.',
'settings_global_webhook_url' => 'Global Webhook URL',
'settings_global_webhook_url_helper' => 'Destination URL to dispatch event payloads asynchronously in the background for all links.',
'settings_webhook_events' => 'Monitored Webhook Events',
'settings_webhook_events_helper' => 'Select which events should trigger a POST notification.',
'webhook_event_visited' => 'Link Visited (Click)',
'webhook_event_created' => 'Link Created',
'webhook_event_expired' => 'Link Expired (Date)',
'webhook_event_limit_reached' => 'Link Click Limit Reached',
'settings_section_api_keys' => 'Developer API Keys',
'settings_api_keys_description' => 'Manage API keys to integrate with external systems (e.g. CRM, Zapier, Make). Authenticate requests using the X-Api-Key header.',
'settings_api_keys' => 'Active API Keys',
'api_key_name' => 'Key Name (Description)',
'api_key_owner_user_id' => 'Owner User ID',
'api_key_owner_user_id_helper' => 'Required when link scoping is enabled (default). Limits this key to links owned by that user.',
'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',
'settings_section_security_v2_desc' => 'Configure VPN/proxy detection and Google Safe Browsing URL validation.',
'settings_section_analytics_security' => 'Analytics & Bot Detection',
'settings_section_analytics_security_desc' => 'Optional click deduplication and advanced bot detection settings.',
'settings_click_dedup_enabled' => 'Enable Click Deduplication',
'settings_click_dedup_enabled_helper' => 'Ignore repeat clicks from the same IP within the configured time window.',
'settings_click_dedup_hours' => 'Deduplication Window (hours)',
'settings_click_dedup_hours_helper' => 'How long to treat repeat clicks from the same IP as duplicates.',
'settings_bot_verify_googlebot' => 'Verify Googlebot IP',
'settings_bot_verify_googlebot_helper' => 'Reverse-DNS + forward IP check for Googlebot user agents.',
'settings_bot_debug_secret' => 'Bot Debug Secret',
'settings_bot_debug_secret_helper' => 'Required value for ?bot=1 outside local/testing environments.',
'settings_vpn_detection_enabled' => 'Enable VPN & Proxy Detection',
'settings_vpn_detection_enabled_helper' => 'When enabled, incoming visits will be checked for VPN, proxy, or Tor usage.',
'settings_vpn_driver' => 'Detection Driver',
'settings_vpn_driver_helper' => 'Choose between the free IP-API service or VPNAPI.io.',
'settings_vpn_driver_ipapi' => 'IP-API (Free)',
'settings_vpn_driver_vpnapi' => 'VPNAPI.io (Premium / Free)',
'settings_vpnapi_key' => 'VPNAPI.io Key',
'settings_vpnapi_key_helper' => 'Your API key from vpnapi.io (required for vpnapi driver).',
'settings_vpn_block_action' => 'VPN Block Action',
'settings_vpn_block_action_helper' => 'Choose whether to only flag VPN traffic in statistics or actively block them with a 403 Forbidden page.',
'settings_vpn_block_social_warning' => '<div class="callout my-2 px-4 py-3 rounded-xl border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30 text-sm text-amber-900 dark:text-amber-200"><strong>Link previews:</strong> blocking mode returns HTTP 403 for VPN/proxy/datacenter IPs. Known social crawlers (Facebook, LinkedIn, Google preview bots) are <em>still allowed</em> so OG meta and cloaked pages keep working. Human visitors on VPN may be blocked.</div>',
'settings_vpn_detection_timeout' => 'VPN detection timeout',
'settings_vpn_detection_timeout_helper' => 'Maximum seconds to wait for the VPN/proxy API response on each redirect.',
'settings_vpn_detection_cache_ttl' => 'VPN detection cache TTL',
'settings_vpn_detection_cache_ttl_helper' => 'How long (in seconds) to cache VPN/proxy lookup results per IP address.',
'settings_trust_cdn_headers_auto_enabled' => '<div class="callout my-2 px-4 py-3 rounded-xl border border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30 text-sm text-blue-900 dark:text-blue-200">The <strong>CDN Headers</strong> Geo-IP driver (Geo-IP tab) requires trusting proxy headers. This option is <strong>automatically enabled</strong> when that driver is selected and on save.</div>',
'settings_vpn_block_flag_only' => 'Flag in stats only (allow redirect)',
'settings_vpn_block_block_403' => 'Block traffic (serve 403 Forbidden)',
'settings_safe_browsing_enabled' => 'Enable Google Safe Browsing URL Verification',
'settings_safe_browsing_enabled_helper' => 'Verify target URLs against Google\'s Threat List to block phishing, malware, and social engineering links.',
'settings_safe_browsing_api_key' => 'Google Safe Browsing API Key',
'settings_safe_browsing_api_key_helper' => 'Your Google Developer Console Web API key.',
'settings_safe_browsing_test' => 'Test API Connection',
'settings_safe_browsing_test_empty' => 'Please enter an API key first.',
'settings_safe_browsing_test_ok' => 'Google Safe Browsing connection OK (API is active).',
'settings_safe_browsing_test_fail' => 'Google Safe Browsing key validation failed.',
'settings_safe_browsing_test_error' => 'API connection error.',
// World Map Widget
'world_map_title' => 'Visitor World Map',
'world_map_total_clicks' => 'total clicks',
'world_map_countries' => 'countries',
'world_map_fewer' => 'Fewer',
'world_map_more' => 'More',
'world_map_top_countries' => 'Top Countries',
'world_map_no_data' => 'No geographic data yet.',
'world_map_no_data_sub' => 'Enable Geo-IP detection to start recording visitor locations.',
'stats_card_qr_scans' => 'QR Code Scans',
'stats_card_qr_rate' => 'QR Scan Conversion Rate',
'stats_breakdown_languages' => 'Top Languages',
'stats_no_language_data' => 'No language data.',
'badge_clicks' => 'clicks',
'badge_unique' => 'unique',
'badge_qr' => 'QR',
'badge_qr_scans' => 'QR scans',
'badge_expires' => 'Expires: :date',
'badge_no_expiry' => 'No expiry',
'badge_redirect' => ':code redirect',
'pixels_navigation_label' => 'Retargeting Pixels',
'pixel_resource_title' => 'Retargeting Pixel',
'pixel_name' => 'Pixel Name',
'pixel_type' => 'Provider',
'pixel_id_label' => 'Pixel ID / Tag ID',
'pixel_status_active' => 'Active',
'settings_tab_deep_linking' => 'Deep Linking',
'settings_section_deep_linking' => 'Universal Links & App Links Association Files',
'settings_deep_linking_enabled' => 'Enable Deep Linking Files',
'settings_deep_linking_enabled_helper' => 'When enabled, the plugin serves AASA and AssetLinks configuration files from your root domain.',
'settings_aasa_json' => 'apple-app-site-association (iOS)',
'settings_aasa_json_helper' => 'Specify the valid JSON configuration for Apple App Site Association. Do not include wrapping script tags.',
'settings_assetlinks_json' => 'assetlinks.json (Android)',
'settings_assetlinks_json_helper' => 'Specify the valid JSON array configuration for Android Digital Asset Links.',
'validation_invalid_json' => 'The entered text is not a valid JSON string.',
// App Linking Preview
'app_linking_supported_os' => 'Supported Operating Systems:',
'app_linking_supported_apps' => 'Supported Apps',
'app_linking_supported_apps_helper' => 'If you do not find your app here, you can use the Targeting feature to redirect the user to your app with your custom deep link.',
'app_linking_matched' => 'Matched',
'app_linking_preconfigured_count' => ':count apps pre-configured for automatic redirection.',
'app_linking_redirect_active' => 'Opens directly inside :app',
'app_linking_auto_open' => 'Auto-Open',
'app_linking_matched_description' => 'When clicked on a mobile device, this link will open immediately inside the native <strong>:app</strong> application instead of the web browser.',
'app_linking_deep_link_scheme' => 'Mobile app path (Deep Link): :scheme',
'app_linking_deep_link_label' => 'Deep link URL',
'app_linking_standard_redirect' => 'This link will open in a standard mobile browser (no matching mobile app found).',
// App Redirect Page
'app_redirect_title' => 'Opening App...',
'app_redirect_opening_in' => 'Opening in :app',
'app_redirect_waiting_text' => 'We are redirecting you to the native application. Please wait a moment...',
'app_redirect_btn_open' => 'Open Native App',
'app_redirect_btn_browser' => 'Open in Browser',
'app_redirect_in_app_warning_title' => 'In-App Browser Detected',
'app_redirect_in_app_warning_desc' => 'Redirections can be restricted inside Instagram or Facebook. Tap the three dots (•••) in the top-right corner and select "Open in Safari" or "Open in Chrome" to launch the app.',
// Link Expired Page
'expired_title' => 'Link Inactive or Expired',
'expired_description' => 'This link is no longer active. It may have reached its click limit, expiration date, or has been deactivated.',
'expired_btn_home' => 'Go to Homepage',
'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.',
'custom_domain_not_owned_error' => 'You do not have permission to use this custom domain.',
'resource_not_owned_error' => 'You do not have permission to use this resource.',
'webhook_signing_secret_required' => 'A webhook signing secret is required when the global webhook is enabled.',
'webhook_signing_secret' => 'Webhook Signing Secret',
'webhook_signing_secret_helper' => 'Required when the global webhook is enabled. Outgoing requests include HMAC-SHA256 in X-ShortUrl-Signature.',
// Stats View & Granularity & PoP Trends
'stats_prev_period' => 'prev period',
'stats_granularity_hourly' => 'Hourly',
'stats_granularity_daily' => 'Daily',
'stats_granularity_weekly' => 'Weekly',
'stats_granularity_monthly' => 'Monthly',
// Stats Security Widget
'stats_security_title' => 'Security & Bot Analytics',
'stats_security_real_users' => 'Real Users',
'stats_security_bots' => 'Bots & Crawlers',
'stats_security_vpn_blocked' => 'Blocked VPN/Proxy Traffic',
'stats_security_no_vpn_blocks' => 'No blocked VPN visits',
'stats_security_bot_ratio' => 'Bot Traffic Ratio',
'stats_security_human_clicks' => 'human clicks',
'stats_security_bot_clicks' => 'bot clicks',
'stats_security_proxy_clicks' => 'VPN/Proxy detected',
// Visit Logs Column & Modal translation enhancements
'stats_col_link' => 'Link',
'stats_col_referrer' => 'Referrer',
'stats_col_language' => 'Language',
'stats_col_utm_source' => 'UTM Source',
'stats_col_utm_medium' => 'UTM Medium',
'stats_col_utm_campaign' => 'UTM Campaign',
'stats_col_utm_term' => 'UTM Term',
'stats_col_utm_content' => 'UTM Content',
'stats_col_variant' => 'A/B Variant',
'stats_col_qr_scan' => 'QR Scan',
'stats_col_bot' => 'Bot',
'stats_col_proxy' => 'Proxy/VPN',
'stats_referer_direct' => '(direct)',
'stats_device_desktop' => 'Desktop',
'stats_device_mobile' => 'Mobile',
'stats_device_tablet' => 'Tablet',
'stats_device_bot' => 'Robot / Bot',
'stats_action_view_details' => 'View Details',
'stats_modal_visit_details' => 'Visit Details',
'stats_detail_location' => 'Location',
'stats_detail_is_bot' => 'Is Bot?',
'stats_detail_is_proxy' => 'Is Proxy/VPN?',
'stats_detail_is_qr' => 'Is QR Scan?',
'stats_detail_visited_at' => 'Visited At',
'stats_yes' => 'Yes',
'stats_no' => 'No',
'add_url' => 'Add URL',
'traffic_split' => 'Traffic Split',
'delete_confirmation_desc' => 'Are you sure you want to delete the following link? Deleting these links will remove all of their analytics. This action cannot be undone proceed with caution.',
'delete_verification_label' => 'To verify, type <strong>:short_url</strong> below',
'delete_verification_error' => 'The entered URL does not match.',
// Custom Domains
'empty_state_domain_heading' => 'No custom domains yet',
'empty_state_domain_description' => 'Add your own custom domains to serve short links using your brand identity (e.g. links.acme.com) instead of the default app domain.',
'empty_state_domain_action' => 'Add domain',
'dns_configuration_guide' => 'DNS Configuration Guide',
'dns_for_subdomains' => 'For Subdomains (CNAME)',
'dns_for_root_domains' => 'For Root Domains (A Record)',
'copied' => 'Copied!',
'domains_navigation_label' => 'Custom Domains',
'domain_resource_title' => 'Custom Domain',
'domain_label' => 'Domain Name',
'domain_status_active' => 'Active',
'domain_status_verified' => 'DNS Verified',
'domain_verification_status' => 'Verification Status',
'action_verify_dns' => 'Verify DNS',
'dns_verify_success' => 'DNS record verified successfully!',
'dns_verify_fail' => 'DNS verification failed. Please check your configuration and try again.',
'default_domain_label' => 'Default App Domain',
'dns_tab_a' => 'A Record (recommended)',
'dns_tab_cname' => 'CNAME Record',
'dns_instructions_apex' => 'To configure your apex domain :domain, set the following A record on your DNS provider:',
'dns_instructions_subdomain' => 'To configure your subdomain :domain, set the following CNAME record on your DNS provider:',
'dns_table_type' => 'Type',
'dns_table_name' => 'Name',
'dns_table_value' => 'Value',
'dns_table_ttl' => 'TTL',
'dns_warning_ttl' => 'If a TTL value of 86400 is not available, choose the highest available value. Domain propagation may take up to 12 hours.',
'dns_setup_title' => ':domain - DNS Setup',
'domain_status_valid' => 'Valid',
'domain_status_invalid' => 'Invalid',
'domain_no_redirects' => 'No redirects configured',
'domain_mapped_links' => 'Mapped to :count short link|Mapped to :count short links',
'action_delete_domain' => 'Delete Domain',
'delete_domain_confirmation_desc' => 'Are you sure you want to delete this custom domain? This action cannot be undone proceed with caution.',
'short_link_label' => 'Short Link',
// API Scopes & Rate Limits
'api_key_scope' => 'API Scope',
'api_key_scope_helper' => 'Choose the permission level for this API key.',
'api_key_scope_read_write' => 'Read & Write (Full Access)',
'api_key_scope_read_only' => 'Read Only',
'api_key_rate_limit' => 'Rate Limit',
'api_key_rate_limit_helper' => 'Define the maximum requests per minute allowed for this key.',
'api_key_rate_limit_unlimited' => 'Unlimited',
'api_key_rate_limit_rpm' => ':count requests / min',
// Folders
'folders_navigation_label' => 'Folders',
'folder_resource_title' => 'Folder',
'folder_name' => 'Folder Name',
'folder_slug' => 'Slug',
'folder_color' => 'Color',
'empty_state_folder_action' => 'Create Folder',
'empty_state_folders_heading' => 'No folders yet',
'empty_state_folders_description' => 'Organize your short links into folders.',
'action_edit_folder' => 'Edit Folder',
'action_delete_folder' => 'Delete Folder',
'link_count_one' => '1 link',
'link_count_many' => ':count links',
// Tags
'tags_navigation_label' => 'Tags',
'tag_resource_title' => 'Tag',
'tag_name' => 'Tag Name',
'tag_slug' => 'Slug',
'tag_color' => 'Color',
'empty_state_tag_action' => 'Create Tag',
'empty_state_tags_heading' => 'No tags yet',
'empty_state_tags_description' => 'Tag your short links to filter them easily.',
'action_edit_tag' => 'Edit Tag',
'action_delete_tag' => 'Delete Tag',
// Archiving
'is_archived' => 'Archived',
'is_archived_helper' => 'Archived links are hidden from the active list but continue to redirect.',
'action_archive' => 'Archive',
'action_restore' => 'Restore',
'action_archive_selected' => 'Archive Selected',
'action_restore_selected' => 'Restore Selected',
'tab_active_links' => 'Active',
'tab_archived_links' => 'Archived',
// Live Feed
'stats_tab_live_feed' => 'Live Feed',
'stats_live_feed_empty' => 'No visits recorded yet. Visits will appear here in real-time.',
'stats_live_feed_poll_interval' => 'Live via SSE',
'stats_live_feed_mode_redis' => 'Live · Redis push',
'stats_live_feed_mode_poll' => 'Live · SSE poll',
'stats_col_visitor' => 'Visitor / Location',
// Empty state mock values
'empty_state_tags_mock_redirecting' => 'Redirecting...',
'empty_state_tags_mock_promo' => 'promo',
'empty_state_tags_mock_social' => 'social',
'empty_state_tags_mock_dev' => 'dev',
// Folder links count
'folder_one_link' => '1 link',
'folder_many_links' => ':count links',
// Colors
'color_gray' => 'Gray',
'color_red' => 'Red',
'color_blue' => 'Blue',
'color_green' => 'Green',
'color_yellow' => 'Yellow',
'color_indigo' => 'Indigo',
'color_purple' => 'Purple',
'color_pink' => 'Pink',
// SEO & Social
'tab_seo_social' => 'SEO & Social',
'is_cloaked' => 'Link Cloaking',
'is_cloaked_helper' => 'Mask the destination URL in the address bar using an iframe on your short link domain.',
'is_cloaked_iframeable_hint' => 'Some destinations block iframe embedding (X-Frame-Options). Use the iframeable pre-check before enabling cloaking.',
'do_index' => 'Search Engine Indexing',
'do_index_helper' => 'Allow search engines (like Google) to index this short URL. Otherwise, it is served with a noindex tag.',
'og_title' => 'Custom Title',
'og_description' => 'Custom Description',
'og_image' => 'Custom Image',
'og_section_title' => 'Social Media Preview (Open Graph)',
'og_section_desc' => 'Customize how this link looks when shared on Facebook, X, LinkedIn, WhatsApp, and Slack.',
'seo_section_title' => 'Search Engine Indexing & Cloaking',
'seo_section_desc' => 'Manage search engine crawling settings and stealth cloaking parameters.',
'live_social_preview' => 'Social Card Preview',
'live_qr_preview' => 'QR Code Preview',
'advanced_options_section' => 'Advanced Redirect & State Options',
// Social Card Preview sidebar
'og_title_placeholder' => 'Page title will appear here',
'og_description_placeholder' => 'Page description will appear here',
'og_empty_state_hint' => 'Paste a link to auto-generate preview',
'password_prompt_preview_desc' => 'Visitors will see a password prompt before being redirected.',
'fetching_metadata' => 'Fetching preview...',
// HTTP / API messages (visitor-facing & REST)
'api_disabled' => 'The Developer API is currently disabled. Enable it in Short URL Settings → API & Webhooks.',
'api_key_missing' => 'Unauthorized. API Key is missing.',
'api_key_invalid' => 'Unauthorized. Invalid or inactive API Key.',
'api_key_owner_required' => 'Forbidden. This API key must have an owner_user_id when link scoping is enabled.',
'api_key_read_only' => 'Forbidden. This API key has read-only permissions.',
'api_rate_limit_exceeded' => 'Too many requests. API key rate limit exceeded.',
'short_url_not_found' => 'Short URL not found.',
'redirect_vpn_blocked' => 'Access denied. VPN, Proxy, or automated scraping connection detected.',
'redirect_rate_limited' => 'Too many requests. Please try again in :seconds seconds.',
'redirect_destination_blocked' => 'This link destination has been blocked for security reasons.',
'public_stats_rate_limited' => 'Too many requests.',
'public_stats_password_invalid' => 'Password required or invalid.',
'public_stats_password_rate_limited' => 'Too many incorrect password attempts. Please try again later.',
'public_stats_retry_in' => 'Please try again in :seconds seconds.',
'action_public_stats' => 'Public stats',
'public_stats_modal_title' => 'Public statistics',
'public_stats_modal_description' => 'Share a read-only stats page without giving access to the admin panel.',
'public_stats_save' => 'Save',
'public_stats_enabled_label' => 'Enable public statistics page',
'public_stats_password_label' => 'Optional password',
'public_stats_password_helper' => 'Leave blank to keep the current password. Clear by disabling public stats.',
'public_stats_disabled_hint' => 'Public stats are off. Enable the toggle and save to generate a shareable link.',
'public_stats_link_label' => 'Public stats link',
'public_stats_save_to_activate' => 'Save to activate this link.',
'public_stats_enabled_status' => 'Public stats are enabled.',
'public_stats_copied' => 'Public stats link copied.',
'public_stats_open' => 'Open',
'public_stats_saved_enabled' => 'Public statistics enabled.',
'public_stats_saved_disabled' => 'Public statistics disabled.',
'public_stats_page_title' => 'Link statistics',
'public_stats_page_subtitle' => 'Stats for /:key',
'public_stats_date_from' => 'From',
'public_stats_date_to' => 'To',
'public_stats_apply_filter' => 'Apply',
'public_stats_total_visits' => 'Total visits',
'public_stats_unique_visits' => 'Unique visits',
'public_stats_today' => 'Today',
'public_stats_this_week' => 'This week',
'public_stats_this_month' => 'This month',
'public_stats_qr_scans' => 'QR scans',
'public_stats_visits_by_day' => 'Visits by day',
'public_stats_no_data' => 'No visits recorded for this period.',
'public_stats_day' => 'Day',
'public_stats_visits' => 'Visits',
'public_stats_password_title' => 'Protected statistics',
'public_stats_password_description' => 'Enter the password to view statistics for this link.',
'public_stats_password_submit' => 'View statistics',
'redirect_html_redirecting' => 'Redirecting you to the destination website...',
'api_created' => 'Short URL created successfully.',
'api_updated' => 'Short URL updated successfully.',
'api_bulk_created' => 'Short URLs created successfully.',
'api_deleted' => 'Short URL deleted successfully.',
'api_bulk_deleted' => 'Short URLs deleted successfully.',
'api_bulk_updated' => 'Short URLs updated successfully.',
];

View File

@@ -0,0 +1,44 @@
<?php
return [
'pl' => 'Polish',
'en' => 'English',
'de' => 'German',
'fr' => 'French',
'es' => 'Spanish',
'it' => 'Italian',
'pt' => 'Portuguese',
'ru' => 'Russian',
'zh' => 'Chinese',
'ja' => 'Japanese',
'ar' => 'Arabic',
'nl' => 'Dutch',
'cs' => 'Czech',
'sk' => 'Slovak',
'hu' => 'Hungarian',
'ro' => 'Romanian',
'bg' => 'Bulgarian',
'hr' => 'Croatian',
'sr' => 'Serbian',
'sl' => 'Slovenian',
'uk' => 'Ukrainian',
'tr' => 'Turkish',
'da' => 'Danish',
'sv' => 'Swedish',
'no' => 'Norwegian',
'fi' => 'Finnish',
'el' => 'Greek',
'he' => 'Hebrew',
'hi' => 'Hindi',
'th' => 'Thai',
'vi' => 'Vietnamese',
'ko' => 'Korean',
'id' => 'Indonesian',
'ms' => 'Malay',
'et' => 'Estonian',
'lv' => 'Latvian',
'lt' => 'Lithuanian',
'is' => 'Icelandic',
'ga' => 'Irish',
'fa' => 'Persian',
];

View File

@@ -0,0 +1,253 @@
<?php
return [
'AF' => 'Afganistan',
'AX' => 'Wyspy Alandzkie',
'AL' => 'Albania',
'DZ' => 'Algieria',
'AS' => 'Samoa Amerykańskie',
'AD' => 'Andora',
'AO' => 'Angola',
'AI' => 'Anguilla',
'AQ' => 'Antarktyda',
'AG' => 'Antigua i Barbuda',
'AR' => 'Argentyna',
'AM' => 'Armenia',
'AW' => 'Aruba',
'AU' => 'Australia',
'AT' => 'Austria',
'AZ' => 'Azerbejdżan',
'BS' => 'Bahamy',
'BH' => 'Bahrajn',
'BD' => 'Bangladesz',
'BB' => 'Barbados',
'BY' => 'Białoruś',
'BE' => 'Belgia',
'BZ' => 'Belize',
'BJ' => 'Benin',
'BM' => 'Bermudy',
'BT' => 'Bhutan',
'BO' => 'Boliwia',
'BQ' => 'Bonaire, Sint Eustatius i Saba',
'BA' => 'Bośnia i Hercegowina',
'BW' => 'Botswana',
'BV' => 'Wyspa Bouveta',
'BR' => 'Brazylia',
'IO' => 'Brytyjskie Terytorium Oceanu Indyjskiego',
'BN' => 'Brunei',
'BG' => 'Bułgaria',
'BF' => 'Burkina Faso',
'BI' => 'Burundi',
'CV' => 'Cabo Verde',
'KH' => 'Kambodża',
'CM' => 'Kamerun',
'CA' => 'Kanada',
'KY' => 'Kajmany',
'CF' => 'Republika Środkowoafrykańska',
'TD' => 'Czad',
'CL' => 'Chile',
'CN' => 'Chiny',
'CX' => 'Wyspa Bożego Narodzenia',
'CC' => 'Wyspy Kokosowe',
'CO' => 'Kolumbia',
'KM' => 'Komory',
'CD' => 'Demokratyczna Republika Konga',
'CG' => 'Kongo',
'CK' => 'Wyspy Cooka',
'CR' => 'Kostaryka',
'CI' => 'Wybrzeże Kości Słoniowej',
'HR' => 'Chorwacja',
'CU' => 'Kuba',
'CW' => 'Curaçao',
'CY' => 'Cypr',
'CZ' => 'Czechy',
'DK' => 'Dania',
'DJ' => 'Dżibuti',
'DM' => 'Dominika',
'DO' => 'Dominikana',
'EC' => 'Ekwador',
'EG' => 'Egipt',
'SV' => 'Salwador',
'GQ' => 'Gwinea Równikowa',
'ER' => 'Erytrea',
'EE' => 'Estonia',
'SZ' => 'Eswatini',
'ET' => 'Etiopia',
'FK' => 'Falklandy',
'FO' => 'Wyspy Owcze',
'FJ' => 'Fidżi',
'FI' => 'Finlandia',
'FR' => 'Francja',
'GF' => 'Gujana Francuska',
'PF' => 'Polinezja Francuska',
'TF' => 'Francuskie Terytoria Południowe',
'GA' => 'Gabon',
'GM' => 'Gambia',
'GE' => 'Gruzja',
'DE' => 'Niemcy',
'GH' => 'Ghana',
'GI' => 'Gibraltar',
'GR' => 'Grecja',
'GL' => 'Grenlandia',
'GD' => 'Grenada',
'GP' => 'Gwadelupa',
'GU' => 'Guam',
'GT' => 'Gwatemala',
'GG' => 'Guernsey',
'GN' => 'Gwinea',
'GW' => 'Gwinea Bissau',
'GY' => 'Gujana',
'HT' => 'Haiti',
'HM' => 'Wyspy Heard i McDonalda',
'VA' => 'Watykan',
'HN' => 'Honduras',
'HK' => 'Hongkong',
'HU' => 'Węgry',
'IS' => 'Islandia',
'IN' => 'Indie',
'ID' => 'Indonezja',
'IR' => 'Iran',
'IQ' => 'Irak',
'IE' => 'Irlandia',
'IM' => 'Wyspa Man',
'IL' => 'Izrael',
'IT' => 'Włochy',
'JM' => 'Jamajka',
'JP' => 'Japonia',
'JE' => 'Jersey',
'JO' => 'Jordania',
'KZ' => 'Kazachstan',
'KE' => 'Kenia',
'KI' => 'Kiribati',
'KP' => 'Korea Północna',
'KR' => 'Korea Południowa',
'KW' => 'Kuwejt',
'KG' => 'Kirgistan',
'LA' => 'Laos',
'LV' => 'Łotwa',
'LB' => 'Liban',
'LS' => 'Lesotho',
'LR' => 'Liberia',
'LY' => 'Libia',
'LI' => 'Liechtenstein',
'LT' => 'Litwa',
'LU' => 'Luksemburg',
'MO' => 'Makau',
'MG' => 'Madagaskar',
'MW' => 'Malawi',
'MY' => 'Malezja',
'MV' => 'Malediwy',
'ML' => 'Mali',
'MT' => 'Malta',
'MH' => 'Wyspy Marshalla',
'MQ' => 'Martynika',
'MR' => 'Mauretania',
'MU' => 'Mauritius',
'YT' => 'Majotta',
'MX' => 'Meksyk',
'FM' => 'Mikronezja',
'MD' => 'Mołdawia',
'MC' => 'Monako',
'MN' => 'Mongolia',
'ME' => 'Czarnogóra',
'MS' => 'Montserrat',
'MA' => 'Maroko',
'MZ' => 'Mozambik',
'MM' => 'Mjanma',
'NA' => 'Namibia',
'NR' => 'Nauru',
'NP' => 'Nepal',
'NL' => 'Holandia',
'NC' => 'Nowa Kaledonia',
'NZ' => 'Nowa Zelandia',
'NI' => 'Nikaragua',
'NE' => 'Niger',
'NG' => 'Nigeria',
'NU' => 'Niue',
'NF' => 'Norfolk',
'MK' => 'Macedonia Północna',
'MP' => 'Mariany Północne',
'NO' => 'Norwegia',
'OM' => 'Oman',
'PK' => 'Pakistan',
'PW' => 'Palau',
'PS' => 'Palestyna',
'PA' => 'Panama',
'PG' => 'Papua-Nowa Gwinea',
'PY' => 'Paragwaj',
'PE' => 'Peru',
'PH' => 'Filipiny',
'PN' => 'Pitcairn',
'PL' => 'Polska',
'PT' => 'Portugalia',
'PR' => 'Portoryko',
'QA' => 'Katar',
'RE' => 'Reunion',
'RO' => 'Rumunia',
'RU' => 'Rosja',
'RW' => 'Rwanda',
'BL' => 'Saint-Barthélemy',
'SH' => 'Święta Helena, Wyspa Wniebowstąpienia i Tristan da Cunha',
'KN' => 'Saint Kitts i Nevis',
'LC' => 'Saint Lucia',
'MF' => 'Saint-Martin',
'PM' => 'Saint-Pierre i Miquelon',
'VC' => 'Saint Vincent i Grenadyny',
'WS' => 'Samoa',
'SM' => 'San Marino',
'ST' => 'Wyspy Świętego Tomasza i Książęca',
'SA' => 'Arabia Saudyjska',
'SN' => 'Senegal',
'RS' => 'Serbia',
'SC' => 'Seszele',
'SL' => 'Sierra Leone',
'SG' => 'Singapur',
'SX' => 'Sint Maarten',
'SK' => 'Słowacja',
'SI' => 'Słowenia',
'SB' => 'Wyspy Salomona',
'SO' => 'Somalia',
'ZA' => 'Południowa Afryka',
'GS' => 'Georgia Południowa i Sandwich Południowy',
'SS' => 'Sudan Południowy',
'ES' => 'Hiszpania',
'LK' => 'Sri Lanka',
'SD' => 'Sudan',
'SR' => 'Surinam',
'SJ' => 'Svalbard i Jan Mayen',
'SE' => 'Szwecja',
'CH' => 'Szwajcaria',
'SY' => 'Syria',
'TW' => 'Tajwan',
'TJ' => 'Tadżykistan',
'TZ' => 'Tanzania',
'TH' => 'Tajlandia',
'TL' => 'Timor Wschodni',
'TG' => 'Togo',
'TK' => 'Tokelau',
'TO' => 'Tonga',
'TT' => 'Trynidad i Tobago',
'TN' => 'Tunezja',
'TR' => 'Turcja',
'TM' => 'Turkmenistan',
'TC' => 'Turks i Caicos',
'TV' => 'Tuvalu',
'UG' => 'Uganda',
'UA' => 'Ukraina',
'AE' => 'Zjednoczone Emiraty Arabskie',
'GB' => 'Wielka Brytania',
'UM' => 'Dalekie Wyspy Mniejsze Stanów Zjednoczonych',
'US' => 'Stany Zjednoczone',
'UY' => 'Urugwaj',
'UZ' => 'Uzbekistan',
'VU' => 'Vanuatu',
'VE' => 'Wenezuela',
'VN' => 'Wietnam',
'VG' => 'Brytyjskie Wyspy Dziewicze',
'VI' => 'Wyspy Dziewicze Stanów Zjednoczonych',
'WF' => 'Wallis i Futuna',
'EH' => 'Sahara Zachodnia',
'YE' => 'Jemen',
'ZM' => 'Zambia',
'ZW' => 'Zimbabwe',
];

View File

@@ -1,16 +1,31 @@
<?php
return [
'navigation_label' => 'Krótkie linki',
'navigation_group' => 'Narzędzia',
'navigation_label' => 'Linki',
'navigation_group' => 'Skracacz linków',
'resource_title' => 'Krótki URL',
'empty_state_heading' => 'Brak linków',
'empty_state_description' => 'Zacznij tworzyć krótkie linki dla swoich kampanii marketingowych, programów partnerskich i nie tylko.',
'empty_state_action' => 'Utwórz link',
'empty_state_pixel_heading' => 'Brak pikseli',
'empty_state_pixel_description' => 'Dodaj piksele śledzące z Meta, Google, TikTok i innych platform, aby kierować reklamy do użytkowników klikających Twoje linki.',
'empty_state_pixel_action' => 'Utwórz piksel',
// Form Tabs
'tab_link' => 'Szczegóły linku',
'tab_tracking' => 'Ustawienia śledzenia',
'tab_tracking' => 'Śledzenie',
'tab_qr_design' => 'Wygląd kodu QR',
'tab_app_linking' => 'Autootwieranie aplikacji',
// App Linking
'form_section_app_linking' => 'Autootwieranie aplikacji / Głębokie linkowanie',
'auto_open_app_mobile' => 'Automatycznie otwieraj aplikację na urządzeniach mobilnych',
'auto_open_app_mobile_helper' => 'Włącz tę opcję, jeśli chcesz, aby po kliknięciu linku na telefonie automatycznie otwierała się dedykowana aplikacja mobilna zamiast przeglądarki.',
// Link Form Fields
'destination_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',
@@ -24,6 +39,71 @@ return [
'forward_query_params' => 'Przekaż parametry URL',
'forward_query_params_helper' => 'Dołącz parametry wejściowe (np. tagi UTM) do docelowego adresu URL.',
'expires_at' => 'Wygasa dnia',
'activated_at' => 'Aktywny od',
'max_visits' => 'Maksymalny limit wejść',
'max_visits_helper' => 'Opcjonalny limit liczby wejść, po osiągnięciu którego link zostanie automatycznie wyłączony.',
'max_visits_no_limit' => 'Bez limitu',
'max_visits_suffix' => 'wejść',
'number_stepper_decrease' => 'Zmniejsz wartość',
'number_stepper_increase' => 'Zwiększ wartość',
'expiration_redirect_url' => 'URL przekierowania po wygaśnięciu',
'expiration_redirect_url_helper' => 'Adres URL, na który nastąpi przekierowanie, gdy link wygaśnie lub będzie nieaktywny (pozostaw puste, aby wyświetlić błąd 410 Gone).',
'form_section_validity' => 'Ważność i limity',
'use_date_validity' => 'Ustaw limity datowe',
'use_date_validity_helper' => 'Zaplanuj, kiedy link ma się włączyć i kiedy przestać działać.',
'validity_tab_intro' => 'Kontroluj dostępność linku w czasie oraz liczbę dozwolonych kliknięć. Wyłączone opcje oznaczają link bez ograniczeń czasowych i ilościowych.',
'validity_schedule_card_title' => 'Harmonogram dostępności',
'validity_schedule_card_subtitle' => 'Opcjonalna data startu i końca — idealne na kampanie, premiery i oferty limitowane.',
'validity_schedule_empty_title' => 'Dostępny bezterminowo',
'validity_schedule_empty_desc' => 'Włącz przełącznik powyżej, aby ustawić datę aktywacji, wygaśnięcia lub obie.',
'validity_schedule_timeline_label' => 'Okno aktywności',
'validity_activated_at_helper' => 'Pozostaw puste, aby link był aktywny od razu po zapisaniu.',
'validity_expires_at_helper' => 'Opcjonalnie. Po tym momencie link przestaje przekierowywać.',
'validity_single_use_toggle' => 'Włącz tryb jednorazowy',
'validity_single_use_active_note' => 'Tryb jednorazowy jest włączony — link wyłączy się po pierwszej udanej wizycie. Limit wejść nie ma zastosowania.',
'validity_max_visits_locked' => 'Niedostępne przy włączonym trybie jednorazowym',
'tracking_visit_card_title' => 'Analityka wizyt',
'tracking_visit_card_subtitle' => 'Zbieraj statystyki kliknięć dla tego linku — wyłącz dla linków prywatnościowych lub samych przekierowań.',
'tracking_visit_empty_title' => 'Śledzenie wyłączone',
'tracking_visit_empty_desc' => 'Włącz przełącznik powyżej, aby rejestrować wizyty i wybrać zapisywane dane.',
'tracking_fields_identity_title' => 'Tożsamość i źródło',
'tracking_fields_device_title' => 'Urządzenie i przeglądarka',
'track_ip_desc' => 'Adres IP do geolokalizacji i wykrywania nadużyć.',
'track_referer_desc' => 'Adres strony odsyłającej, gdy użytkownik przychodzi z innej witryny.',
'track_browser_desc' => 'Rodzina przeglądarki, np. Chrome, Safari lub Firefox.',
'track_browser_version_desc' => 'Dokładna wersja przeglądarki z user agenta.',
'track_os_desc' => 'System operacyjny, np. iOS, Windows lub Android.',
'track_os_version_desc' => 'Wersja systemu operacyjnego urządzenia.',
'track_device_type_desc' => 'Typ urządzenia — desktop, mobile, tablet lub bot.',
'track_browser_language_desc' => 'Preferowany język z nagłówka Accept-Language.',
'tracking_utm_card_title' => 'Parametry kampanii',
'tracking_utm_card_subtitle' => 'Dołącz tagi UTM do docelowego URL. Zmiany synchronizują się od razu z adresem linku.',
'tracking_ga_card_title' => 'Google Analytics 4',
'tracking_ga_card_subtitle' => 'Opcjonalny identyfikator pomiaru do zdarzeń przekierowań po stronie serwera (GA4 Measurement Protocol).',
'link_destination_card_title' => 'Docelowy adres',
'link_destination_card_subtitle' => 'Kieruj odwiedzających na jeden URL lub dziel ruch między warianty testu A/B.',
'link_short_url_card_title' => 'Krótki link',
'link_short_url_card_subtitle' => 'Wybierz domenę i dostosuj końcówkę adresu skróconego linku.',
'link_behavior_card_title' => 'Zachowanie',
'link_behavior_card_subtitle' => 'Steruj aktywnością linku i sposobem przekazywania parametrów URL.',
'link_status_desc' => 'Po wyłączeniu odwiedzający zobaczą stronę błędu zamiast przekierowania.',
'link_tags_card_title' => 'Tagi',
'link_tags_card_subtitle' => 'Dodaj do pięciu etykiet, aby organizować, filtrować i raportować linki.',
'link_notes_card_title' => 'Notatki wewnętrzne',
'link_notes_card_subtitle' => 'Prywatna notatka w panelu — nigdy niewidoczna dla odwiedzających.',
'link_notes_placeholder' => 'Dodaj przypomnienie dla zespołu…',
'targeting_rules_card_title' => 'Warunkowe przekierowania',
'targeting_rules_card_subtitle' => 'Kieruj odwiedzających na różne URL-e w zależności od urządzenia, kraju, języka lub platformy.',
'targeting_rules_empty_title' => 'Brak reguł targetowania',
'targeting_rules_empty_desc' => 'Dodaj regułę poniżej, aby nadpisać domyślny cel linku, gdy spełnione są określone warunki.',
'add_targeting_rule' => 'Dodaj regułę',
'targeting_rule_conditions_title' => 'Kiedy zastosować',
'targeting_rule_destination_title' => 'Przekieruj na',
'filter_duplicate_error' => 'Każdy typ filtra (Urządzenie, Platforma, Kraj, Język) można dodać tylko raz.',
'app_linking_card_title' => 'Linkowanie aplikacji',
'app_linking_card_subtitle' => 'Otwieraj natywną aplikację mobilną zamiast przeglądarki, gdy wykryto pasującą aplikację.',
'app_linking_empty_title' => 'Autootwieranie aplikacji wyłączone',
'app_linking_empty_desc' => 'Włącz przełącznik powyżej, aby podejrzeć i włączyć zachowanie deep link na urządzeniach mobilnych.',
'notes' => 'Wewnętrzne notatki',
'ga_tracking_id' => 'Identyfikator śledzenia Google Analytics 4',
'ga_tracking_id_helper' => 'Opcjonalny identyfikator G-XXXXXXXXXX do śledzenia przekierowań po stronie serwera.',
@@ -51,6 +131,7 @@ return [
'track_os_version' => 'Śledź wersję systemu operacyjnego',
'track_device_type' => 'Śledź typ urządzenia (desktop/mobile/tablet)',
'track_referer' => 'Śledź referer URL',
'track_browser_language' => 'Śledź język przeglądarki',
// QR Design Fields
'qr_size' => 'Rozmiar kodu QR (px)',
@@ -85,14 +166,35 @@ return [
'qr_label_eye_dot_style' => 'Styl punktu oka',
'qr_label_eye_color' => 'Kolor oka',
'qr_label_preview' => 'Podgląd',
'qr_option_none' => 'Brak',
'qr_option_square' => 'Kwadrat',
'qr_option_dots' => 'Kropki',
'qr_option_dot' => 'Okrągły',
'qr_option_rounded' => 'Zaokrąglony',
'qr_option_classy' => 'Elegancki',
'qr_option_classy_rounded' => 'Zaokrąglony elegancki',
'qr_option_extra_rounded' => 'Bardzo zaokrąglony',
'qr_option_dot' => 'Kropka',
'qr_chart_visits_label' => 'Wizyty',
'qr_label_dots_background' => 'Punkty i tło',
'qr_label_custom_eye_config' => 'Własny styl oczu',
'qr_label_logo_overlay' => 'Nakładka logo',
'qr_label_drag_drop_upload' => 'Przeciągnij i upuść lub kliknij, aby wgrać logo',
'qr_label_upload_supports' => 'Obsługuje PNG, JPG, SVG do 10MB',
'qr_label_drag_drop_replace' => 'Przeciągnij i upuść lub kliknij, aby zastąpić logo',
'qr_label_uploading_logo' => 'Wgrywanie logo...',
'qr_logo_upload_parse_error' => 'Błąd przetwarzania odpowiedzi serwera!',
'qr_logo_upload_error' => 'Błąd podczas wgrywania pliku logo!',
'qr_logo_upload_connection_error' => 'Błąd połączenia podczas wgrywania pliku logo!',
'qr_label_remove_logo' => 'Usuń logo',
'qr_label_logo_shape' => 'Kształt logo',
'qr_label_logo_size' => 'Rozmiar logo',
'qr_label_logo_margin' => 'Margines logo',
'qr_label_clear_dots' => 'Usuń punkty pod logo',
'qr_label_live_preview' => 'Podgląd na żywo',
'qr_option_circle' => 'Koło',
'qr_label_png' => 'PNG',
'qr_label_svg' => 'SVG',
'qr_save_design' => 'Zapisz projekt',
// Table Columns
'col_short_url' => 'Krótki URL',
@@ -108,11 +210,23 @@ return [
'action_qr' => 'Kod QR',
'action_edit' => 'Edytuj URL',
'action_delete' => 'Usuń URL',
'url_key_change_confirmation_heading' => 'Zastąpić link?',
'url_key_change_confirmation' => 'Zmieniłeś krótki klucz (Short Key) tego linku. Zapisanie tych zmian spowoduje, że dotychczasowy krótki URL przestanie działać, a wszelkie udostępnione wcześniej linki z poprzednim kluczem wygasną.',
'action_share' => 'Udostępnij link',
'action_move' => 'Przenieś',
'share_title' => 'Udostępnij Link',
'share_description' => 'Udostępnij ten krótki link przez:',
'share_copy' => 'Skopiuj',
'share_copied' => 'Krótki link skopiowany do schowka!',
'qr_modal_helper' => 'Zeskanuj, skopiuj lub pobierz swój spersonalizowany kod QR.',
'qr_download_svg' => 'Pobierz SVG',
'qr_download_png' => 'Pobierz PNG',
'success_modal_title' => 'Twój link i kod QR są gotowe!',
'success_modal_subtitle' => 'Czas na kliknięcia 🎉',
'success_modal_helper' => 'Skopiuj i udostępnij ręcznie lub wybierz platformę.',
'open_link' => 'Otwórz link',
'close_button' => 'Zamknij',
'dont_show_again' => 'Nie pokazuj opcji udostępniania po utworzeniu linku',
// Stats Page
'stats_title' => 'Statystyki',
@@ -121,6 +235,7 @@ return [
'stats_card_total' => 'Wszystkie wizyty',
'stats_card_unique' => 'Unikalni goście',
'stats_card_today' => 'Dzisiaj',
'stats_card_today_clicks' => 'Kliknięcia dzisiaj',
'stats_card_week' => 'W tym tygodniu',
'stats_card_month' => 'W tym miesiącu',
'stats_chart_title' => 'Wizyty — Ostatnie 30 dni',
@@ -138,6 +253,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',
@@ -161,14 +278,53 @@ return [
'settings_section_ga4' => 'GA4 Measurement Protocol',
'settings_ga4_description' => 'Skonfiguruj serwerowe śledzenie zdarzeń GA4 przez Measurement Protocol. Zdarzenia są wysyłane w tle bez blokowania przekierowania.',
'settings_site_name' => 'Niestandardowa nazwa witryny',
'settings_site_name_helper' => 'Własna nazwa witryny/marki wyświetlana na ekranach przekierowań (monit o hasło, ostrzeżenia, piksele). W przypadku braku wartości, zostanie użyta nazwa z konfiguracji config("app.name").',
'settings_route_prefix' => 'Prefiks trasy',
'settings_route_prefix_helper' => 'Segment URL przed kluczem, np. "/s/abc123". Zmiana wymaga php artisan config:clear.',
'settings_route_prefix_helper' => 'Segment URL przed kluczem, np. "/s/abc123". Pozostaw puste, aby serwować linki bezpośrednio w głównej domenie (np. "domena.com/abc123"). Zmiana wymaga php artisan config:clear.',
'settings_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.',
'settings_cache_ttl' => 'TTL cache przekierowania',
'settings_cache_ttl_helper' => 'Sekundy cachowania rekordów krótkich URL. Ustaw 0 aby wyłączyć (niezalecane na produkcji).',
'settings_queue_connection' => 'Połączenie kolejki',
'settings_queue_connection_helper' => 'Kolejka Laravel do asynchronicznego śledzenia wizyt. Użyj "sync" (synchroniczne), lub "redis"/"sqs" na produkcji.',
'settings_queue_connection_helper' => 'Połączenie kolejki Laravel do asynchronicznego śledzenia wizyt. Każdy sterownik ma inne wymagania — zobacz infobox poniżej po zmianie wartości.',
'settings_redis_test' => 'Testuj połączenie Redis',
'settings_redis_test_ok' => 'Połączenie Redis OK',
'settings_redis_test_fail' => 'Połączenie Redis nieudane',
'settings_queue_worker_test' => 'Testuj workera kolejki',
'settings_queue_worker_test_ok' => 'Worker kolejki OK',
'settings_queue_worker_test_fail' => 'Worker kolejki nie odpowiada',
'settings_section_redis' => 'Połączenie Redis',
'settings_section_redis_description' => 'Te wartości nadpisują database.redis i queue.connections.redis w runtime, gdy Queue Connection = redis. Zapisz ustawienia po zmianie.',
'settings_redis_host' => 'Host Redis',
'settings_redis_host_helper' => 'Host lub IP serwera Redis dla kolejek, liczników, statystyk i live feed.',
'settings_redis_port' => 'Port Redis',
'settings_redis_port_helper' => 'Domyślnie: 6379.',
'settings_redis_password' => 'Hasło Redis',
'settings_redis_password_helper' => 'Pozostaw puste, jeśli Redis nie wymaga hasła. Przechowywane bezpiecznie w ustawieniach.',
'settings_redis_database' => 'Baza Redis (DB index)',
'settings_redis_database_helper' => 'Indeks logicznej bazy Redis (015). Domyślnie: 0.',
'settings_redis_key_prefix' => 'Prefix kluczy',
'settings_redis_key_prefix_helper' => 'Opcjonalny prefix dla kluczy pluginu w Redis. Puste = domyślny z config.',
'settings_queue_worker_command' => '<strong>Wymagany proces w tle (Queue Worker):</strong> Uruchom workera na tym samym połączeniu i nazwie kolejki co powyżej (niezależnie od <code>QUEUE_CONNECTION</code> w <code>.env</code>): <code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">php artisan queue:work :connection --queue=:queue</code>',
'settings_queue_mode_desc_redis' => '<strong>redis</strong> — tryb produkcyjny: async joby, dedykowane liczniki/statystyki/live feed w Redis (phpredis lub Predis), auto buforowanie. Skonfiguruj Redis poniżej (nadpisuje .env w runtime). Użyj przycisków testu przed zapisem.',
'settings_queue_mode_desc_database' => '<strong>database</strong> — joby w tabeli <code>jobs</code>. Bez dedykowanego Redis — chyba że włączysz buforowanie ręcznie.',
'settings_queue_mode_desc_sqs' => '<strong>sqs</strong> — joby przez AWS SQS. Wymaga poprawnych credentiali AWS w <code>config/queue.php</code>.',
'settings_queue_mode_desc_beanstalkd' => '<strong>beanstalkd</strong> — joby przez Beanstalkd. Wymaga działającego serwera beanstalkd.',
'settings_queue_mode_desc_deferred' => '<strong>deferred</strong> — joby po odpowiedzi HTTP w tym samym procesie PHP. Bez workera, ale większe obciążenie po response przy ruchu.',
'settings_queue_mode_desc_background' => '<strong>background</strong> — sterownik background Laravel: joby po response bez workera (podobnie jak deferred).',
'settings_queue_mode_desc_failover' => '<strong>failover</strong> — łańcuch failover z <code>config/queue.php</code>. Upewnij się, że co najmniej jeden backend działa.',
'settings_queue_mode_desc_default' => '<strong>:connection</strong> — async tracking przez to połączenie Laravel. Skonfiguruj je w <code>config/queue.php</code>.',
'settings_queue_mode_info_sync' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="text-sm text-neutral-800 dark:text-neutral-300"><strong>sync</strong> — wizyty zapisywane podczas przekierowania (bez workera). Dev i niski ruch. Opcjonalne ręczne buforowanie liczników poniżej.</div></div>',
'settings_geoip_enabled' => 'Włącz wykrywanie Geo-IP',
'settings_geoip_enabled_helper' => 'Wykrywaj i zapisuj kraj odwiedzającego przy każdej wizycie.',
@@ -177,12 +333,13 @@ return [
'settings_geoip_driver_headers' => 'Nagłówki CDN (najszybsze — Cloudflare, CloudFront)',
'settings_geoip_driver_maxmind' => 'MaxMind lokalna baza (offline, zerowe opóźnienie)',
'settings_geoip_driver_ipapi' => 'ip-api.com (darmowe API, limit 45 req/min)',
'settings_geoip_cache_ttl' => 'TTL cache wyniku Geo-IP',
'settings_geoip_cache_ttl_helper' => 'Sekundy cachowania kraju dla zahashowanego IP. 86400 = 24 godziny.',
'settings_geoip_cache_ttl' => 'Czas życia (TTL) pamięci podręcznej IP',
'settings_geoip_cache_ttl_helper' => 'Czas (w sekundach) przechowywania wyniku wykrywania lokalizacji dla danego IP. Zapobiega to wielokrotnemu odpytywaniu API lub baz danych przy kolejnych kliknięciach tej samej osoby. Domyślnie: 86400 (24 godziny). Maksymalnie: 31536000 (1 rok).',
'settings_geoip_timeout' => 'Timeout API',
'settings_geoip_timeout_helper' => 'Maksymalna liczba sekund oczekiwania na odpowiedź zewnętrznego API Geo-IP.',
'settings_maxmind_path' => 'Ścieżka bazy MaxMind',
'settings_maxmind_path_helper' => 'Bezwzględna ścieżka do pliku GeoLite2-Country.mmdb lub GeoIP2-Country.mmdb. Wymagana gdy sterownik to MaxMind.',
'settings_maxmind_info_callout' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm.75-11.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm0 2.5a.75.75 0 0 1 .75.75v4a.75.75 0 0 1-1.5 0v-4a.75.75 0 0 1 .75-.75Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Jak skonfigurować MaxMind (Offline Geo-IP):</strong><br>1. Zarejestruj darmowe konto na <a href="https://www.maxmind.com" target="_blank" class="underline text-primary-600 dark:text-primary-400">maxmind.com</a>.<br>2. Pobierz bezpłatną bazę danych <strong>GeoLite2 Country</strong> lub <strong>GeoLite2 City</strong> w formacie binarnym (plik z rozszerzeniem <code class="px-1 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">.mmdb</code>).<br>3. Prześlij plik na swój serwer, np. do katalogu <code class="px-1 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">storage/geoip/GeoLite2-Country.mmdb</code>.<br>4. Wpisz pełną ścieżkę bezwzględną poniżej i kliknij przycisk <strong>Zweryfikuj plik</strong> z prawej strony pola.</span></div></div>',
'settings_maxmind_verify' => 'Weryfikuj plik',
'settings_maxmind_verify_ok' => '✅ Plik znaleziony i dostępny',
'settings_maxmind_verify_fail' => '❌ Plik nie istnieje lub jest niedostępny',
@@ -192,18 +349,32 @@ return [
'settings_ga4_api_secret_helper' => 'Wygeneruj w GA4 → Admin → Strumienie danych → twój strumień → Klucze API Measurement Protocol.',
'settings_ga4_firebase_app_id' => 'Firebase App ID (opcjonalne)',
'settings_ga4_firebase_app_id_helper' => 'Wymagane tylko przy śledzeniu strumienia Firebase/aplikacji. Zostaw puste dla standardowych strumieni GA4.',
'settings_ga4_verify' => 'Testuj połączenie',
'settings_ga4_verify_ok' => '✅ Klucz API jest prawidłowy — połączenie z GA4 udane',
'settings_ga4_verify_fail' => '❌ Nieprawidłowy klucz API — GA4 odrzuciło żądanie',
'settings_ga4_verify_empty' => 'Najpierw wprowadź klucz API.',
'settings_ga4_verify_measurement_id' => 'Measurement ID do testu połączenia',
'settings_ga4_verify_measurement_id_helper' => 'Podaj dokładny G-XXXXXXXXXX ze strumienia GA4 powiązanego z kluczem API. Wymagane, chyba że używasz Firebase App ID.',
'settings_ga4_verify_measurement_required' => 'Podaj Measurement ID (G-XXXXXXXXXX) lub Firebase App ID, aby przetestować połączenie.',
'settings_ga4_verify_error' => '⚠️ Błąd połączenia — nie można nawiązać kontaktu z GA4',
'settings_section_buffering' => 'Buforowanie liczników wizyt',
'settings_buffering_enabled' => 'Buforuj kliknięcia w pamięci podręcznej (Cache)',
'settings_buffering_helper' => 'Po włączeniu, zliczenia kliknięć będą buforowane w pamięci podręcznej aplikacji i muszą być okresowo synchronizowane z bazą danych za pomocą komendy "php artisan short-url:sync-counters". Zapobiega to blokowaniu wierszy bazy danych i spadkom wydajności przy masowym ruchu.',
'settings_buffering_redis_auto' => 'Włączane automatycznie przy kolejce Redis. Liczniki linków, statystyki „dziś” i live feed korzystaj z dedykowanego połączenia Redis kolejki (phpredis lub Predis) — niezależnie od CACHE_STORE. Zaplanuj short-url:sync-counters co minutę.',
'settings_buffering_worker_info' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm.75-11.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm0 2.5a.75.75 0 0 1 .75.75v4a.75.75 0 0 1-1.5 0v-4a.75.75 0 0 1 .75-.75Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full [&_kbd]:bg-background-light dark:[&_kbd]:bg-background-dark [_code]:!text-current [_kbd]:!text-current [_a]:!text-current [_a]:border-current [_strong]:!text-current text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Wymagane zadanie Cron (Scheduler):</strong> Włączyłeś buforowanie kliknięć. Musisz dodać do harmonogramu zadań (cron) komendę synchronizującą liczniki z bazą danych:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">* * * * * cd /sciezka-do-twojego-projektu && php artisan schedule:run >> /dev/null 2>&1</code><br>Komenda uruchamiana automatycznie w tle przez harmonogram:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">php artisan short-url:sync-counters</code></span></div></div>',
// CDN Trust Settings
'settings_trust_cdn_headers' => 'Ufaj nagłówkom CDN i proxy',
'settings_trust_cdn_headers_helper' => 'Włącz tę opcję, jeśli Twoja aplikacja działa za CDN (np. Cloudflare, AWS CloudFront) lub reverse proxy. Pozwala to na pobieranie prawdziwego adresu IP klienta i kodu kraju z nagłówków CDN. Uwaga: włączaj tylko wtedy, gdy faktycznie korzystasz z proxy, w przeciwnym razie adresy IP mogą być sfałszowane!',
'settings_trust_cdn_headers_info_callout' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-amber-200 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/20" data-callout-type="warning"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-amber-800 dark:text-amber-300" aria-label="Warning"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" /></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full text-amber-900 dark:text-amber-200" data-component-part="callout-content"><span><strong>Uwaga (Bezpieczeństwo adresów IP):</strong> Włączyłeś ufanie nagłówkom proxy. Włączaj tę opcję <u>wyłącznie</u> wtedy, gdy Twoja strona jest podpięta pod:<br>• <strong>Cloudflare</strong> (odczyt z nagłówka CF-Connecting-IP)<br>• <strong>AWS CloudFront</strong> lub inny system CDN<br>• <strong>Nginx/Apache reverse proxy</strong> przekazujący nagłówki X-Forwarded-For.<br><br><strong>Wyłącz tę opcję</strong>, jeśli Twój serwer łączy się z użytkownikami bezpośrednio. Jeśli pozostawisz ją włączoną bez CDN, złośliwi użytkownicy mogą łatwo sfałszować swój adres IP, wysyłając własny nagłówek.</span></div></div>',
'settings_geoip_headers_warning' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-amber-200 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/20" data-callout-type="warning"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-amber-800 dark:text-amber-300" aria-label="Warning"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" /></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full text-amber-900 dark:text-amber-200" data-component-part="callout-content"><span><strong>Brak zaufania do nagłówków proxy:</strong> Wybrałeś <em>Nagłówki CDN</em> jako sterownik wykrywania lokalizacji, ale opcja <strong>„Ufaj nagłówkom CDN i proxy”</strong> w zakładce <em>Ogólne</em> jest wyłączona. Wykrywanie kraju nie będzie działać, dopóki jej nie włączysz.</span></div></div>',
'settings_geoip_headers_info' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-blue-200 bg-blue-50 dark:border-blue-700 dark:bg-blue-950/20" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-blue-800 dark:text-blue-300" aria-label="Info"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-7-4a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM9 9a.75.75 0 0 0 0 1.5h.253a.25.25 0 0 1 .244.304l-.459 2.066A1.75 1.75 0 0 0 10.747 15H11a.75.75 0 0 0 0-1.5h-.253a.25.25 0 0 1-.244-.304l.459-2.066A1.75 1.75 0 0 0 9.253 9H9Z" clip-rule="evenodd" /></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full text-blue-900 dark:text-blue-200" data-component-part="callout-content"><span><strong>Sterownik Nagłówki CDN:</strong> Zapis ustawień z tym sterownikiem automatycznie włącza opcję <strong>Ufaj nagłówkom CDN i proxy</strong>. Gdy brakuje nagłówków kraju z CDN, wtyczka korzysta z MaxMind, a następnie ip-api.com (z limitem zapytań) zamiast zwracać puste dane geo.</span></div></div>',
// Stats View & Export Localization
'stats_filter_visited_from' => 'Odwiedzono od',
'stats_filter_visited_until' => 'Odwiedzono do',
'stats_filter_counted_in_stats' => 'Tylko wizyty w statystykach',
'stats_action_export' => 'Eksportuj CSV',
'stats_csv_time' => 'Czas',
'stats_csv_ip' => 'Adres IP',
@@ -212,6 +383,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',
@@ -221,8 +401,11 @@ return [
'form_section_options' => 'Opcje',
'form_section_notes' => 'Wewnętrzne notatki',
'form_section_tracking' => 'Śledzenie wizyt',
'form_section_tracking_desc' => 'Włącz lub wyłącz śledzenie wizyt i statystyki dla tego skróconego linku.',
'form_section_tracked_fields' => 'Śledzone pola',
'form_section_tracked_fields_desc' => 'Wybierz, które metryki są zbierane dla tego linku — przydatne pod kątem analityki i prywatności.',
'form_section_analytics' => 'Zewnętrzne systemy analityczne',
'form_section_analytics_desc' => 'Zintegruj ten link z Google Analytics, podając identyfikator strumienia danych.',
// New Dashboard Analytics Keys
'stats_card_top_source' => 'Główne źródło UTM',
@@ -235,16 +418,48 @@ 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
'tab_targeting' => 'Targetowanie i bezpieczeństwo',
'tab_targeting' => 'Targetowanie',
'tab_password' => 'Hasło',
'tab_validity' => 'Wygasanie',
'expiration_dates_section_title' => 'Wygasanie czasowe',
'visit_limits_section_title' => 'Limity wizyt',
'security_section_title' => 'Kontrola bezpieczeństwa',
'password' => 'Hasło dostępu',
'password_helper' => 'Wymagaj od odwiedzających wprowadzenia hasła przed przekierowaniem.',
'password_card_title' => 'Ochrona hasłem',
'password_card_subtitle' => 'Wymagaj od odwiedzających wprowadzenia hasła przed przekierowaniem.',
'warning_page_card_title' => 'Strona ostrzegająca przed przekierowaniem',
'warning_page_card_subtitle' => 'Pokaż ekran pośredni przed przekierowaniem odwiedzających na docelowy adres.',
'warning_page_empty_title' => 'Strona ostrzegająca wyłączona',
'warning_page_empty_desc' => 'Włącz przełącznik powyżej, aby pokazać ekran bezpieczeństwa przed zewnętrznym przekierowaniem.',
'warning_page_active_title' => 'Strona ostrzegająca włączona',
'confirm_password' => 'Potwierdź hasło',
'new_password' => 'Nowe hasło',
'change_password' => 'Zmień hasło',
'remove_password' => 'Usuń hasło',
'password_status_active' => 'Ochrona hasłem jest włączona.',
'password_status_active_desc' => 'Link jest zabezpieczony. Dostęp wymaga podania prawidłowego hasła.',
'password_empty_state_title' => 'Brak zabezpieczeń',
'password_empty_state_desc' => 'Dodaj hasło, aby ograniczyć dostęp do tego skróconego linku tylko dla wybranych osób.',
'password_settings_section' => 'Ustawienia hasła',
'password_change_short' => 'Zmień',
'password_remove_short' => 'Usuń',
'save_password' => 'Zapisz hasło',
'expiration_dates_section_desc' => 'Zarządzaj dostępnością linku w czasie. Możesz zaplanować start kampanii lub jej automatyczne zakończenie.',
'visit_limits_section_desc' => 'Ogranicz maksymalną liczbę kliknięć. Idealne do biletów jednorazowych lub ofert limitowanych.',
'set_password' => 'Ustaw hasło',
'cancel' => 'Anuluj',
'confirm' => 'Potwierdź',
'password_required_error' => 'Hasło jest wymagane.',
'password_mismatch_error' => 'Hasła nie są identyczne.',
'show_warning_page' => 'Pokaż stronę ostrzegającą przed przekierowaniem',
'show_warning_page_helper' => 'Pokaż ekran pośredni ostrzegający o przekierowaniu na zewnętrzny adres (ochrona przed phishingiem/bezpieczeństwo NSFW).',
'targeting_type' => 'Strategia targetowania',
@@ -252,24 +467,52 @@ return [
'targeting_type_device' => 'Przekierowania zależne od urządzenia',
'targeting_type_country' => 'Przekierowania zależne od kraju (Geo-IP)',
'targeting_type_rotation' => 'Podział ruchu A/B (Rotacja)',
'targeting_type_language' => 'Przekierowania zależne od języka przeglądarki',
'device_targeting_rules' => 'Reguły urządzeń',
'country_targeting_rules' => 'Reguły krajów',
'language_targeting_rules' => 'Reguły języków',
'rotation_targeting_rules' => 'Cele rotacji',
'device_mobile' => 'Docelowy URL dla urządzeń mobilnych',
'device_tablet' => 'Docelowy URL dla tabletów',
'device_desktop' => 'Docelowy URL dla komputerów stacjonarnych',
'country_code' => 'Kod kraju',
'language_code' => 'Kod języka',
'rotation_url' => 'Docelowy URL',
'rotation_weight' => 'Waga ruchu',
'rotation_weight_helper' => 'Procent ruchu kierowany na ten URL (np. 50 dla 50%). Wagi są bilansowane proporcjonalnie.',
'rotation_variants' => 'Warianty rotacji A/B',
'rotation_variants_helper' => 'Zdefiniuj adresy URL oraz podział ruchu dla testów split A/B.',
'variant_label' => 'Etykieta wariantu',
'variant_url' => 'Docelowy URL wariantu',
'variant_weight' => 'Udział w ruchu (%)',
'safe_browsing_error' => 'Ten adres URL został oznaczony przez Google Safe Browsing jako niebezpieczny.',
// New Advanced Targeting Builder
'targeting_rules' => 'Reguły targetowania',
'add_filter' => 'Dodaj filtr',
'match' => 'Warunek dopasowania',
'match_or' => 'Dowolny z poniższych filtrów (OR)',
'match_and' => 'Wszystkie poniższe filtry (AND)',
'filter_device' => 'Urządzenie',
'filter_platform' => 'Platforma (System operacyjny)',
'filter_country' => 'Kraj',
'filter_language' => 'Język przeglądarki',
'direct_to_url' => 'Przekieruj na URL',
'select_devices' => 'Wybierz urządzenia',
'select_platforms' => 'Wybierz systemy operacyjne',
'select_countries' => 'Wybierz kraje',
'select_languages' => 'Wybierz języki przeglądarki',
'device_desktop_label' => 'Komputer stacjonarny',
'device_mobile_label' => 'Smartfon',
'device_tablet_label' => 'Tablet',
// New Settings Page Fields
'settings_tab_advanced' => 'Wydajność i bezpieczeństwo',
'settings_section_aggregation' => 'Zarządzanie logami o dużym natężeniu ruchu',
'settings_retention_days' => 'Czas retencji surowych logów',
'settings_retention_days_helper' => 'Wybierz, przez jaki okres czasu chcesz przechowywać szczegółowe logi pojedynczych wizyt przed ich usunięciem.',
'settings_aggregation_enabled' => 'Włącz automatyczną agregację i czyszczenie logów',
'settings_aggregation_enabled_helper' => 'Gdy włączone, wtyczka automatycznie rejestruje zadanie w harmonogramie (codziennie o 02:00), aby zsumować wizyty do statystyk dziennych i usunąć surowe logi starsze niż wybrany okres retencji.',
'settings_aggregation_enabled' => 'Włącz automatyczne usuwanie surowych logów',
'settings_aggregation_enabled_helper' => 'Codzienne zadanie o 02:00 zawsze agreguje wizyty do statystyk dziennych. Ten przełącznik kontroluje wyłącznie usuwanie surowych logów starszych niż wybrany okres retencji.',
'retention_30_days' => '30 dni',
'retention_60_days' => '60 dni',
'retention_90_days' => '90 dni',
@@ -279,11 +522,50 @@ return [
'settings_section_rate_limiting' => 'Ograniczanie częstotliwości żądań / Ochrona przed botami',
'settings_rate_limiting_enabled' => 'Włącz ochronę limitów',
'settings_rate_limiting_enabled_helper' => 'Ograniczaj liczbę przekierowań na adres IP klienta.',
'settings_rate_limiting_route_info' => '<div class="callout my-2 px-4 py-3 rounded-xl border border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30 text-sm text-blue-900 dark:text-blue-200"><strong>Dwa poziomy:</strong> trasy przekierowań mają też domyślny limit middleware pakietu (<code>120/min</code> na IP). Po włączeniu limitera poniżej <em>oba</em> mogą zwrócić HTTP 429 — pierwszy osiągnięty limit wygrywa.</div>',
'settings_rate_limiting_max_attempts' => 'Maksymalna dopuszczalna liczba przekierowań',
'settings_rate_limiting_max_attempts_helper' => 'Maksymalna liczba żądań w oknie wygasania.',
'settings_rate_limiting_decay_seconds' => 'Okno wygasania (sekundy)',
'settings_rate_limiting_decay_seconds_helper' => 'Przedział czasowy dla ograniczenia liczby żądań.',
// Queue Settings Additions
'settings_queue_name' => 'Nazwa kolejki',
'settings_queue_name_helper' => 'Docelowa nazwa kolejki, do której wysyłane są zadania śledzenia i synchronizacji liczników. Domyślnie: "default".',
'settings_queue_worker_info' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm.75-11.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm0 2.5a.75.75 0 0 1 .75.75v4a.75.75 0 0 1-1.5 0v-4a.75.75 0 0 1 .75-.75Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full [&_kbd]:bg-background-light dark:[&_kbd]:bg-background-dark [_code]:!text-current [_kbd]:!text-current [_a]:!text-current [_a]:border-current [_strong]:!text-current text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Wymagany proces w tle (Queue Worker):</strong> Wybrane połączenie działa asynchronicznie. Uruchom workera na tym samym połączeniu i nazwie kolejki co powyżej (niezależnie od <code>QUEUE_CONNECTION</code> w <code>.env</code>):<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">php artisan queue:work :connection --queue=:queue</code></span></div></div>',
'settings_geoip_stats_cache_ttl' => 'Czas życia (TTL) pamięci podręcznej statystyk',
'settings_geoip_stats_cache_ttl_helper' => 'Czas (w sekundach) buforowania obliczeń statystyk linków na pulpicie. Kiedy przeglądasz statystyki, system pobiera dane z cache zamiast każdorazowo przeliczać miliony rekordów w bazie danych. Domyślnie: 300 (5 minut). Maksymalnie: 86400 (24 godziny).',
// Default Tracking Tab
'settings_tab_tracking_defaults' => 'Domyślne śledzenie',
'settings_section_tracking_defaults' => 'Domyślne ustawienia śledzenia wizyt',
'settings_section_tracking_defaults_helper' => 'Ustawienia te określają domyślny status śledzenia dla nowo tworzonych krótkich adresów URL.',
'settings_track_visits_default' => 'Śledź wizyty domyślnie',
'settings_track_ip_default' => 'Domyślne śledzenie adresu IP',
'settings_track_browser_default' => 'Domyślne śledzenie przeglądarki',
'settings_track_browser_version_default' => 'Domyślne śledzenie wersji przeglądarki',
'settings_track_os_default' => 'Domyślne śledzenie systemu operacyjnego',
'settings_track_os_version_default' => 'Domyślne śledzenie wersji systemu operacyjnego',
'settings_track_referer_default' => 'Domyślne śledzenie adresu referera',
'settings_track_device_type_default' => 'Domyślne śledzenie typu urządzenia',
'settings_track_browser_language_default' => 'Domyślne śledzenie języka przeglądarki',
'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',
'settings_section_qr_defaults' => 'Domyślne opcje wyglądu kodów QR',
'settings_section_qr_defaults_helper' => 'Skonfiguruj standardowy styl wyglądu automatycznie ładowany dla nowo generowanych kodów QR.',
'settings_qr_size' => 'Domyślny rozmiar (px)',
'settings_qr_margin' => 'Domyślny margines',
'settings_qr_dot_style' => 'Domyślny styl kropek',
'settings_qr_foreground_color' => 'Domyślny kolor pierwszego planu',
'settings_qr_background_color' => 'Domyślny kolor tła',
'settings_qr_gradient_enabled' => 'Domyślnie włącz gradient',
'settings_qr_gradient_from' => 'Kolor gradientu od',
'settings_qr_gradient_to' => 'Kolor gradientu do',
'settings_qr_gradient_type' => 'Domyślny typ gradientu',
// Views
'password_title' => 'Wymagane hasło',
'password_description' => 'Ten link jest chroniony hasłem. Wprowadź poprawne hasło, aby kontynuować.',
@@ -294,4 +576,406 @@ return [
'warning_description' => 'Opuszczasz ten bezpieczny portal i zostajesz przekierowany na zewnętrzny link docelowy. Upewnij się, że ufasz poniższemu adresowi:',
'warning_btn_continue' => 'Kontynuuj do celu',
'warning_btn_back' => 'Wróć',
'pixel_loading_title' => 'Łączenie...',
'pixel_loading_description' => 'Przygotowujemy połączenie i za chwilę zostaniesz przekierowany.',
// Marketing & API Tab
'tab_marketing' => 'Marketing i API',
'marketing_pixels_title' => 'Piksele retargetingowe (Client-Side)',
'marketing_pixels_desc' => 'Dodaj skrypty śledzące, aby dołączać ciasteczka odwiedzających i budować grupy remarketingowe reklam.',
'marketing_pixels_empty_title' => 'Brak przypisanych pikseli',
'marketing_pixels_empty_desc' => 'Wybierz piksele retargetingowe, które mają uruchamiać skrypty śledzące po kliknięciu w ten link.',
'marketing_pixels_select_placeholder' => 'Wybierz piksele retargetingowe…',
'marketing_webhook_empty_title' => 'Brak skonfigurowanego webhooka',
'marketing_webhook_empty_desc' => 'Dodaj docelowy adres URL, aby otrzymywać powiadomienia HTTP POST w czasie rzeczywistym po każdym kliknięciu.',
'pixel_meta' => 'Meta Pixel ID',
'pixel_google' => 'Google Tag / GA4 ID',
'pixel_linkedin' => 'LinkedIn Partner ID',
'marketing_webhooks_title' => 'Integracja Webhook linku',
'marketing_webhooks_desc' => 'Skonfiguruj niestandardowy adres URL, pod który wysłane zostanie natychmiastowe powiadomienie HTTP POST po kliknięciu.',
'webhook_url' => 'Dedykowany Webhook URL',
'webhook_helper_alert' => 'Po skonfigurowaniu adresu URL webhooka, przy każdym kliknięciu w ten skrócony link wyślemy żądanie HTTP POST w tle w czasie rzeczywistym. Payload zawiera szczegółowe metadane wizyty w JSON (klucz linku, docelowy URL, urządzenie, przeglądarka, system operacyjny, geolokalizacja, referer, parametry UTM i flagę skanowania QR). Adresy IP nie są uwzględniane w payloadzie webhooka.',
'webhook_show_payload' => 'Pokaż przykładowy payload JSON',
'webhook_payload_modal_title' => 'Przykładowy payload webhooka',
'webhook_payload_modal_desc' => 'Przykładowe body JSON dla zdarzenia visited. Rzeczywiste wartości zależą od każdego kliknięcia.',
'webhook_payload_copy' => 'Kopiuj payload do schowka',
// Settings tab additions
'settings_tab_developer' => 'API i Webhooki',
'settings_section_rest_api' => 'Dostęp do REST API',
'settings_api_enabled' => 'Włącz Developer REST API',
'settings_api_enabled_helper' => 'Zezwól zewnętrznym systemom na tworzenie, listowanie i usuwanie krótkich linków przez REST API. Wyłącz, aby zablokować wszystkie endpointy /api/short-url/* odpowiedzią 503.',
'settings_section_global_webhook' => 'Konfiguracja globalnego Webhooka',
'settings_global_webhook_enabled' => 'Włącz globalny webhook',
'settings_global_webhook_enabled_helper' => 'Gdy włączone, wtyczka będzie wysyłać powiadomienia HTTP POST w tle przy zdarzeniach wszystkich linków.',
'settings_global_webhook_url' => 'Globalny Webhook URL',
'settings_global_webhook_url_helper' => 'Adres URL, pod który wysyłane będą w tle powiadomienia o zdarzeniach ze wszystkich linków.',
'settings_webhook_events' => 'Monitorowane zdarzenia Webhooka',
'settings_webhook_events_helper' => 'Wybierz zdarzenia, które powinny wyzwalać wysłanie powiadomienia POST.',
'webhook_event_visited' => 'Odwiedziny linku (Wizyta)',
'webhook_event_created' => 'Utworzenie linku',
'webhook_event_expired' => 'Wygaśnięcie linku (Data)',
'webhook_event_limit_reached' => 'Osiągnięcie limitu kliknięć',
'settings_section_api_keys' => 'Klucze API Dewelopera',
'settings_api_keys_description' => 'Zarządzaj kluczami API do integracji z zewnętrznymi systemami (np. CRM, Zapier, Make). Autoryzacja odbywa się za pomocą nagłówka X-Api-Key.',
'settings_api_keys' => 'Aktywne klucze API',
'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',
'settings_section_security_v2_desc' => 'Skonfiguruj wykrywanie sieci VPN/Proxy oraz walidację adresów przez Google Safe Browsing.',
'settings_section_analytics_security' => 'Analityka i wykrywanie botów',
'settings_section_analytics_security_desc' => 'Opcjonalna deduplikacja kliknięć i zaawansowane ustawienia botów.',
'settings_click_dedup_enabled' => 'Włącz deduplikację kliknięć',
'settings_click_dedup_enabled_helper' => 'Ignoruj powtarzające się kliknięcia z tego samego IP w określonym oknie czasu.',
'settings_click_dedup_hours' => 'Okno deduplikacji (godziny)',
'settings_click_dedup_hours_helper' => 'Jak długo traktować powtarzające się kliknięcia z tego samego IP jako duplikaty.',
'settings_bot_verify_googlebot' => 'Weryfikuj IP Googlebota',
'settings_bot_verify_googlebot_helper' => 'Reverse-DNS + forward IP dla user agentów Googlebota.',
'settings_bot_debug_secret' => 'Sekret debugowania botów',
'settings_bot_debug_secret_helper' => 'Wymagana wartość dla ?bot=1 poza local/testing.',
'outbound_url_blocked' => 'Ten adres URL wychodzący jest niedozwolony.',
'api_key_owner_user_id' => 'ID właściciela',
'api_key_owner_user_id_helper' => 'Wymagane, gdy włączone jest scope linków do użytkownika (domyślnie). Ogranicza klucz do linków tego użytkownika.',
'settings_vpn_detection_enabled' => 'Włącz wykrywanie VPN i Proxy',
'settings_vpn_detection_enabled_helper' => 'Po włączeniu każda wizyta będzie sprawdzana pod kątem korzystania z połączeń VPN, proxy lub sieci Tor.',
'settings_vpn_driver' => 'Sterownik wykrywania',
'settings_vpn_driver_helper' => 'Wybierz darmową usługę IP-API lub platformę VPNAPI.io.',
'settings_vpn_driver_ipapi' => 'IP-API (Darmowy)',
'settings_vpn_driver_vpnapi' => 'VPNAPI.io (Premium / Darmowy)',
'settings_vpnapi_key' => 'Klucz VPNAPI.io',
'settings_vpnapi_key_helper' => 'Twój klucz API pobrany z vpnapi.io (wymagany dla sterownika vpnapi).',
'settings_vpn_block_action' => 'Akcja blokowania VPN',
'settings_vpn_block_action_helper' => 'Wybierz, czy ruch z sieci VPN ma być jedynie flagowany w statystykach, czy aktywnie blokowany stroną 403 Forbidden.',
'settings_vpn_block_social_warning' => '<div class="callout my-2 px-4 py-3 rounded-xl border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30 text-sm text-amber-900 dark:text-amber-200"><strong>Podglądy linków:</strong> tryb blokowania zwraca HTTP 403 dla IP VPN/proxy/hostingu. Znane boty social (Facebook, LinkedIn, Google preview) są <em>nadal wpuszczane</em>, aby OG i cloaking działały. Ludzie na VPN mogą dostać 403.</div>',
'settings_vpn_detection_timeout' => 'Timeout detekcji VPN',
'settings_vpn_detection_timeout_helper' => 'Maksymalny czas oczekiwania (w sekundach) na odpowiedź API VPN/proxy przy przekierowaniu.',
'settings_vpn_detection_cache_ttl' => 'TTL cache detekcji VPN',
'settings_vpn_detection_cache_ttl_helper' => 'Jak długo (w sekundach) cacheować wynik lookupu VPN/proxy dla danego IP.',
'settings_trust_cdn_headers_auto_enabled' => '<div class="callout my-2 px-4 py-3 rounded-xl border border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30 text-sm text-blue-900 dark:text-blue-200">Sterownik <strong>Nagłówki CDN</strong> (zakładka Geo-IP) wymaga ufania nagłówkom proxy. Opcja jest <strong>automatycznie włączana</strong> przy tym sterowniku i przy zapisie ustawień.</div>',
'settings_vpn_block_flag_only' => 'Tylko flaguj w statystykach (zezwól na przekierowanie)',
'settings_vpn_block_block_403' => 'Blokuj ruch (wyświetl błąd 403 Forbidden)',
'settings_safe_browsing_enabled' => 'Włącz weryfikację przez Google Safe Browsing',
'settings_safe_browsing_enabled_helper' => 'Automatycznie sprawdzaj docelowe adresy URL pod kątem phishingu, złośliwego oprogramowania i oszustw.',
'settings_safe_browsing_api_key' => 'Klucz API Google Safe Browsing',
'settings_safe_browsing_api_key_helper' => 'Klucz API Web Developer pobrany z Google Cloud Console.',
'settings_safe_browsing_test' => 'Testuj połączenie API',
'settings_safe_browsing_test_empty' => 'Najpierw wprowadź klucz API.',
'settings_safe_browsing_test_ok' => 'Połączenie z Google Safe Browsing powiodło się (API jest aktywne).',
'settings_safe_browsing_test_fail' => 'Weryfikacja klucza Google Safe Browsing nie powiodła się.',
'settings_safe_browsing_test_error' => 'Błąd połączenia z API.',
// World Map Widget
'world_map_title' => 'Mapa odwiedzin',
'world_map_total_clicks' => 'wszystkich kliknięć',
'world_map_countries' => 'krajów',
'world_map_fewer' => 'Mniej',
'world_map_more' => 'Więcej',
'world_map_top_countries' => 'Najpopularniejsze kraje',
'world_map_no_data' => 'Brak danych geograficznych.',
'world_map_no_data_sub' => 'Włącz wykrywanie Geo-IP, aby zacząć rejestrować lokalizacje odwiedzających.',
'stats_card_qr_scans' => 'Zeskanowania QR',
'stats_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ęć',
'badge_unique' => 'unikalnych',
'badge_qr' => 'QR',
'badge_qr_scans' => 'odczytów QR',
'badge_expires' => 'Wygasa: :date',
'badge_no_expiry' => 'Bez wygasania',
'badge_redirect' => 'Przekierowanie :code',
'pixels_navigation_label' => 'Piksele retargetingowe',
'pixel_resource_title' => 'Piksel retargetingowy',
'pixel_name' => 'Nazwa piksela',
'pixel_type' => 'Dostawca',
'pixel_id_label' => 'ID Piksela / Tagu',
'pixel_status_active' => 'Aktywny',
'settings_tab_deep_linking' => 'Głębokie linkowanie',
'settings_section_deep_linking' => 'Pliki powiązań Universal Links & App Links',
'settings_deep_linking_enabled' => 'Włącz serwowanie plików powiązań',
'settings_deep_linking_enabled_helper' => 'Po włączeniu wtyczka będzie serwować pliki konfiguracyjne AASA (iOS) oraz AssetLinks (Android) z poziomu domeny głównej.',
'settings_aasa_json' => 'apple-app-site-association (iOS)',
'settings_aasa_json_helper' => 'Wprowadź prawidłowy kod JSON dla Apple App Site Association. Nie dołączaj znaczników HTML/JavaScript.',
'settings_assetlinks_json' => 'assetlinks.json (Android)',
'settings_assetlinks_json_helper' => 'Wprowadź prawidłową tablicę JSON dla Android Digital Asset Links.',
'validation_invalid_json' => 'Wprowadzony tekst nie jest poprawnym ciągiem JSON.',
// App Linking Preview
'app_linking_supported_os' => 'Wspierane systemy operacyjne:',
'app_linking_supported_apps' => 'Wspierane aplikacje',
'app_linking_supported_apps_helper' => 'Jeśli nie znajdziesz swojej aplikacji na liście, możesz użyć funkcji Targetowania, aby przekierować użytkownika za pomocą własnego głębokiego linku (Deep Link).',
'app_linking_matched' => 'Dopasowano',
'app_linking_preconfigured_count' => ':count gotowych aplikacji do automatycznego przekierowania.',
'app_linking_redirect_active' => 'Otwiera się bezpośrednio w :app',
'app_linking_auto_open' => 'Autootwieranie',
'app_linking_matched_description' => 'Po kliknięciu na telefonie lub tablecie, ten link otworzy się od razu w dedykowanej aplikacji <strong>:app</strong> zamiast w zwykłej przeglądarce.',
'app_linking_deep_link_scheme' => 'Używany schemat (Deep Link): :scheme',
'app_linking_deep_link_label' => 'Adres deep link',
'app_linking_standard_redirect' => 'Ten link otworzy się w zwykłej przeglądarce internetowej (brak dopasowanej aplikacji mobilnej).',
// App Redirect Page
'app_redirect_title' => 'Otwieranie aplikacji...',
'app_redirect_opening_in' => 'Otwieranie w: :app',
'app_redirect_waiting_text' => 'Przekierowujemy Cię do natywnej aplikacji. Proszę czekać...',
'app_redirect_btn_open' => 'Otwórz aplikację',
'app_redirect_btn_browser' => 'Otwórz w przeglądarce',
'app_redirect_in_app_warning_title' => 'Wykryto przeglądarkę wewnątrz aplikacji',
'app_redirect_in_app_warning_desc' => 'Przekierowania mogą być blokowane wewnątrz Instagrama lub Facebooka. Kliknij ikonę trzech kropek (•••) w prawym górnym rogu i wybierz „Otwórz w Safari” lub „Otwórz w Chrome”, aby uruchomić aplikację.',
// Link Expired Page
'expired_title' => 'Link nieaktywny lub wygasł',
'expired_description' => 'Ten link jest już nieaktywny. Mógł osiągnąć limit kliknięć, minął jego termin ważności lub został wyłączony.',
'expired_btn_home' => 'Przejdź do strony głównej',
'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.',
'custom_domain_not_owned_error' => 'Nie masz uprawnień do użycia tej domeny niestandardowej.',
'resource_not_owned_error' => 'Nie masz uprawnień do użycia tego zasobu.',
'webhook_signing_secret_required' => 'Włączenie globalnego webhooka wymaga ustawienia sekretu podpisu HMAC.',
'webhook_signing_secret' => 'Sekret podpisu webhooka',
'webhook_signing_secret_helper' => 'Wymagany, gdy włączony jest globalny webhook. Żądania wychodzące zawierają HMAC-SHA256 w nagłówku X-ShortUrl-Signature.',
// Stats View & Granularity & PoP Trends
'stats_prev_period' => 'poprzedni okres',
'stats_granularity_hourly' => 'Godzinowo',
'stats_granularity_daily' => 'Dziennie',
'stats_granularity_weekly' => 'Tygodniowo',
'stats_granularity_monthly' => 'Miesięcznie',
// Stats Security Widget
'stats_security_title' => 'Bezpieczeństwo i ruch botów',
'stats_security_real_users' => 'Prawdziwi użytkownicy',
'stats_security_bots' => 'Boty i skanery',
'stats_security_vpn_blocked' => 'Zablokowany ruch VPN/Proxy',
'stats_security_no_vpn_blocks' => 'Brak zablokowanych wejść z VPN',
'stats_security_bot_ratio' => 'Wskaźnik ruchu botów',
'stats_security_human_clicks' => 'kliknięć ludzi',
'stats_security_bot_clicks' => 'kliknięć botów',
'stats_security_proxy_clicks' => 'wykrytych VPN/Proxy',
// Visit Logs Column & Modal translation enhancements
'stats_col_link' => 'Link',
'stats_col_referrer' => 'Źródło (Referer)',
'stats_col_language' => 'Język',
'stats_col_utm_source' => 'Źródło UTM (utm_source)',
'stats_col_utm_medium' => 'Medium UTM (utm_medium)',
'stats_col_utm_campaign' => 'Kampania UTM (utm_campaign)',
'stats_col_utm_term' => 'Słowo kluczowe UTM (utm_term)',
'stats_col_utm_content' => 'Treść UTM (utm_content)',
'stats_col_variant' => 'Wariant A/B',
'stats_col_qr_scan' => 'Skan QR',
'stats_col_bot' => 'Bot',
'stats_col_proxy' => 'Proxy/VPN',
'stats_referer_direct' => '(bezpośrednie)',
'stats_device_desktop' => 'Komputer stacjonarny',
'stats_device_mobile' => 'Smartfon',
'stats_device_tablet' => 'Tablet',
'stats_device_bot' => 'Robot / Bot',
'stats_action_view_details' => 'Szczegóły',
'stats_modal_visit_details' => 'Szczegóły wizyty',
'stats_detail_location' => 'Lokalizacja',
'stats_detail_is_bot' => 'Czy bot?',
'stats_detail_is_proxy' => 'Czy Proxy/VPN?',
'stats_detail_is_qr' => 'Czy skan QR?',
'stats_detail_visited_at' => 'Czas wizyty',
'stats_yes' => 'Tak',
'stats_no' => 'Nie',
'add_url' => 'Dodaj URL',
'traffic_split' => 'Podział ruchu',
'delete_confirmation_desc' => 'Czy na pewno chcesz usunąć ten link? Usunięcie spowoduje bezpowrotne skasowanie wszystkich statystyk i analiz. Tej operacji nie można cofnąć postępuj ostrożnie.',
'delete_verification_label' => 'Aby zweryfikować, wpisz poniżej <strong>:short_url</strong>',
'delete_verification_error' => 'Wpisany adres URL się nie zgadza.',
// Custom Domains
'empty_state_domain_heading' => 'Brak własnych domen',
'empty_state_domain_description' => 'Dodaj własne domeny, aby skrócone linki były serwowane pod Twoją marką (np. links.acme.com) zamiast domyślnej domeny.',
'empty_state_domain_action' => 'Dodaj domenę',
'dns_configuration_guide' => 'Przewodnik konfiguracji DNS',
'dns_for_subdomains' => 'Dla subdomen (CNAME)',
'dns_for_root_domains' => 'Dla domen głównych (Rekord A)',
'copied' => 'Skopiowano!',
'domains_navigation_label' => 'Własne domeny',
'domain_resource_title' => 'Własna domena',
'domain_label' => 'Nazwa domeny',
'domain_status_active' => 'Aktywna',
'domain_status_verified' => 'DNS Zweryfikowany',
'domain_verification_status' => 'Status weryfikacji',
'action_verify_dns' => 'Zweryfikuj DNS',
'dns_verify_success' => 'Rekord DNS zweryfikowany pomyślnie!',
'dns_verify_fail' => 'Weryfikacja DNS nie powiodła się. Sprawdź konfigurację i spróbuj ponownie.',
'dns_instructions_title' => 'Instrukcje DNS',
'default_domain_label' => 'Domyślna domena aplikacji',
'dns_tab_a' => 'Rekord A (rekomendowany)',
'dns_tab_cname' => 'Rekord CNAME',
'dns_instructions_apex' => 'Aby skonfigurować domenę główną (apex) :domain, ustaw następujący rekord A u swojego dostawcy DNS:',
'dns_instructions_subdomain' => 'Aby skonfigurować subdomenę :domain, ustaw następujący rekord CNAME u swojego dostawcy DNS:',
'dns_table_type' => 'Typ',
'dns_table_name' => 'Nazwa',
'dns_table_value' => 'Wartość',
'dns_table_ttl' => 'TTL',
'dns_warning_ttl' => 'Jeśli wartość TTL 86400 nie jest dostępna, wybierz najwyższą dostępną wartość. Propagacja domeny może potrwać do 12 godzin.',
'dns_setup_title' => ':domain - Konfiguracja DNS',
'domain_status_valid' => 'Zweryfikowana',
'domain_status_invalid' => 'Niezweryfikowana',
'domain_no_redirects' => 'Brak przypisanych linków',
'domain_mapped_links' => 'Przypisano :count krótki link|Przypisano :count krótkie linki|Przypisano :count krótkich linków',
'action_delete_domain' => 'Usuń domenę',
'delete_domain_confirmation_desc' => 'Czy na pewno chcesz usunąć tę własną domenę? Ta akcja nie może zostać cofnięta postępuj ostrożnie.',
'short_link_label' => 'Krótki link',
// API Scopes & Rate Limits
'api_key_scope' => 'Uprawnienia (Scope)',
'api_key_scope_helper' => 'Wybierz poziom dostępu dla tego klucza API.',
'api_key_scope_read_write' => 'Odczyt i Zapis (Pełny dostęp)',
'api_key_scope_read_only' => 'Tylko Odczyt (Read-Only)',
'api_key_rate_limit' => 'Limit zapytań',
'api_key_rate_limit_helper' => 'Zdefiniuj maksymalną liczbę zapytań na minutę dopuszczalną dla tego klucza.',
'api_key_rate_limit_unlimited' => 'Bez limitu',
'api_key_rate_limit_rpm' => ':count zapytań / min',
// Folders
'folders_navigation_label' => 'Foldery',
'folder_resource_title' => 'Folder',
'folder_name' => 'Nazwa folderu',
'folder_slug' => 'Uproszczona nazwa (Slug)',
'folder_color' => 'Kolor',
'empty_state_folder_action' => 'Utwórz folder',
'empty_state_folders_heading' => 'Brak folderów',
'empty_state_folders_description' => 'Uporządkuj swoje krótkie linki w folderach.',
'action_edit_folder' => 'Edytuj folder',
'action_delete_folder' => 'Usuń folder',
'link_count_one' => '1 link',
'link_count_many' => ':count linków',
// Tagi
'tags_navigation_label' => 'Tagi',
'tag_resource_title' => 'Tag',
'tag_name' => 'Nazwa tagu',
'tag_slug' => 'Uproszczona nazwa (Slug)',
'tag_color' => 'Kolor',
'empty_state_tag_action' => 'Utwórz tag',
'empty_state_tags_heading' => 'Brak tagów',
'empty_state_tags_description' => 'Oznaczaj swoje krótkie linki tagami, by łatwo je filtrować.',
'action_edit_tag' => 'Edytuj tag',
'action_delete_tag' => 'Usuń tag',
// Archiving
'is_archived' => 'Zarchiwizowany',
'is_archived_helper' => 'Zarchiwizowane linki są ukrywane z głównej listy, ale nadal przekierowują użytkowników.',
'action_archive' => 'Archiwizuj',
'action_restore' => 'Przywróć',
'action_archive_selected' => 'Zarchiwizuj zaznaczone',
'action_restore_selected' => 'Przywróć zaznaczone',
'tab_active_links' => 'Aktywne',
'tab_archived_links' => 'Zarchiwizowane',
// Live Feed
'stats_tab_live_feed' => 'Strumień aktywności',
'stats_live_feed_empty' => 'Brak wizyt. Nowe wizyty pojawią się tutaj w czasie rzeczywistym.',
'stats_live_feed_poll_interval' => 'Na żywo (SSE)',
'stats_live_feed_mode_redis' => 'Na żywo · Redis push',
'stats_live_feed_mode_poll' => 'Na żywo · SSE poll',
'stats_col_visitor' => 'Odwiedzający / Lokalizacja',
// Puste stany - makiety
'empty_state_tags_mock_redirecting' => 'Przekierowywanie...',
'empty_state_tags_mock_promo' => 'promocja',
'empty_state_tags_mock_social' => 'social-media',
'empty_state_tags_mock_dev' => 'programisci',
// Liczba linków w folderze
'folder_one_link' => '1 link',
'folder_many_links' => ':count linków',
// Colors
'color_gray' => 'Szary',
'color_red' => 'Czerwony',
'color_blue' => 'Niebieski',
'color_green' => 'Zielony',
'color_yellow' => 'Żółty',
'color_indigo' => 'Indygo',
'color_purple' => 'Fioletowy',
'color_pink' => 'Różowy',
// SEO & Social
'tab_seo_social' => 'SEO i Social',
'is_cloaked' => 'Maskowanie linku (Cloaking)',
'is_cloaked_helper' => 'Maskuj adres docelowy w pasku adresu przeglądarki za pomocą ramki iframe na domenie krótkiego linku.',
'is_cloaked_iframeable_hint' => 'Niektóre strony docelowe blokują osadzanie w iframe (X-Frame-Options). Przed włączeniem cloakingu użyj testu iframeable.',
'do_index' => 'Indeksowanie w wyszukiwarkach',
'do_index_helper' => 'Zezwól wyszukiwarkom (np. Google) na indeksowanie tego krótkiego linku. W przeciwnym razie będzie on serwowany z tagiem noindex.',
'og_title' => 'Niestandardowy tytuł',
'og_description' => 'Niestandardowy opis',
'og_image' => 'Niestandardowy obrazek',
'og_section_title' => 'Podgląd w mediach społecznościowych (Open Graph)',
'og_section_desc' => 'Dostosuj wygląd tego linku po udostępnieniu na Facebooku, X (Twitterze), LinkedIn, WhatsApp czy Slacku.',
'seo_section_title' => 'Indeksowanie i maskowanie linków',
'seo_section_desc' => 'Zarządzaj widocznością w wyszukiwarkach oraz parametrami maskowania linków docelowych.',
'live_social_preview' => 'Podgląd karty społecznościowej',
'live_qr_preview' => 'Podgląd kodu QR',
'advanced_options_section' => 'Zaawansowane opcje przekierowań i stanu',
'fetching_metadata' => 'Pobieranie podglądu...',
// HTTP / API messages (visitor-facing & REST)
'api_disabled' => 'API deweloperskie jest wyłączone. Włącz je w Ustawieniach skrótów → API i webhooki.',
'api_key_missing' => 'Brak autoryzacji. Nie podano klucza API.',
'api_key_invalid' => 'Brak autoryzacji. Nieprawidłowy lub nieaktywny klucz API.',
'api_key_owner_required' => 'Brak dostępu. Ten klucz API musi mieć owner_user_id, gdy włączone jest scope linków do użytkownika.',
'api_key_read_only' => 'Brak dostępu. Ten klucz API ma uprawnienia tylko do odczytu.',
'api_rate_limit_exceeded' => 'Zbyt wiele żądań. Przekroczono limit klucza API.',
'short_url_not_found' => 'Nie znaleziono skrótu URL.',
'redirect_vpn_blocked' => 'Odmowa dostępu. Wykryto VPN, proxy lub automatyczne pobieranie.',
'redirect_rate_limited' => 'Zbyt wiele żądań. Spróbuj ponownie za :seconds sek.',
'redirect_destination_blocked' => 'Docelowy adres tego linku został zablokowany ze względów bezpieczeństwa.',
'public_stats_rate_limited' => 'Zbyt wiele żądań.',
'public_stats_password_invalid' => 'Wymagane lub nieprawidłowe hasło.',
'public_stats_password_rate_limited' => 'Zbyt wiele nieudanych prób hasła. Spróbuj ponownie później.',
'public_stats_retry_in' => 'Spróbuj ponownie za :seconds s.',
'action_public_stats' => 'Statystyki publiczne',
'public_stats_modal_title' => 'Publiczne statystyki',
'public_stats_modal_description' => 'Udostępnij stronę ze statystykami bez dostępu do panelu administracyjnego.',
'public_stats_save' => 'Zapisz',
'public_stats_enabled_label' => 'Włącz publiczną stronę statystyk',
'public_stats_password_label' => 'Opcjonalne hasło',
'public_stats_password_helper' => 'Pozostaw puste, aby zachować obecne hasło. Wyłącz statystyki, aby je usunąć.',
'public_stats_disabled_hint' => 'Statystyki publiczne są wyłączone. Włącz przełącznik i zapisz, aby wygenerować link.',
'public_stats_link_label' => 'Link do statystyk',
'public_stats_save_to_activate' => 'Zapisz, aby aktywować ten link.',
'public_stats_enabled_status' => 'Statystyki publiczne są włączone.',
'public_stats_copied' => 'Skopiowano link do statystyk.',
'public_stats_open' => 'Otwórz',
'public_stats_saved_enabled' => 'Włączono publiczne statystyki.',
'public_stats_saved_disabled' => 'Wyłączono publiczne statystyki.',
'public_stats_page_title' => 'Statystyki linku',
'public_stats_page_subtitle' => 'Statystyki dla /:key',
'public_stats_date_from' => 'Od',
'public_stats_date_to' => 'Do',
'public_stats_apply_filter' => 'Filtruj',
'public_stats_total_visits' => 'Wszystkie wizyty',
'public_stats_unique_visits' => 'Unikalne wizyty',
'public_stats_today' => 'Dziś',
'public_stats_this_week' => 'Ten tydzień',
'public_stats_this_month' => 'Ten miesiąc',
'public_stats_qr_scans' => 'Skany QR',
'public_stats_visits_by_day' => 'Wizyty wg dnia',
'public_stats_no_data' => 'Brak wizyt w tym okresie.',
'public_stats_day' => 'Dzień',
'public_stats_visits' => 'Wizyty',
'public_stats_password_title' => 'Chronione statystyki',
'public_stats_password_description' => 'Podaj hasło, aby zobaczyć statystyki tego linku.',
'public_stats_password_submit' => 'Pokaż statystyki',
'redirect_html_redirecting' => 'Trwa przekierowanie na stronę docelową...',
'api_created' => 'Skrót URL został utworzony.',
'api_updated' => 'Skrót URL został zaktualizowany.',
'api_bulk_created' => 'Skróty URL zostały utworzone.',
'api_deleted' => 'Skrót URL został usunięty.',
'api_bulk_deleted' => 'Skróty URL zostały usunięte.',
'api_bulk_updated' => 'Skróty URL zostały zaktualizowane.',
];

View File

@@ -0,0 +1,44 @@
<?php
return [
'pl' => 'Polski',
'en' => 'Angielski',
'de' => 'Niemiecki',
'fr' => 'Francuski',
'es' => 'Hiszpański',
'it' => 'Włoski',
'pt' => 'Portugalski',
'ru' => 'Rosyjski',
'zh' => 'Chiński',
'ja' => 'Japoński',
'ar' => 'Arabski',
'nl' => 'Holenderski',
'cs' => 'Czeski',
'sk' => 'Słowacki',
'hu' => 'Węgierski',
'ro' => 'Rumuński',
'bg' => 'Bułgarski',
'hr' => 'Chorwacki',
'sr' => 'Serbski',
'sl' => 'Słoweński',
'uk' => 'Ukraiński',
'tr' => 'Turecki',
'da' => 'Duński',
'sv' => 'Szwedzki',
'no' => 'Norweski',
'fi' => 'Fiński',
'el' => 'Grecki',
'he' => 'Hebrajski',
'hi' => 'Hindi',
'th' => 'Tajski',
'vi' => 'Wietnamski',
'ko' => 'Koreański',
'id' => 'Indonezyjski',
'ms' => 'Malajski',
'et' => 'Estoński',
'lv' => 'Łotewski',
'lt' => 'Litewski',
'is' => 'Islandzki',
'ga' => 'Irlandzki',
'fa' => 'Perski',
];

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 5.5A2.5 2.5 0 0 1 5.5 3h1A1.5 1.5 0 0 1 8 4.5v1A2.5 2.5 0 0 1 5.5 8h-1A1.5 1.5 0 0 1 3 6.5v-1z"/>
<path d="M16 5.5A2.5 2.5 0 0 1 18.5 3h1A1.5 1.5 0 0 1 21 4.5v1A2.5 2.5 0 0 1 18.5 8h-1A1.5 1.5 0 0 1 16 6.5v-1z"/>
<path d="M9.5 12A2.5 2.5 0 0 1 12 9.5h1A1.5 1.5 0 0 1 14.5 11v1A2.5 2.5 0 0 1 12 14.5h-1A1.5 1.5 0 0 1 9.5 13v-1z"/>
<path d="M3 18.5A2.5 2.5 0 0 1 5.5 16h1A1.5 1.5 0 0 1 8 17.5v1A2.5 2.5 0 0 1 5.5 21h-1A1.5 1.5 0 0 1 3 19.5v-1z"/>
<path d="M16 18.5A2.5 2.5 0 0 1 18.5 16h1A1.5 1.5 0 0 1 21 17.5v1A2.5 2.5 0 0 1 18.5 21h-1A1.5 1.5 0 0 1 16 19.5v-1z"/>
</svg>

After

Width:  |  Height:  |  Size: 685 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 5.5C3 4.1 4.1 3 5.5 3H8v2.5C8 6.9 6.9 8 5.5 8H3V5.5z"/>
<path d="M16 5.5C16 4.1 17.1 3 18.5 3H21v2.5C21 6.9 19.9 8 18.5 8H16V5.5z"/>
<path d="M9.5 12C9.5 10.6 10.6 9.5 12 9.5H14.5V12C14.5 13.4 13.4 14.5 12 14.5H9.5V12z"/>
<path d="M3 18.5C3 17.1 4.1 16 5.5 16H8v2.5C8 19.9 6.9 21 5.5 21H3V18.5z"/>
<path d="M16 18.5C16 17.1 17.1 16 18.5 16H21v2.5C21 19.9 19.9 21 18.5 21H16V18.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 503 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<circle cx="5.5" cy="5.5" r="2.5" />
<circle cx="18.5" cy="5.5" r="2.5" />
<circle cx="12" cy="12" r="2.5" />
<circle cx="5.5" cy="18.5" r="2.5" />
<circle cx="18.5" cy="18.5" r="2.5" />
</svg>

After

Width:  |  Height:  |  Size: 295 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<rect x="3" y="3" width="5" height="5" rx="2.2" />
<rect x="16" y="3" width="5" height="5" rx="2.2" />
<rect x="9.5" y="9.5" width="5" height="5" rx="2.2" />
<rect x="3" y="16" width="5" height="5" rx="2.2" />
<rect x="16" y="16" width="5" height="5" rx="2.2" />
</svg>

After

Width:  |  Height:  |  Size: 371 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<rect x="3" y="3" width="5" height="5" rx="1.5" />
<rect x="16" y="3" width="5" height="5" rx="1.5" />
<rect x="9.5" y="9.5" width="5" height="5" rx="1.5" />
<rect x="3" y="16" width="5" height="5" rx="1.5" />
<rect x="16" y="16" width="5" height="5" rx="1.5" />
</svg>

After

Width:  |  Height:  |  Size: 371 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<rect x="3" y="3" width="5" height="5" />
<rect x="16" y="3" width="5" height="5" />
<rect x="9.5" y="9.5" width="5" height="5" />
<rect x="3" y="16" width="5" height="5" />
<rect x="16" y="16" width="5" height="5" />
</svg>

After

Width:  |  Height:  |  Size: 326 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<!-- Light outer border -->
<rect x="3" y="3" width="18" height="18" rx="2" opacity="0.3" />
<!-- Solid inner circle -->
<circle cx="12" cy="12" r="4" fill="currentColor" stroke="none" />
</svg>

After

Width:  |  Height:  |  Size: 323 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<!-- Light outer border -->
<rect x="3" y="3" width="18" height="18" rx="2" opacity="0.3" />
<!-- No inner center (slashed or empty) -->
<line x1="8" y1="16" x2="16" y2="8" opacity="0.6" stroke-width="1.5" />
</svg>

After

Width:  |  Height:  |  Size: 367 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<!-- Light outer border -->
<rect x="3" y="3" width="18" height="18" rx="2" opacity="0.3" />
<!-- Solid inner square -->
<rect x="8.5" y="8.5" width="7" height="7" rx="0.5" fill="currentColor" stroke="none" />
</svg>

After

Width:  |  Height:  |  Size: 345 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<circle cx="12" cy="12" r="9" />
</svg>

After

Width:  |  Height:  |  Size: 158 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<rect x="3" y="3" width="18" height="18" rx="6" />
</svg>

After

Width:  |  Height:  |  Size: 176 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<!-- Slashed border -->
<rect x="3" y="3" width="18" height="18" rx="2" stroke-dasharray="2 2" opacity="0.4" />
<line x1="3" y1="21" x2="21" y2="3" opacity="0.6" stroke-width="1.5" />
</svg>

After

Width:  |  Height:  |  Size: 338 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<rect x="3" y="3" width="18" height="18" rx="0.5" />
</svg>

After

Width:  |  Height:  |  Size: 178 B

View File

@@ -0,0 +1,111 @@
@php
$matchedAppId = \Bjanczak\FilamentShortUrl\Services\AppLinkingEngine::matchApp($destinationUrl);
$apps = \Bjanczak\FilamentShortUrl\Services\AppLinkingEngine::getSupportedApps();
$appCount = count($apps);
@endphp
<div class="app-linking-preview">
@if ($matchedAppId && isset($apps[$matchedAppId]))
@php
$matchedApp = $apps[$matchedAppId];
$matchedDomain = explode('/', $matchedApp['domains'][0])[0];
$matchedFavicon = "https://icons.duckduckgo.com/ip2/{$matchedDomain}.ico";
$deepLink = \Bjanczak\FilamentShortUrl\Services\AppLinkingEngine::convertToScheme($destinationUrl, $matchedAppId);
@endphp
<div class="app-linking-status app-linking-status--matched">
<div class="app-linking-status-main">
<div class="app-linking-status-icon app-linking-status-icon--matched">
<img
src="{{ $matchedFavicon }}"
alt="{{ $matchedApp['name'] }}"
loading="lazy"
onerror="this.src='https://icons.duckduckgo.com/ip2/google.com.ico'"
>
</div>
<div class="app-linking-status-copy">
<div class="app-linking-status-title-row">
<p class="app-linking-status-title">{{ $matchedApp['name'] }}</p>
<span class="app-linking-status-badge app-linking-status-badge--success">
{{ __('filament-short-url::default.app_linking_auto_open') }}
</span>
</div>
<p class="app-linking-status-desc">
{!! __('filament-short-url::default.app_linking_matched_description', ['app' => e($matchedApp['name'])]) !!}
</p>
</div>
</div>
<div class="app-linking-deep-link">
<span class="app-linking-deep-link-label">{{ __('filament-short-url::default.app_linking_deep_link_label') }}</span>
<code class="app-linking-deep-link-value">{{ $deepLink }}</code>
</div>
</div>
@else
<div class="app-linking-status app-linking-status--browser">
<div class="app-linking-status-icon app-linking-status-icon--browser">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5a17.92 17.92 0 0 1-8.716-2.247m0 0A8.966 8.966 0 0 1 3 12c0-1.264.26-2.467.732-3.553" />
</svg>
</div>
<div class="app-linking-status-copy">
<p class="app-linking-status-title">{{ __('filament-short-url::default.app_linking_standard_redirect') }}</p>
</div>
</div>
@endif
<div class="app-linking-catalog">
<div class="app-linking-catalog-header">
<div class="app-linking-catalog-heading">
<p class="app-linking-catalog-title">{{ __('filament-short-url::default.app_linking_supported_apps') }}</p>
<p class="app-linking-catalog-subtitle">
{{ __('filament-short-url::default.app_linking_preconfigured_count', ['count' => $appCount]) }}
</p>
</div>
<div class="app-linking-os-badges" aria-label="{{ __('filament-short-url::default.app_linking_supported_os') }}">
<span class="app-linking-os-badge">iOS</span>
<span class="app-linking-os-badge">Android</span>
</div>
</div>
<div class="app-linking-grid">
@foreach ($apps as $appId => $app)
@php
$isMatched = ($appId === $matchedAppId);
$appDomain = explode('/', $app['domains'][0])[0];
$appFavicon = "https://icons.duckduckgo.com/ip2/{$appDomain}.ico";
@endphp
<div @class([
'app-linking-app',
'app-linking-app--matched' => $isMatched,
])>
<div class="app-linking-app-icon-wrap">
<img
src="{{ $appFavicon }}"
alt=""
class="app-linking-app-icon"
loading="lazy"
onerror="this.src='https://icons.duckduckgo.com/ip2/google.com.ico'"
>
@if ($isMatched)
<span class="app-linking-app-check" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z" clip-rule="evenodd" />
</svg>
</span>
@endif
</div>
<span class="app-linking-app-name">{{ $app['name'] }}</span>
</div>
@endforeach
</div>
<p class="app-linking-footnote">
{{ __('filament-short-url::default.app_linking_supported_apps_helper') }}
</p>
</div>
</div>

View File

@@ -0,0 +1,274 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="h-full">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ __('filament-short-url::default.app_redirect_title') }}</title>
<!-- Premium Google Fonts: Bricolage Grotesque -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,200..800&display=swap" rel="stylesheet">
<!-- Self-contained Tailwind CSS CDN for maximum plug-and-play reliability -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
fontFamily: {
sans: ['Bricolage Grotesque', 'sans-serif'],
},
}
}
}
// Detect system dark mode preferences
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
</script>
@php
$pixelMetaIds = $pixels->where('type', 'meta')->pluck('pixel_id');
$pixelGoogleIds = $pixels->where('type', 'google')->pluck('pixel_id');
$pixelLinkedinIds = $pixels->where('type', 'linkedin')->pluck('pixel_id');
$pixelTiktokIds = $pixels->where('type', 'tiktok')->pluck('pixel_id');
$pixelPinterestIds = $pixels->where('type', 'pinterest')->pluck('pixel_id');
@endphp
<!-- Meta / Facebook Pixel -->
@if($pixelMetaIds->isNotEmpty())
<script>
!function(f,b,e,v,n,t,s)
{if(f.fbq)return;n=f.fbq=function(){n.callMethod?
n.callMethod.apply(n,arguments):n.queue.push(arguments)};
if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
n.queue=[];t=b.createElement(e);t.async=!0;
t.src=v;s=b.getElementsByTagName(e)[0];
s.parentNode.insertBefore(t,s)}(window, document,'script',
'https://connect.facebook.net/en_US/fbevents.js');
@foreach($pixelMetaIds as $id)
fbq('init', '{{ $id }}');
fbq('track', 'PageView');
@endforeach
</script>
@foreach($pixelMetaIds as $id)
<noscript><img height="1" width="1" style="display:none" src="https://www.facebook.com/tr?id={{ $id }}&ev=PageView&noscript=1" /></noscript>
@endforeach
@endif
<!-- Google Analytics / GTM -->
@if($pixelGoogleIds->isNotEmpty())
@php $firstGoogleId = $pixelGoogleIds->first(); @endphp
<script async src="https://www.googletagmanager.com/gtag/js?id={{ $firstGoogleId }}"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
@foreach($pixelGoogleIds as $id)
gtag('config', '{{ $id }}');
@endforeach
</script>
@endif
<!-- LinkedIn Insight -->
@if($pixelLinkedinIds->isNotEmpty())
<script type="text/javascript">
window._linkedin_data_partner_ids = window._linkedin_data_partner_ids || [];
@foreach($pixelLinkedinIds as $id)
window._linkedin_data_partner_ids.push("{{ $id }}");
@endforeach
(function(l) {
if (!l){window.lintrk = function(a,b){window.lintrk.q.push([a,b])};
window.lintrk.q=[]}
var s = document.getElementsByTagName("script")[0];
var b = document.createElement("script");
b.type = "text/javascript";b.async = true;
b.src = "https://snap.licdn.com/li.lms-analytics/insight.min.js";
s.parentNode.insertBefore(b, s);})(window.lintrk);
</script>
@foreach($pixelLinkedinIds as $id)
<noscript><img height="1" width="1" style="display:none;" alt="" src="https://px.ads.linkedin.com/collect/?pid={{ $id }}&fmt=gif" /></noscript>
@endforeach
@endif
<!-- TikTok Pixel -->
@if($pixelTiktokIds->isNotEmpty())
<script>
!function (w, d, t) {
w.TiktokAnalyticsObject=t;var ttq=w[t]=w[t]||[];ttq.methods=["page","track","identify","instances","debug","on","off","once","ready","alias","group","enableCookie","disableCookie","holdConsent","revokeConsent","grantConsent"],ttq.setAndDefer=function(t,e){t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}};for(var i=0;i<ttq.methods.length;i++)ttq.setAndDefer(ttq,ttq.methods[i]);ttq.instance=function(t){for(var e=ttq._i[t]||[],n=0;n<ttq.methods.length;n++)ttq.setAndDefer(e,ttq.methods[n]);return e},ttq.load=function(e,n){var r="https://analytics.tiktok.com/i18n/pixel/events.js",o=n&&n.mixpanel;ttq._i=ttq._i||{},ttq._i[e]=[],ttq._i[e]._u=r,ttq._t=ttq._t||{},ttq._t[e]=+new Date,ttq._o=ttq._o||{},ttq._o[e]=o||{};var a=d.createElement("script");a.type="text/javascript",a.async=!0,a.src=r+"?sdkid="+e+"&lib="+t;var c=d.getElementsByTagName("script")[0];c.parentNode.insertBefore(a,c)};
@foreach($pixelTiktokIds as $id)
ttq.load('{{ $id }}');
ttq.page();
@endforeach
}(window, document, 'ttq');
</script>
@endif
<!-- Pinterest Tag -->
@if($pixelPinterestIds->isNotEmpty())
<script>
!function(e,n,t,r,a,s,o){e[r]||(e[r]=function(){(e[r].q=e[r].q||[]).push(arguments)},e[r].q=e[r].q||[],s=n.createElement(t),s.async=!0,s.src="https://s.pntrac.com/tag.js",o=n.getElementsByTagName(t)[0],o.parentNode.insertBefore(s,o))}(window,document,"script","pintrk");
@foreach($pixelPinterestIds as $id)
pintrk('load', '{{ $id }}');
pintrk('page');
@endforeach
</script>
@foreach($pixelPinterestIds as $id)
<noscript><img height="1" width="1" style="display:none;" alt="" src="https://ct.pinterest.com/v3/?event=init&tid={{ $id }}&noscript=1" /></noscript>
@endforeach
@endif
</head>
<body class="bg-[#FCFCFC] dark:bg-[#0C0C0C] min-h-screen flex flex-col justify-between items-center py-10 px-6 font-sans antialiased">
@php
$siteName = config('filament-short-url.site_name') ?: config('app.name', 'Laravel');
// Retrieve app metadata for premium visual hints
$apps = \Bjanczak\FilamentShortUrl\Services\AppLinkingEngine::getSupportedApps();
$matchedApp = $apps[$appId] ?? null;
$appColor = $matchedApp['color'] ?? '#3b82f6';
$appName = $matchedApp['name'] ?? 'App';
@endphp
{{-- Main Container --}}
<div class="w-full max-w-[360px] flex flex-col items-center gap-6 my-auto">
<div class="flex flex-col items-center text-center pb-2 select-none w-full">
<span class="text-2xl font-extrabold tracking-tight text-neutral-900 dark:text-white mb-6 select-none">{{ $siteName }}</span>
<!-- Premium Dynamic Loader with App Theme Highlight -->
<div class="relative flex items-center justify-center w-20 h-20 mb-6">
<!-- Outer Pulse ring -->
<div class="absolute inset-0 rounded-full opacity-20 animate-ping" style="background-color: {{ $appColor }};"></div>
<!-- Inner Spinning Border -->
<div class="absolute inset-0 rounded-full border-4 border-neutral-100 dark:border-neutral-900"></div>
<div class="absolute inset-0 rounded-full border-4 border-transparent border-t-current animate-spin" style="color: {{ $appColor }};"></div>
<!-- Center App Accent -->
<div class="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold shadow-md" style="background-color: {{ $appColor }};">
{{ substr($appName, 0, 1) }}
</div>
</div>
<p class="text-xl font-bold text-neutral-900 dark:text-white mt-2">
{{ __('filament-short-url::default.app_redirect_opening_in', ['app' => $appName]) }}
</p>
<p class="text-sm text-neutral-400 dark:text-neutral-500 mt-2 max-w-[280px]">
{{ __('filament-short-url::default.app_redirect_waiting_text') }}
</p>
</div>
<div class="w-full flex flex-col gap-4">
{{-- Action Buttons --}}
<div class="flex flex-col gap-3 w-full mt-2">
<!-- Manual trigger if redirect didn't catch -->
<a href="{{ $deepLink }}"
class="w-full py-3.5 rounded-xl text-white font-semibold text-sm transition duration-200 shadow-md flex justify-center items-center gap-2 text-center"
style="background-color: {{ $appColor }}; filter: brightness(0.95);"
onmouseover="this.style.filter='brightness(1.05)';"
onmouseout="this.style.filter='brightness(0.95)';"
id="deep-link-btn">
<span>{{ __('filament-short-url::default.app_redirect_btn_open') }}</span>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
</a>
<!-- Fallback Button -->
<a href="{{ $destination }}"
class="w-full py-3 rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 hover:bg-neutral-50 dark:hover:bg-neutral-800 text-neutral-500 dark:text-neutral-400 font-semibold text-sm transition duration-200 flex justify-center items-center gap-2 shadow-sm"
id="fallback-btn">
<span>{{ __('filament-short-url::default.app_redirect_btn_browser') }}</span>
</a>
</div>
{{-- In-App Browser Alert Banner (Hidden by default, shown via JS) --}}
<div id="in-app-alert" class="hidden mt-4 p-3.5 rounded-xl border border-amber-200 bg-amber-50 dark:border-amber-950/20 dark:bg-amber-950/10 text-amber-800 dark:text-amber-400 text-xs text-center flex flex-col gap-1 select-none">
<p class="font-bold flex items-center justify-center gap-1.5 text-amber-900 dark:text-amber-300">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span>{{ __('filament-short-url::default.app_redirect_in_app_warning_title') }}</span>
</p>
<p class="text-[11px] leading-relaxed opacity-95">
{{ __('filament-short-url::default.app_redirect_in_app_warning_desc') }}
</p>
</div>
</div>
</div>
{{-- Footer section --}}
<div class="flex flex-col items-center gap-2 mt-auto select-none">
<span class="text-xs font-medium text-neutral-400 dark:text-neutral-600">© {{ date('Y') }} {{ $siteName }}. All rights reserved.</span>
</div>
<!-- Automatic JS Deep Linking Engine -->
<script>
(function() {
var deepLink = @json($deepLink);
var fallbackUrl = @json($destination);
var hasPixels = @json(!empty($pixels) && $pixels->isNotEmpty());
// Detect in-app webviews
var ua = navigator.userAgent || navigator.vendor || window.opera;
var isInstagram = ua.indexOf('Instagram') > -1;
var isFacebook = (ua.indexOf('FBAN') > -1) || (ua.indexOf('FBAV') > -1);
var isMessenger = (ua.indexOf('Messenger') > -1) || (ua.indexOf('FB_IAB') > -1);
if (isInstagram || isFacebook || isMessenger) {
var alertBox = document.getElementById('in-app-alert');
if (alertBox) {
alertBox.classList.remove('hidden');
}
}
var fallbackTimeout = null;
function launchApp() {
// Try opening native app scheme
window.location.replace(deepLink);
// Trigger fallback if not installed/unsupported after 1.8 seconds
fallbackTimeout = setTimeout(function() {
window.location.replace(fallbackUrl);
}, 1800);
// Cancel fallback if user switches context (app successfully opened)
window.addEventListener('pagehide', function() {
if (fallbackTimeout) clearTimeout(fallbackTimeout);
});
window.addEventListener('visibilitychange', function() {
if (document.hidden && fallbackTimeout) {
clearTimeout(fallbackTimeout);
}
});
window.addEventListener('blur', function() {
if (fallbackTimeout) {
clearTimeout(fallbackTimeout);
}
});
}
// Also clear timeout if user clicks manual trigger button
var deepLinkBtn = document.getElementById('deep-link-btn');
if (deepLinkBtn) {
deepLinkBtn.addEventListener('click', function() {
if (fallbackTimeout) {
clearTimeout(fallbackTimeout);
}
});
}
if (hasPixels) {
// Wait 250ms for pixels to fire before triggering redirect
setTimeout(launchApp, 250);
} else {
launchApp();
}
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,90 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="h-full">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ __('filament-short-url::default.expired_title') }}</title>
<!-- Premium Google Fonts: Bricolage Grotesque -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,200..800&display=swap" rel="stylesheet">
<!-- Self-contained Tailwind CSS CDN for maximum plug-and-play reliability -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
fontFamily: {
sans: ['Bricolage Grotesque', 'sans-serif'],
},
}
}
}
// Detect system dark mode preferences
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
</script>
</head>
<body class="bg-[#FCFCFC] dark:bg-[#0C0C0C] min-h-screen flex flex-col justify-between items-center py-10 px-6 font-sans antialiased">
@php
$siteName = config('filament-short-url.site_name') ?: config('app.name', 'Laravel');
@endphp
{{-- Main Expiry Box --}}
<div class="w-full max-w-[360px] flex flex-col items-center gap-6 my-auto">
<div class="flex flex-col items-center text-center pb-2 select-none w-full">
<span class="text-3xl font-extrabold tracking-tight text-neutral-900 dark:text-white mb-3">{{ $siteName }}</span>
<div class="w-16 h-16 rounded-full bg-amber-500/10 dark:bg-amber-500/20 flex items-center justify-center mt-4 mb-2 text-amber-500">
<svg class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<p class="text-xl font-medium text-neutral-900 dark:text-white mt-2">
{{ __('filament-short-url::default.expired_title') }}
</p>
<p class="text-sm text-neutral-400 dark:text-neutral-500 mt-1">
{{ __('filament-short-url::default.expired_description') }}
</p>
</div>
<div class="w-full flex flex-col gap-4">
{{-- Short URL info box --}}
@if ($shortUrl)
<div class="relative flex items-center justify-center px-3 py-3.5 w-full rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 transition duration-200">
<div class="flex flex-col justify-center text-center min-w-0">
<span class="text-[10px] font-bold text-neutral-400 dark:text-neutral-500 uppercase tracking-wide select-none">
Expired Link
</span>
<p class="break-all text-xs font-mono font-semibold text-rose-600 dark:text-rose-400 mt-1 select-all">
{{ $shortUrl->getShortUrl() }}
</p>
</div>
</div>
@endif
{{-- Action Button --}}
<a href="{{ url('/') }}" class="w-full mt-2 py-3.5 rounded-xl bg-neutral-900 hover:bg-neutral-800 dark:bg-white dark:hover:bg-neutral-200 dark:text-neutral-900 text-white font-semibold text-sm transition duration-200 flex justify-center items-center gap-2 text-center">
<span>{{ __('filament-short-url::default.expired_btn_home') }}</span>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
</a>
</div>
</div>
{{-- Footer section --}}
<div class="flex flex-col items-center gap-2 mt-auto select-none">
<span class="text-xs font-medium text-neutral-400 dark:text-neutral-600">© {{ date('Y') }} {{ $siteName }} Inc. All rights reserved.</span>
</div>
</body>
</html>

View File

@@ -0,0 +1,39 @@
<x-dynamic-component
:component="$getFieldWrapperView()"
:field="$field"
>
@php
$targetName = str_replace('_presets', '', $field->getName());
$targetStatePath = str_replace($field->getName(), $targetName, $getStatePath());
@endphp
<div
x-data="{
state: $wire.{{ $applyStateBindingModifiers("\$entangle('{$targetStatePath}')") }},
presets: ['#000000', '#dc2626', '#ea580c', '#ec4899', '#f59e0b', '#16a34a', '#2563eb', '#7c3aed'],
get activePreset() {
if (!this.state) return null;
const normalizedState = this.state.toLowerCase();
return this.presets.find(p => p.toLowerCase() === normalizedState) || null;
}
}"
class="flex items-center gap-2"
>
<template x-for="color in presets" :key="color">
<button type="button"
x-on:click="state = color"
:style="{ backgroundColor: color }"
class="w-7 h-7 rounded-full border border-neutral-200 dark:border-neutral-800 shadow-sm flex items-center justify-center cursor-pointer transition-all hover:scale-105 relative focus:outline-none"
:class="activePreset === color ? 'ring-2 ring-neutral-950 dark:ring-white ring-offset-2 dark:ring-offset-neutral-900' : ''"
:title="color"
>
<!-- Checkmark icon visible when selected -->
<template x-if="activePreset === color">
<svg class="w-3.5 h-3.5" :class="color.toLowerCase() === '#f59e0b' ? 'text-black' : 'text-white'" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="4">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</template>
</button>
</template>
</div>
</x-dynamic-component>

View File

@@ -0,0 +1,47 @@
<x-dynamic-component
:component="$getFieldWrapperView()"
:field="$field"
>
<div
x-data="{
state: $wire.{{ $applyStateBindingModifiers("\$entangle('{$getStatePath()}')") }},
presets: ['#000000', '#dc2626', '#ea580c', '#ec4899', '#f59e0b', '#16a34a', '#2563eb', '#7c3aed'],
get activePreset() {
if (!this.state) return null;
const normalizedState = this.state.toLowerCase();
return this.presets.find(p => p.toLowerCase() === normalizedState) || null;
}
}"
class="flex flex-row items-center gap-4 mt-2"
>
<!-- Custom Color Input Box -->
<div class="relative flex items-center border border-neutral-300 dark:border-neutral-700 rounded-lg overflow-hidden bg-white dark:bg-neutral-900 shadow-sm" style="width: 145px; height: 36px;">
<!-- Color block (left) -->
<div :style="{ backgroundColor: state || '#000000' }" class="w-10 h-full cursor-pointer relative flex-shrink-0 border-r border-neutral-300 dark:border-neutral-700">
<input type="color" x-model="state" class="absolute inset-0 opacity-0 w-full h-full cursor-pointer" />
</div>
<!-- Hex text input (right) -->
<input type="text" x-model="state" placeholder="#000000" class="w-full h-full px-2 text-xs bg-transparent border-none outline-none focus:ring-0 text-neutral-800 dark:text-neutral-200 text-center font-mono" />
</div>
<!-- Presets Row -->
<div class="flex items-center gap-2">
<template x-for="color in presets" :key="color">
<button type="button"
x-on:click="state = color"
:style="{ backgroundColor: color }"
class="w-6.5 h-6.5 rounded-full border border-neutral-200 dark:border-neutral-800 shadow-sm flex items-center justify-center cursor-pointer transition-all hover:scale-105 relative focus:outline-none"
:class="activePreset === color ? 'ring-2 ring-neutral-950 dark:ring-white ring-offset-2 dark:ring-offset-neutral-900' : ''"
:title="color"
>
<!-- Checkmark icon visible when selected -->
<template x-if="activePreset === color">
<svg class="w-3 h-3" :class="color.toLowerCase() === '#f59e0b' ? 'text-black' : 'text-white'" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="4">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</template>
</button>
</template>
</div>
</div>
</x-dynamic-component>

View File

@@ -0,0 +1,216 @@
@php
$statePath = $getStatePath();
$minValue = $getMinValue();
$maxValue = $getMaxValue();
$step = $getStep();
$isInteger = $isInteger();
$isNullable = $isNullable();
$isDisabled = $isDisabled();
$variant = $getVariant();
$size = $getSize();
$displaySuffix = $getDisplaySuffix();
$nullLabel = $getNullLabel() ?? '—';
$containerClasses = match ($size) {
'sm' => 'h-8 gap-0.5 p-0.5',
'lg' => 'h-12 gap-1.5 p-1.5',
default => 'h-10 gap-1 p-1',
};
$buttonSizeClasses = match ($size) {
'sm' => 'size-7',
'lg' => 'size-10',
default => 'size-8',
};
$valueClasses = match ($size) {
'sm' => 'min-w-14 px-1 text-xs',
'lg' => 'min-w-20 px-2 text-base',
default => 'min-w-16 px-1.5 text-sm',
};
$iconClasses = match ($size) {
'sm' => 'size-3',
'lg' => 'size-5',
default => 'size-4',
};
$buttonVariantClasses = match ($variant) {
'secondary' => 'bg-primary-50 text-primary-600 shadow-sm hover:bg-primary-100 disabled:opacity-40 dark:bg-primary-500/10 dark:text-primary-400 dark:hover:bg-primary-500/20',
'tertiary' => 'bg-gray-200/80 text-gray-700 shadow-sm hover:bg-gray-300/80 disabled:opacity-40 dark:bg-white/10 dark:text-gray-200 dark:hover:bg-white/15',
'outline' => 'border border-gray-300 bg-white text-gray-700 shadow-sm hover:bg-gray-50 disabled:opacity-40 dark:border-white/20 dark:bg-transparent dark:text-gray-200 dark:hover:bg-white/5',
default => 'bg-primary-600 text-white shadow-sm hover:bg-primary-500 disabled:opacity-40 dark:bg-primary-500 dark:hover:bg-primary-400',
};
@endphp
<x-dynamic-component
:component="$getFieldWrapperView()"
:field="$field"
>
<div
x-data="{
state: $wire.{{ $applyStateBindingModifiers("\$entangle('{$statePath}')") }},
min: @js($minValue),
max: @js($maxValue),
step: @js($step),
integer: @js($isInteger),
nullable: @js($isNullable),
disabled: @js($isDisabled),
nullLabel: @js($nullLabel),
suffix: @js($displaySuffix),
normalize(value) {
if (value === null || value === '') {
return null;
}
const numeric = Number(value);
if (Number.isNaN(numeric)) {
return null;
}
return this.integer ? Math.round(numeric) : numeric;
},
get numericState() {
return this.normalize(this.state);
},
get hasValue() {
return this.numericState !== null;
},
get displayValue() {
if (! this.hasValue) {
return this.nullLabel;
}
return String(this.numericState);
},
get canDecrement() {
if (this.disabled) {
return false;
}
if (! this.hasValue) {
return false;
}
if (this.min === null) {
return this.nullable || this.numericState > 0;
}
if (this.nullable && this.numericState <= this.min) {
return true;
}
return this.numericState > this.min;
},
get canIncrement() {
if (this.disabled) {
return false;
}
if (! this.hasValue) {
return true;
}
if (this.max === null) {
return true;
}
return this.numericState < this.max;
},
decrement() {
if (! this.canDecrement) {
return;
}
if (! this.hasValue) {
return;
}
if (this.nullable && this.min !== null && this.numericState <= this.min) {
this.state = null;
return;
}
let next = this.numericState - this.step;
if (this.min !== null) {
next = Math.max(next, this.min);
}
this.state = this.integer ? Math.round(next) : next;
},
increment() {
if (! this.canIncrement) {
return;
}
let next = this.hasValue
? this.numericState + this.step
: (this.min ?? this.step);
if (this.max !== null) {
next = Math.min(next, this.max);
}
this.state = this.integer ? Math.round(next) : next;
},
}"
@class([
'fsu-number-stepper inline-flex w-fit max-w-max shrink-0 justify-self-start items-center rounded-full bg-gray-100 dark:bg-white/10',
$containerClasses,
'opacity-60' => $isDisabled,
])
role="group"
aria-label="{{ $getLabel() }}"
>
<button
type="button"
@class([
'inline-flex shrink-0 items-center justify-center rounded-full transition duration-75 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40 disabled:cursor-not-allowed',
$buttonSizeClasses,
$buttonVariantClasses,
])
x-on:click="decrement()"
x-bind:disabled="! canDecrement"
x-bind:aria-disabled="! canDecrement"
aria-label="{{ __('filament-short-url::default.number_stepper_decrease') }}"
>
<svg @class([$iconClasses]) viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M4 10a.75.75 0 0 1 .75-.75h10.5a.75.75 0 0 1 0 1.5H4.75A.75.75 0 0 1 4 10Z" clip-rule="evenodd" />
</svg>
</button>
<div
@class([
'flex items-center justify-center gap-1 font-semibold tabular-nums text-gray-950 dark:text-white',
$valueClasses,
])
>
<span x-text="displayValue"></span>
<span
x-show="hasValue && suffix"
x-text="suffix"
class="font-medium text-gray-500 dark:text-gray-400"
></span>
</div>
<button
type="button"
@class([
'inline-flex shrink-0 items-center justify-center rounded-full transition duration-75 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40 disabled:cursor-not-allowed',
$buttonSizeClasses,
$buttonVariantClasses,
])
x-on:click="increment()"
x-bind:disabled="! canIncrement"
x-bind:aria-disabled="! canIncrement"
aria-label="{{ __('filament-short-url::default.number_stepper_increase') }}"
>
<svg @class([$iconClasses]) viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M10.75 4.75a.75.75 0 0 0-1.5 0v4.5h-4.5a.75.75 0 0 0 0 1.5h4.5v4.5a.75.75 0 0 0 1.5 0v-4.5h4.5a.75.75 0 0 0 0-1.5h-4.5v-4.5Z" />
</svg>
</button>
</div>
</x-dynamic-component>

View File

@@ -0,0 +1,169 @@
@php
use Illuminate\Support\Js;
$statePath = $getStatePath();
$options = $getNormalizedOptions();
$optionKeys = array_keys($options);
$size = $getSize();
$variant = $getVariant();
$hasSeparators = $hasSeparators();
$isFullWidth = $isFullWidth();
$isIconOnly = $isIconOnly();
$expandSelectedLabel = $shouldExpandSelectedLabel();
$isDisabled = $isDisabled();
@endphp
<x-dynamic-component
:component="$getFieldWrapperView()"
:field="$field"
>
<div
x-data="{
state: $wire.{{ $applyStateBindingModifiers("\$entangle('{$statePath}')") }},
optionKeys: {{ Js::from(array_values($optionKeys)) }},
disabledOptions: {{ Js::from(collect($options)->mapWithKeys(fn (array $option, string | int $key): array => [(string) $key => $option['disabled']])->all()) }},
separators: @js($hasSeparators),
disabled: @js($isDisabled),
indicatorStyle: '',
resizeObserver: null,
normalize(value) {
return value === null || value === undefined ? null : String(value);
},
isSelected(value) {
return this.normalize(this.state) === this.normalize(value);
},
isOptionDisabled(value) {
return this.disabledOptions[this.normalize(value)] ?? false;
},
canSelect(value) {
return ! this.disabled && ! this.isOptionDisabled(value);
},
select(value) {
if (! this.canSelect(value)) {
return;
}
this.state = value;
this.$nextTick(() => this.updateIndicator());
},
selectedIndex() {
const current = this.normalize(this.state);
return this.optionKeys.findIndex((key) => this.normalize(key) === current);
},
showSeparator(separatorIndex) {
if (! this.separators) {
return false;
}
const selectedIndex = this.selectedIndex();
if (selectedIndex === -1) {
return true;
}
return separatorIndex !== selectedIndex - 1 && separatorIndex !== selectedIndex;
},
updateIndicator() {
const track = this.$refs.track;
if (! track) {
return;
}
const selected = track.querySelector('[data-segment-selected=true]');
if (! selected) {
this.indicatorStyle = 'opacity: 0;';
return;
}
this.indicatorStyle =
'width: ' + selected.offsetWidth + 'px;' +
'height: ' + selected.offsetHeight + 'px;' +
'transform: translate3d(' + selected.offsetLeft + 'px, ' + selected.offsetTop + 'px, 0);' +
'opacity: 1;';
},
init() {
this.$watch('state', () => this.$nextTick(() => this.updateIndicator()));
this.$nextTick(() => this.updateIndicator());
if (typeof ResizeObserver === 'undefined') {
return;
}
this.resizeObserver = new ResizeObserver(() => this.updateIndicator());
this.resizeObserver.observe(this.$refs.track);
},
}"
x-init="init()"
@class([
'fsu-segment-control',
'w-full' => $isFullWidth,
'opacity-60 pointer-events-none' => $isDisabled,
])
role="radiogroup"
aria-label="{{ $getLabel() }}"
>
<div
x-ref="track"
@class([
'fsu-segment-track',
'fsu-segment-track--'.$size,
'fsu-segment-track--ghost' => $variant === 'ghost',
])
>
<div
x-ref="indicator"
aria-hidden="true"
@class([
'fsu-segment-indicator',
'fsu-segment-indicator--ghost' => $variant === 'ghost',
])
:style="indicatorStyle"
></div>
@foreach ($options as $value => $option)
@if (! $loop->first && $hasSeparators)
<span
x-show="showSeparator({{ $loop->index - 1 }})"
x-cloak
class="fsu-segment-separator"
aria-hidden="true"
></span>
@endif
<button
type="button"
role="radio"
@class([
'fsu-segment-item',
'fsu-segment-item--'.$size,
])
data-segment-value="{{ $value }}"
x-bind:data-segment-selected="isSelected(@js($value)) ? 'true' : 'false'"
x-bind:aria-checked="isSelected(@js($value)) ? 'true' : 'false'"
x-bind:disabled="disabled || isOptionDisabled(@js($value))"
x-on:click="select(@js($value))"
@if (filled($option['tooltip'] ?? null))
x-tooltip="{ content: @js($option['tooltip']), theme: $store.theme }"
@endif
>
@if ($option['icon'])
<x-filament::icon :icon="$option['icon']" />
@endif
@if ($isIconOnly)
<span class="sr-only">{{ $option['label'] }}</span>
@elseif ($expandSelectedLabel)
<span
x-show="isSelected(@js($value))"
x-cloak
>{{ $option['label'] }}</span>
@else
<span>{{ $option['label'] }}</span>
@endif
</button>
@endforeach
</div>
</div>
</x-dynamic-component>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,216 @@
{{--
Share After Create Modal
Variables available:
- $shortUrl string Full short URL (e.g. https://wy.test/s/abc)
- $qrTargetUrl string Short URL with ?source=qr appended
- $destHost string Host of destination URL (for favicon)
- $urlKey string URL key (used for QR download filenames)
- $eid string Unique element ID prefix
- $qrOptions array QR styling options for QRCodeStyling
- $successTitle string Modal heading text
- $successSubtitle string Modal subtitle text
- $successHelper string Modal helper text
- $downloadSvgText string Download SVG button label
- $downloadPngText string Download PNG button label
- $closeButtonText string Close button title
- $copyLinkText string Copy link button title
- $qrCodeText string QR code button title
- $openLinkText string Open link button title
- $dontShowAgainText string Don't show again button label
--}}
<div x-data x-init="if(localStorage.getItem('fsu:hide-share-modal')==='1'){ $nextTick(()=>$wire.unmountAction()) }">
{{-- Close Button (x) --}}
<button type="button"
x-on:click="event.preventDefault(); event.stopPropagation(); const f=$el.closest('.fi-modal-window'); const m=f?(f.getAttribute('x-on:keydown.window.escape')||'').match(/'(fi-[^']*)'/):null; if(m){$dispatch('close-modal',{id:m[1]})}else{$wire.unmountAction()}"
style="position:absolute;top:16px;right:16px;z-index:50;width:28px;height:28px;border-radius:50%;border:none;background:#f3f4f6;cursor:pointer;display:flex;align-items:center;justify-content:center;color:#6b7280;transition:color .15s,background-color .15s"
onmouseover="this.style.color='#374151';this.style.backgroundColor='#e5e7eb'"
onmouseout="this.style.color='#6b7280';this.style.backgroundColor='#f3f4f6'"
title="{{ $closeButtonText }}">
<svg style="width:16px;height:16px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
<div style="text-align:center;padding:4px 0 8px">
<p style="font-size:13px;color:#6b7280;margin:0 0 6px">{{ $successSubtitle }}</p>
<h2 style="font-size:23px;font-weight:700;color:#111827;margin:0 0 6px;line-height:1.25">{{ $successTitle }}</h2>
<p style="font-size:13px;color:#9ca3af;margin:0">{{ $successHelper }}</p>
</div>
{{-- URL pill --}}
<div style="display:flex;align-items:center;gap:10px;background:#EFF6FF;border-radius:999px;padding:10px 14px;margin:18px 0 0">
<div style="width:30px;height:30px;border-radius:50%;background:#fff;display:flex;align-items:center;justify-content:center;flex-shrink:0;border:1px solid #e5e7eb">
<img src="https://icons.duckduckgo.com/ip2/{{ $destHost }}.ico" style="width:16px;height:16px;object-fit:contain" onerror="this.style.display='none'">
</div>
<span style="flex:1;font-size:14px;font-weight:600;color:#1d4ed8;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ $shortUrl }}</span>
<div style="display:flex;align-items:center;gap:4px;flex-shrink:0">
{{-- Copy button --}}
<button id="{{ $eid }}_copy" type="button"
onclick="
event.preventDefault();
event.stopPropagation();
const u='{{ $shortUrl }}';
if(navigator.clipboard&&window.isSecureContext){navigator.clipboard.writeText(u);}
else{const t=document.createElement('textarea');t.value=u;t.style.cssText='position:fixed;left:-9999px';document.body.appendChild(t);t.select();document.execCommand('copy');t.remove();}
const b=document.getElementById('{{ $eid }}_copy');
const prev=b.innerHTML;
b.innerHTML='<svg style=\'width:16px;height:16px;color:#16a34a\' fill=\'none\' viewBox=\'0 0 24 24\' stroke=\'currentColor\' stroke-width=\'2\'><path stroke-linecap=\'round\' stroke-linejoin=\'round\' d=\'M5 13l4 4L19 7\'/></svg>';
setTimeout(()=>b.innerHTML=prev,1800);
"
style="width:30px;height:30px;border-radius:7px;border:1px solid #e5e7eb;background:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .15s"
onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'" title="{{ $copyLinkText }}">
<svg style="width:16px;height:16px;color:#6b7280" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path stroke-linecap="round" stroke-linejoin="round" d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</button>
{{-- QR toggle button --}}
<button id="{{ $eid }}_qr_btn" type="button" data-qr-options='@json($qrOptions)'
onclick="
event.preventDefault();
event.stopPropagation();
const container = document.getElementById('{{ $eid }}_qr_container');
const canvas = document.getElementById('{{ $eid }}_qr_canvas');
if (container.style.display === 'none') {
container.style.display = 'block';
const loadScript = () => {
return new Promise((resolve, reject) => {
if (window.QRCodeStyling) return resolve();
const s = document.createElement('script');
s.src = '/js/janczakb/filament-short-url/qr-code-styling.js';
s.onload = () => resolve();
s.onerror = reject;
document.head.appendChild(s);
});
};
loadScript().then(() => {
if (!canvas.innerHTML) {
const opts = JSON.parse(this.getAttribute('data-qr-options'));
opts.data = '{{ $qrTargetUrl }}';
const fixSvg = () => {
const svg = canvas.querySelector('svg');
if (svg) {
const w = svg.getAttribute('width') || opts.width || 200;
const h = svg.getAttribute('height') || opts.height || 200;
svg.setAttribute('viewBox', '0 0 ' + w + ' ' + h);
svg.style.width = '100%';
svg.style.height = '100%';
}
};
if (opts.image && opts.imageOptions) {
const img = new Image();
img.onload = () => {
const cv = document.createElement('canvas');
cv.width = 1200; cv.height = 1200;
const cx = cv.getContext('2d');
cx.imageSmoothingEnabled = true;
cx.imageSmoothingQuality = 'high';
const isCircle = opts.imageOptions.logoShape === 'circle';
const targetDim = 1200 - (parseFloat(opts.imageOptions.margin || 0) * 20);
cx.save();
if (isCircle) {
cx.beginPath();
cx.arc(600, 600, targetDim / 2, 0, 2 * Math.PI);
cx.clip();
const scale = Math.max(targetDim / img.width, targetDim / img.height);
cx.drawImage(img, (1200 - img.width * scale) / 2, (1200 - img.height * scale) / 2, img.width * scale, img.height * scale);
} else {
cx.beginPath();
const offset = (1200 - targetDim) / 2;
const radius = 144 * (targetDim / 1200);
if (typeof cx.roundRect === 'function') {
cx.roundRect(offset, offset, targetDim, targetDim, radius);
} else {
cx.rect(offset, offset, targetDim, targetDim);
}
cx.clip();
const scale = Math.max(targetDim / img.width, targetDim / img.height);
cx.drawImage(img, (1200 - img.width * scale) / 2, (1200 - img.height * scale) / 2, img.width * scale, img.height * scale);
}
cx.restore();
opts.image = cv.toDataURL('image/png');
opts.imageOptions.margin = 0;
window['qr_{{ $eid }}'] = new window.QRCodeStyling(opts);
window['qr_{{ $eid }}'].append(canvas);
fixSvg();
};
img.src = opts.image;
} else {
window['qr_{{ $eid }}'] = new window.QRCodeStyling(opts);
window['qr_{{ $eid }}'].append(canvas);
fixSvg();
}
}
});
} else {
container.style.display = 'none';
}
"
style="width:30px;height:30px;border-radius:7px;border:1px solid #e5e7eb;background:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .15s"
onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'" title="{{ $qrCodeText }}">
<svg style="width:16px;height:16px;color:#6b7280" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0 1 3.75 9.375v-4.5ZM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 0 1-1.125-1.125v-4.5ZM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0 1 13.5 9.375v-4.5Z"/>
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12h.008v.008H15V12Zm0 3h.008v.008H15V15Zm0 3h.008v.008H15V18Zm3-3h.008v.008H18V15Zm0 3h.008v.008H18V18Zm3-3h.008v.008H21V15Zm0 3h.008v.008H21V18Zm0-6h.008v.008H21V12Zm-3 0h.008v.008H18V12Z"/>
</svg>
</button>
{{-- Open link --}}
<a href="{{ $shortUrl }}" target="_blank" rel="noopener noreferrer"
style="width:30px;height:30px;border-radius:7px;border:1px solid #e5e7eb;background:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;text-decoration:none;transition:background .15s"
onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'" title="{{ $openLinkText }}">
<svg style="width:15px;height:15px;color:#6b7280" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
</a>
</div>
</div>
{{-- QR Code Container (toggled) --}}
<div id="{{ $eid }}_qr_container" style="display:none;margin:16px 0 0;text-align:center;background:#f9fafb;border:1px solid #e5e7eb;border-radius:12px;padding:16px">
<div style="display:flex;justify-content:center;margin-bottom:12px">
<div id="{{ $eid }}_qr_canvas" style="background:#fff;padding:8px;border-radius:8px;box-shadow:0 1px 3px rgba(0,0,0,0.05)"></div>
</div>
<div style="display:flex;justify-content:center;gap:8px">
<button type="button" onclick="event.preventDefault();event.stopPropagation();window['qr_{{ $eid }}']?.download({ name: '{{ $urlKey }}-qr', extension: 'svg' })" style="font-size:12px;font-weight:600;color:#374151;border:1.5px solid #e5e7eb;background:#fff;padding:6px 12px;border-radius:8px;cursor:pointer;transition:background .15s" onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'">{{ $downloadSvgText }}</button>
<button type="button" onclick="event.preventDefault();event.stopPropagation();window['qr_{{ $eid }}']?.download({ name: '{{ $urlKey }}-qr', extension: 'png' })" style="font-size:12px;font-weight:600;color:#374151;border:1.5px solid #e5e7eb;background:#fff;padding:6px 12px;border-radius:8px;cursor:pointer;transition:background .15s" onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'">{{ $downloadPngText }}</button>
</div>
</div>
{{-- Social sharing --}}
<div style="display:flex;align-items:center;justify-content:center;gap:8px;margin:18px 0 0;flex-wrap:wrap">
<a href="mailto:?body={{ urlencode($shortUrl) }}" target="_blank" rel="noopener" title="Email"
style="width:52px;height:52px;border-radius:12px;border:1.5px solid #e5e7eb;background:#fff;display:flex;align-items:center;justify-content:center;text-decoration:none;transition:border-color .15s,box-shadow .15s" onmouseover="this.style.borderColor='#d1d5db';this.style.boxShadow='0 2px 8px rgba(0,0,0,0.07)'" onmouseout="this.style.borderColor='#e5e7eb';this.style.boxShadow='none'">
<svg style="width:22px;height:22px;color:#374151" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
</a>
<a href="https://www.linkedin.com/sharing/share-offsite/?url={{ urlencode($shortUrl) }}" target="_blank" rel="noopener" title="LinkedIn"
style="width:52px;height:52px;border-radius:12px;border:1.5px solid #e5e7eb;background:#fff;display:flex;align-items:center;justify-content:center;text-decoration:none;transition:border-color .15s,box-shadow .15s" onmouseover="this.style.borderColor='#d1d5db';this.style.boxShadow='0 2px 8px rgba(0,0,0,0.07)'" onmouseout="this.style.borderColor='#e5e7eb';this.style.boxShadow='none'">
<svg style="width:22px;height:22px;color:#0077b5" fill="currentColor" viewBox="0 0 24 24"><path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.779-1.75-1.75s.784-1.75 1.75-1.75 1.75.779 1.75 1.75-.784 1.75-1.75 1.75zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z"/></svg>
</a>
<a href="https://www.facebook.com/sharer/sharer.php?u={{ urlencode($shortUrl) }}" target="_blank" rel="noopener" title="Facebook"
style="width:52px;height:52px;border-radius:12px;border:1.5px solid #e5e7eb;background:#fff;display:flex;align-items:center;justify-content:center;text-decoration:none;transition:border-color .15s,box-shadow .15s" onmouseover="this.style.borderColor='#d1d5db';this.style.boxShadow='0 2px 8px rgba(0,0,0,0.07)'" onmouseout="this.style.borderColor='#e5e7eb';this.style.boxShadow='none'">
<svg style="width:22px;height:22px;color:#1877f2" fill="currentColor" viewBox="0 0 24 24"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
</a>
<a href="https://twitter.com/intent/tweet?url={{ urlencode($shortUrl) }}" target="_blank" rel="noopener" title="X (Twitter)"
style="width:52px;height:52px;border-radius:12px;border:1.5px solid #e5e7eb;background:#fff;display:flex;align-items:center;justify-content:center;text-decoration:none;transition:border-color .15s,box-shadow .15s" onmouseover="this.style.borderColor='#d1d5db';this.style.boxShadow='0 2px 8px rgba(0,0,0,0.07)'" onmouseout="this.style.borderColor='#e5e7eb';this.style.boxShadow='none'">
<svg style="width:19px;height:19px;color:#0f1419" fill="currentColor" viewBox="0 0 24 24"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
</a>
<a href="https://api.whatsapp.com/send?text={{ urlencode($shortUrl) }}" target="_blank" rel="noopener" title="WhatsApp"
style="width:52px;height:52px;border-radius:12px;border:1.5px solid #e5e7eb;background:#fff;display:flex;align-items:center;justify-content:center;text-decoration:none;transition:border-color .15s,box-shadow .15s" onmouseover="this.style.borderColor='#d1d5db';this.style.boxShadow='0 2px 8px rgba(0,0,0,0.07)'" onmouseout="this.style.borderColor='#e5e7eb';this.style.boxShadow='none'">
<svg style="width:22px;height:22px;color:#25d366" fill="currentColor" viewBox="0 0 24 24"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L0 24l6.335-1.662c1.746.953 3.71 1.458 5.704 1.459h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/></svg>
</a>
<a href="https://t.me/share/url?url={{ urlencode($shortUrl) }}" target="_blank" rel="noopener" title="Telegram"
style="width:52px;height:52px;border-radius:12px;border:1.5px solid #e5e7eb;background:#fff;display:flex;align-items:center;justify-content:center;text-decoration:none;transition:border-color .15s,box-shadow .15s" onmouseover="this.style.borderColor='#d1d5db';this.style.boxShadow='0 2px 8px rgba(0,0,0,0.07)'" onmouseout="this.style.borderColor='#e5e7eb';this.style.boxShadow='none'">
<svg style="width:22px;height:22px;color:#229ED9" fill="currentColor" viewBox="0 0 24 24"><path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/></svg>
</a>
</div>
{{-- Don't show again --}}
<div style="display:flex;justify-content:center;margin:20px 0 2px">
<button type="button"
x-on:click="localStorage.setItem('fsu:hide-share-modal', '1'); const f=$el.closest('.fi-modal-window'); const m=f?(f.getAttribute('x-on:keydown.window.escape')||'').match(/'(fi-[^']*)'/):null; if(m){$dispatch('close-modal',{id:m[1]})}else{$wire.unmountAction()}"
style="font-size:12px;color:#9ca3af;background:none;border:none;cursor:pointer;text-decoration:underline;transition:color .15s"
onmouseover="this.style.color='#4b5563'"
onmouseout="this.style.color='#9ca3af'">
{{ $dontShowAgainText }}
</button>
</div>
</div>

View File

@@ -0,0 +1,33 @@
@php
use Bjanczak\FilamentShortUrl\FilamentShortUrlPlugin;
$version = FilamentShortUrlPlugin::version();
@endphp
<footer class="filamentshortUrl-panel-footer" aria-label="Plugin footer">
<div class="filamentshortUrl-panel-footer__card">
<span class="filamentshortUrl-panel-footer__title">Filament Short URL</span>
<span class="filamentshortUrl-panel-footer__version">v{{ $version }}</span>
<span class="filamentshortUrl-panel-footer__sep" aria-hidden="true">·</span>
<span class="filamentshortUrl-panel-footer__meta">
{{ FilamentShortUrlPlugin::POWERED_BY_LABEL }}
<a
href="{{ FilamentShortUrlPlugin::AUTHOR_URL }}"
target="_blank"
rel="noopener noreferrer"
class="filamentshortUrl-panel-footer__link"
>
{{ FilamentShortUrlPlugin::AUTHOR_HANDLE }}
</a>
</span>
<span class="filamentshortUrl-panel-footer__sep" aria-hidden="true">·</span>
<a
href="{{ FilamentShortUrlPlugin::PACKAGE_URL }}"
target="_blank"
rel="noopener noreferrer"
class="filamentshortUrl-panel-footer__link filamentshortUrl-panel-footer__package"
>
{{ FilamentShortUrlPlugin::PACKAGE_NAME }}
</a>
</div>
</footer>

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ __('filament-short-url::default.password_title') ?? 'Password Protected' }}</title>
<title>{{ __('filament-short-url::default.password_title') }}</title>
<!-- Premium Google Fonts: Bricolage Grotesque -->
<link rel="preconnect" href="https://fonts.googleapis.com">
@@ -37,7 +37,7 @@
@php
$logoPath = function_exists('setting') ? setting('logo_path') : null;
$logoUrl = $logoPath ? \Illuminate\Support\Facades\Storage::disk('public')->url($logoPath) : null;
$siteName = config('app.name', 'Laravel');
$siteName = config('filament-short-url.site_name') ?: config('app.name', 'Laravel');
@endphp
{{-- Main Sign-in Box --}}
@@ -49,10 +49,10 @@
<span class="text-3xl font-extrabold tracking-tight text-neutral-900 dark:text-white mb-3">{{ $siteName }}</span>
@endif
<p class="text-xl font-medium text-neutral-900 dark:text-white mt-2">
{{ __('filament-short-url::default.password_title') ?? 'Password Protected' }}
{{ __('filament-short-url::default.password_title') }}
</p>
<p class="text-sm text-neutral-400 dark:text-neutral-500 mt-1">
{{ __('filament-short-url::default.password_description') ?? 'This link is password-protected. Please enter the correct password to continue.' }}
{{ __('filament-short-url::default.password_description') }}
</p>
</div>
@@ -63,7 +63,7 @@
<div class="flex flex-col gap-1.5 w-full">
<div class="relative flex flex-col justify-center px-3 py-2 w-full rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 focus-within:ring-2 focus-within:ring-neutral-900 dark:focus-within:ring-white transition duration-200">
<label for="password" class="text-[10px] font-bold text-neutral-400 dark:text-neutral-500 uppercase tracking-wide select-none">
{{ __('filament-short-url::default.password_placeholder') ?? 'Password' }}
{{ __('filament-short-url::default.password_placeholder') }}
</label>
<input
type="password"
@@ -85,7 +85,7 @@
type="submit"
class="w-full mt-2 py-3.5 rounded-xl bg-neutral-900 hover:bg-neutral-800 dark:bg-white dark:hover:bg-neutral-200 dark:text-neutral-900 text-white font-semibold text-sm transition duration-200 shadow-sm flex justify-center items-center gap-2"
>
<span>{{ __('filament-short-url::default.password_btn_unlock') ?? 'Unlock & Redirect' }}</span>
<span>{{ __('filament-short-url::default.password_btn_unlock') }}</span>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>

View File

@@ -0,0 +1,181 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="h-full">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ __('filament-short-url::default.pixel_loading_title') }}</title>
<!-- Premium Google Fonts: Bricolage Grotesque -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,200..800&display=swap" rel="stylesheet">
<!-- Self-contained Tailwind CSS CDN for maximum plug-and-play reliability -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
fontFamily: {
sans: ['Bricolage Grotesque', 'sans-serif'],
},
}
}
}
// Detect system dark mode preferences
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
</script>
@php
$pixelMetaIds = $pixels->where('type', 'meta')->pluck('pixel_id');
$pixelGoogleIds = $pixels->where('type', 'google')->pluck('pixel_id');
$pixelLinkedinIds = $pixels->where('type', 'linkedin')->pluck('pixel_id');
$pixelTiktokIds = $pixels->where('type', 'tiktok')->pluck('pixel_id');
$pixelPinterestIds = $pixels->where('type', 'pinterest')->pluck('pixel_id');
@endphp
<!-- Meta / Facebook Pixel -->
@if($pixelMetaIds->isNotEmpty())
<script>
!function(f,b,e,v,n,t,s)
{if(f.fbq)return;n=f.fbq=function(){n.callMethod?
n.callMethod.apply(n,arguments):n.queue.push(arguments)};
if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
n.queue=[];t=b.createElement(e);t.async=!0;
t.src=v;s=b.getElementsByTagName(e)[0];
s.parentNode.insertBefore(t,s)}(window, document,'script',
'https://connect.facebook.net/en_US/fbevents.js');
@foreach($pixelMetaIds as $id)
fbq('init', '{{ $id }}');
fbq('track', 'PageView');
@endforeach
</script>
@foreach($pixelMetaIds as $id)
<noscript><img height="1" width="1" style="display:none" src="https://www.facebook.com/tr?id={{ $id }}&ev=PageView&noscript=1" /></noscript>
@endforeach
@endif
<!-- Google Analytics / GTM -->
@if($pixelGoogleIds->isNotEmpty())
@php $firstGoogleId = $pixelGoogleIds->first(); @endphp
<script async src="https://www.googletagmanager.com/gtag/js?id={{ $firstGoogleId }}"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
@foreach($pixelGoogleIds as $id)
gtag('config', '{{ $id }}');
@endforeach
</script>
@endif
<!-- LinkedIn Insight -->
@if($pixelLinkedinIds->isNotEmpty())
<script type="text/javascript">
window._linkedin_data_partner_ids = window._linkedin_data_partner_ids || [];
@foreach($pixelLinkedinIds as $id)
window._linkedin_data_partner_ids.push("{{ $id }}");
@endforeach
(function(l) {
if (!l){window.lintrk = function(a,b){window.lintrk.q.push([a,b])};
window.lintrk.q=[]}
var s = document.getElementsByTagName("script")[0];
var b = document.createElement("script");
b.type = "text/javascript";b.async = true;
b.src = "https://snap.licdn.com/li.lms-analytics/insight.min.js";
s.parentNode.insertBefore(b, s);})(window.lintrk);
</script>
@foreach($pixelLinkedinIds as $id)
<noscript><img height="1" width="1" style="display:none;" alt="" src="https://px.ads.linkedin.com/collect/?pid={{ $id }}&fmt=gif" /></noscript>
@endforeach
@endif
<!-- TikTok Pixel -->
@if($pixelTiktokIds->isNotEmpty())
<script>
!function (w, d, t) {
w.TiktokAnalyticsObject=t;var ttq=w[t]=w[t]||[];ttq.methods=["page","track","identify","instances","debug","on","off","once","ready","alias","group","enableCookie","disableCookie","holdConsent","revokeConsent","grantConsent"],ttq.setAndDefer=function(t,e){t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}};for(var i=0;i<ttq.methods.length;i++)ttq.setAndDefer(ttq,ttq.methods[i]);ttq.instance=function(t){for(var e=ttq._i[t]||[],n=0;n<ttq.methods.length;n++)ttq.setAndDefer(e,ttq.methods[n]);return e},ttq.load=function(e,n){var r="https://analytics.tiktok.com/i18n/pixel/events.js",o=n&&n.mixpanel;ttq._i=ttq._i||{},ttq._i[e]=[],ttq._i[e]._u=r,ttq._t=ttq._t||{},ttq._t[e]=+new Date,ttq._o=ttq._o||{},ttq._o[e]=o||{};var a=d.createElement("script");a.type="text/javascript",a.async=!0,a.src=r+"?sdkid="+e+"&lib="+t;var c=d.getElementsByTagName("script")[0];c.parentNode.insertBefore(a,c)};
@foreach($pixelTiktokIds as $id)
ttq.load('{{ $id }}');
ttq.page();
@endforeach
}(window, document, 'ttq');
</script>
@endif
<!-- Pinterest Tag -->
@if($pixelPinterestIds->isNotEmpty())
<script>
!function(e,n,t,r,a,s,o){e[r]||(e[r]=function(){(e[r].q=e[r].q||[]).push(arguments)},e[r].q=e[r].q||[],s=n.createElement(t),s.async=!0,s.src="https://s.pntrac.com/tag.js",o=n.getElementsByTagName(t)[0],o.parentNode.insertBefore(s,o))}(window,document,"script","pintrk");
@foreach($pixelPinterestIds as $id)
pintrk('load', '{{ $id }}');
pintrk('page');
@endforeach
</script>
@foreach($pixelPinterestIds as $id)
<noscript><img height="1" width="1" style="display:none;" alt="" src="https://ct.pinterest.com/v3/?event=init&tid={{ $id }}&noscript=1" /></noscript>
@endforeach
@endif
</head>
<body class="bg-[#FCFCFC] dark:bg-[#0C0C0C] min-h-screen flex flex-col justify-between items-center py-10 px-6 font-sans antialiased">
@php
$logoPath = function_exists('setting') ? setting('logo_path') : null;
$logoUrl = $logoPath ? \Illuminate\Support\Facades\Storage::disk('public')->url($logoPath) : null;
$siteName = config('filament-short-url.site_name') ?: config('app.name', 'Laravel');
@endphp
{{-- Main card --}}
<div class="w-full max-w-[360px] flex flex-col items-center gap-6 my-auto">
<div class="flex flex-col items-center text-center pb-2 select-none w-full">
@if ($logoUrl)
<img src="{{ $logoUrl }}" alt="{{ $siteName }}" class="h-[60px] w-auto object-contain mb-4" />
@else
<span class="text-3xl font-extrabold tracking-tight text-neutral-900 dark:text-white mb-3">{{ $siteName }}</span>
@endif
{{-- Animated spinner --}}
<div class="my-5 flex items-center justify-center gap-1.5" aria-hidden="true">
<span class="block w-2.5 h-2.5 rounded-full bg-neutral-300 dark:bg-neutral-600 animate-bounce [animation-delay:-0.3s]"></span>
<span class="block w-2.5 h-2.5 rounded-full bg-neutral-400 dark:bg-neutral-500 animate-bounce [animation-delay:-0.15s]"></span>
<span class="block w-2.5 h-2.5 rounded-full bg-neutral-500 dark:bg-neutral-400 animate-bounce"></span>
</div>
<p class="text-xl font-medium text-neutral-900 dark:text-white mt-2">
{{ __('filament-short-url::default.pixel_loading_title') }}
</p>
<p class="text-sm text-neutral-400 dark:text-neutral-500 mt-1">
{{ __('filament-short-url::default.pixel_loading_description') }}
</p>
</div>
{{-- Progress bar --}}
<div class="w-full h-1 rounded-full bg-neutral-200 dark:bg-neutral-800 overflow-hidden">
<div id="pixel-progress" class="h-full w-0 rounded-full bg-neutral-900 dark:bg-white transition-all ease-linear" style="transition-duration: 220ms;"></div>
</div>
</div>
{{-- Footer --}}
<div class="flex flex-col items-center gap-2 mt-auto select-none">
<span class="text-xs font-medium text-neutral-400 dark:text-neutral-600">© {{ date('Y') }} {{ $siteName }} Inc. All rights reserved.</span>
</div>
{{-- Redirect after pixels fire --}}
<script>
// Animate the progress bar to full in sync with the redirect delay
requestAnimationFrame(function() {
document.getElementById('pixel-progress').style.width = '100%';
});
setTimeout(function() {
window.location.replace(@json($destination));
}, 250);
</script>
</body>
</html>

View File

@@ -0,0 +1,48 @@
@php
$logoPath = function_exists('setting') ? setting('logo_path') : null;
$logoUrl = $logoPath ? \Illuminate\Support\Facades\Storage::disk('public')->url($logoPath) : null;
$siteName = config('filament-short-url.site_name') ?: config('app.name', 'Laravel');
@endphp
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="h-full">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>@yield('title', $siteName)</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,200..800&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
fontFamily: {
sans: ['Bricolage Grotesque', 'sans-serif'],
},
}
}
}
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.classList.add('dark');
}
</script>
</head>
<body class="bg-[#FCFCFC] dark:bg-[#0C0C0C] min-h-screen font-sans antialiased text-neutral-900 dark:text-white">
<div class="mx-auto w-full max-w-3xl px-6 py-10">
<header class="mb-8 flex flex-col items-center text-center">
@if ($logoUrl)
<img src="{{ $logoUrl }}" alt="{{ $siteName }}" class="mb-4 h-[52px] w-auto object-contain" />
@else
<span class="mb-2 text-2xl font-extrabold tracking-tight">{{ $siteName }}</span>
@endif
@hasSection('subtitle')
<p class="text-sm text-neutral-500 dark:text-neutral-400">@yield('subtitle')</p>
@endif
</header>
@yield('content')
</div>
</body>
</html>

View File

@@ -0,0 +1,27 @@
@extends('filament-short-url::public-stats-layout')
@section('title', __('filament-short-url::default.public_stats_password_title'))
@section('content')
<div class="mx-auto flex w-full max-w-[360px] flex-col gap-6">
<div class="text-center">
<p class="text-xl font-medium">{{ __('filament-short-url::default.public_stats_password_title') }}</p>
<p class="mt-1 text-sm text-neutral-500">{{ __('filament-short-url::default.public_stats_password_description') }}</p>
</div>
<form method="POST" class="flex flex-col gap-4">
@csrf
<div>
<label for="password" class="mb-1 block text-sm font-medium">{{ __('filament-short-url::default.password_placeholder') }}</label>
<input type="password" name="password" id="password" required autofocus
class="w-full rounded-lg border border-neutral-300 bg-neutral-50 px-3.5 py-2.5 text-sm focus:border-neutral-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-900">
@if(isset($errors) && $errors->has('password'))
<p class="mt-1 text-sm text-red-600">{{ $errors->first('password') }}</p>
@endif
</div>
<button type="submit" class="rounded-lg bg-neutral-900 px-4 py-2.5 text-sm font-semibold text-white hover:bg-neutral-800 dark:bg-white dark:text-neutral-950">
{{ __('filament-short-url::default.public_stats_password_submit') }}
</button>
</form>
</div>
@endsection

View File

@@ -0,0 +1,12 @@
@extends('filament-short-url::public-stats-layout')
@section('title', __('filament-short-url::default.public_stats_rate_limited'))
@section('content')
<div class="rounded-2xl border border-amber-200 bg-amber-50 p-6 text-center dark:border-amber-900 dark:bg-amber-950/40">
<p class="text-lg font-semibold">{{ __('filament-short-url::default.public_stats_rate_limited') }}</p>
<p class="mt-2 text-sm text-neutral-600 dark:text-neutral-300">
{{ __('filament-short-url::default.public_stats_retry_in', ['seconds' => $retryAfter]) }}
</p>
</div>
@endsection

View File

@@ -0,0 +1,69 @@
@extends('filament-short-url::public-stats-layout')
@section('title', __('filament-short-url::default.public_stats_page_title'))
@section('subtitle')
{{ __('filament-short-url::default.public_stats_page_subtitle', ['key' => $shortUrl->url_key]) }}
@endsection
@section('content')
<form method="GET" class="mb-6 flex flex-wrap items-end gap-3 rounded-2xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-950">
<div class="min-w-[140px] flex-1">
<label for="date_from" class="mb-1 block text-xs font-semibold uppercase tracking-wide text-neutral-500">{{ __('filament-short-url::default.public_stats_date_from') }}</label>
<input type="date" name="date_from" id="date_from" value="{{ $dateFrom }}"
class="w-full rounded-lg border border-neutral-300 bg-neutral-50 px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-900">
</div>
<div class="min-w-[140px] flex-1">
<label for="date_to" class="mb-1 block text-xs font-semibold uppercase tracking-wide text-neutral-500">{{ __('filament-short-url::default.public_stats_date_to') }}</label>
<input type="date" name="date_to" id="date_to" value="{{ $dateTo }}"
class="w-full rounded-lg border border-neutral-300 bg-neutral-50 px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-900">
</div>
<button type="submit" class="rounded-lg bg-neutral-900 px-4 py-2 text-sm font-semibold text-white hover:bg-neutral-800 dark:bg-white dark:text-neutral-950 dark:hover:bg-neutral-200">
{{ __('filament-short-url::default.public_stats_apply_filter') }}
</button>
</form>
<div class="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-3">
@foreach ([
'totalVisits' => __('filament-short-url::default.public_stats_total_visits'),
'uniqueVisits' => __('filament-short-url::default.public_stats_unique_visits'),
'visitsToday' => __('filament-short-url::default.public_stats_today'),
'visitsThisWeek' => __('filament-short-url::default.public_stats_this_week'),
'visitsThisMonth' => __('filament-short-url::default.public_stats_this_month'),
'qrScans' => __('filament-short-url::default.public_stats_qr_scans'),
] as $metric => $label)
<div class="rounded-2xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-950">
<p class="text-xs font-semibold uppercase tracking-wide text-neutral-500">{{ $label }}</p>
<p class="mt-2 text-2xl font-bold tabular-nums">{{ number_format((int) ($stats[$metric] ?? 0)) }}</p>
</div>
@endforeach
</div>
<div class="rounded-2xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-950">
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wide text-neutral-500">{{ __('filament-short-url::default.public_stats_visits_by_day') }}</h2>
@php($visitsByDay = $stats['visitsByDay'] ?? [])
@if (empty($visitsByDay))
<p class="text-sm text-neutral-500">{{ __('filament-short-url::default.public_stats_no_data') }}</p>
@else
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead>
<tr class="border-b border-neutral-200 text-left text-neutral-500 dark:border-neutral-800">
<th class="py-2 pr-4 font-semibold">{{ __('filament-short-url::default.public_stats_day') }}</th>
<th class="py-2 font-semibold">{{ __('filament-short-url::default.public_stats_visits') }}</th>
</tr>
</thead>
<tbody>
@foreach ($visitsByDay as $day => $count)
<tr class="border-b border-neutral-100 dark:border-neutral-900">
<td class="py-2 pr-4">{{ $day }}</td>
<td class="py-2 tabular-nums">{{ number_format((int) $count) }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
@endsection

View File

@@ -1,490 +0,0 @@
@php
/** @var \Bjanczak\FilamentShortUrl\Models\ShortUrl|null $record */
$record = $this->record ?? null;
$shortUrl = $record ? $record->getShortUrl() : (config('app.url').'/s/preview');
$opts = $record ? $record->getQrOptions() : config('filament-short-url.qr_defaults', []);
@endphp
<style>
/* Color picker */
.qr-color-picker input[type=color] {
position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;
}
/* Radio buttons */
.qr-radio-option {
display:flex;align-items:center;gap:8px;cursor:pointer;
padding:8px 12px;border-radius:8px;border:1.5px solid #e5e7eb;
transition:all .15s;font-size:13px;font-weight:500;color:#374151;
background:white;
}
.dark .qr-radio-option { background:#1f2937;border-color:#374151;color:#d1d5db; }
.qr-radio-option.active {
border-color:#6366f1;background:#eef2ff;color:#4338ca;
}
.dark .qr-radio-option.active { background:#1e1b4b;border-color:#818cf8;color:#a5b4fc; }
.qr-radio-dot {
width:15px;height:15px;border-radius:50%;border:2px solid currentColor;
display:flex;align-items:center;justify-content:center;flex-shrink:0;
}
.qr-radio-option.active .qr-radio-dot::after {
content:'';width:6px;height:6px;border-radius:50%;background:currentColor;
}
/* Toggle */
.qr-toggle {
position:relative;display:inline-flex;align-items:center;
height:22px;width:42px;cursor:pointer;border-radius:9999px;
transition:background-color .2s ease;flex-shrink:0;
}
.qr-toggle.on { background:#6366f1; }
.qr-toggle.off { background:#d1d5db; }
.dark .qr-toggle.off { background:#4b5563; }
.qr-toggle-thumb {
pointer-events:none;height:18px;width:18px;border-radius:9999px;
background:white;box-shadow:0 1px 3px rgba(0,0,0,.25);
transition:transform .2s ease;position:absolute;left:2px;
}
.qr-toggle.on .qr-toggle-thumb { transform:translateX(20px); }
.qr-toggle.off .qr-toggle-thumb { transform:translateX(0); }
/* Labels */
.qr-label {
font-size:11px;font-weight:700;color:#9ca3af;
text-transform:uppercase;letter-spacing:.06em;
margin-bottom:6px;display:block;
}
/* Selects */
.qr-select {
width:100%;padding:7px 30px 7px 10px;border-radius:8px;font-size:13px;font-weight:500;
border:1.5px solid #e5e7eb;background:#fff;color:#111827;
appearance:none;
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3E%3C/svg%3E");
background-repeat:no-repeat;background-position:right 8px center;background-size:16px;
cursor:pointer;transition:border-color .15s;
}
.qr-select:focus { outline:none;border-color:#6366f1; }
.dark .qr-select { background-color:#374151;border-color:#4b5563;color:#f9fafb; }
/* Number input */
.qr-input-num {
width:100%;padding:7px 10px;border-radius:8px;font-size:13px;font-weight:500;
border:1.5px solid #e5e7eb;background:#fff;color:#111827;transition:border-color .15s;
}
.qr-input-num:focus { outline:none;border-color:#6366f1; }
.dark .qr-input-num { background-color:#374151;border-color:#4b5563;color:#f9fafb; }
/* Color swatch */
.qr-color-swatch {
position:relative;width:36px;height:36px;border-radius:8px;
border:1.5px solid #e5e7eb;cursor:pointer;overflow:hidden;flex-shrink:0;
}
/* Hex input */
.qr-hex-input {
flex:1;min-width:0;padding:7px 10px;border-radius:8px;
font-size:12px;font-family:monospace;font-weight:600;
border:1.5px solid #e5e7eb;background:#fff;color:#374151;
letter-spacing:.03em;transition:border-color .15s;
}
.qr-hex-input:focus { outline:none;border-color:#6366f1; }
.dark .qr-hex-input { background:#374151;border-color:#4b5563;color:#f9fafb; }
/* Sections */
.qr-section { padding:14px 0;border-bottom:1px solid #f3f4f6; }
.dark .qr-section { border-color:#374151; }
.qr-section:last-child { border-bottom:none; }
/* Download buttons */
.qr-dl-btn {
display:inline-flex;align-items:center;gap:5px;padding:6px 14px;
border-radius:8px;font-size:12px;font-weight:600;
border:1.5px solid #e5e7eb;background:#fff;color:#374151;
cursor:pointer;transition:all .15s;
}
.qr-dl-btn:hover { background:#f9fafb;border-color:#d1d5db; }
.dark .qr-dl-btn { background:#374151;border-color:#4b5563;color:#d1d5db; }
.dark .qr-dl-btn:hover { background:#4b5563; }
/* Transparent checker */
.qr-checker {
background-image:linear-gradient(45deg,#d1d5db 25%,transparent 25%),
linear-gradient(-45deg,#d1d5db 25%,transparent 25%),
linear-gradient(45deg,transparent 75%,#d1d5db 75%),
linear-gradient(-45deg,transparent 75%,#d1d5db 75%);
background-size:10px 10px;
background-position:0 0,0 5px,5px -5px,-5px 0;
background-color:#f3f4f6;
}
@keyframes qr-spin { to { transform: rotate(360deg); } }
.qr-margin-top {
margin-top: 12px !important;
}
.qr-space-y > div {
margin-top: 12px !important;
}
.qr-space-y > div:first-child {
margin-top: 0 !important;
}
</style>
{{-- Preload QR library as soon as the tab renders --}}
<script>
(function(){
if (window.QRCodeStyling) return;
var s = document.createElement('script');
s.src = 'https://unpkg.com/qr-code-styling@1.6.0-rc.1/lib/qr-code-styling.js';
s.onerror = function(){
var s2 = document.createElement('script');
s2.src = 'https://cdn.jsdelivr.net/npm/qr-code-styling@1.6.0-rc.1/lib/qr-code-styling.js';
document.head.appendChild(s2);
};
document.head.appendChild(s);
})();
</script>
<div
x-data="{
url: @js($shortUrl),
size: {{ $opts['size'] ?? 300 }},
margin: {{ $opts['margin'] ?? 1 }},
dotStyle: @js($opts['dot_style'] ?? 'square'),
colorMode: @js($opts['color_mode'] ?? 'solid'),
fgColor: @js($opts['foreground_color'] ?? '#000000'),
gradientFrom: @js($opts['gradient_from'] ?? '#4f46e5'),
gradientTo: @js($opts['gradient_to'] ?? '#06b6d4'),
gradientType: @js($opts['gradient_type'] ?? 'linear'),
bgTransparent: {{ ($opts['bg_transparent'] ?? false) ? 'true' : 'false' }},
bgColor: @js($opts['background_color'] ?? '#ffffff'),
eyeConfigEnabled: {{ ($opts['eye_config_enabled'] ?? false) ? 'true' : 'false' }},
eyeSquareStyle: @js($opts['eye_square_style'] ?? 'square'),
eyeDotStyle: @js($opts['eye_dot_style'] ?? 'square'),
eyeColor: @js($opts['eye_color'] ?? '#000000'),
qrInstance: null,
init() {
this.$nextTick(() => {
this.loadScript().then(() => {
this.render();
['size','margin','dotStyle','colorMode','fgColor','gradientFrom',
'gradientTo','gradientType','bgTransparent','bgColor',
'eyeConfigEnabled','eyeSquareStyle','eyeDotStyle','eyeColor'
].forEach(k => this.$watch(k, () => { this.syncDom(); this.render(); }));
// Re-render when the QR tab becomes visible (Filament uses x-show on tab panels)
const canvas = this.$refs.qrCanvas;
if (canvas) {
const obs = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) { this.render(); }
});
obs.observe(canvas);
}
});
});
},
loadScript() {
if (window.QRCodeStyling) return Promise.resolve();
return new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = 'https://unpkg.com/qr-code-styling@1.6.0-rc.1/lib/qr-code-styling.js';
s.onload = resolve;
s.onerror = () => {
// Fallback CDN
const s2 = document.createElement('script');
s2.src = 'https://cdn.jsdelivr.net/npm/qr-code-styling@1.6.0-rc.1/lib/qr-code-styling.js';
s2.onload = resolve;
s2.onerror = reject;
document.head.appendChild(s2);
};
document.head.appendChild(s);
});
},
buildOptions() {
const isGrad = this.colorMode === 'gradient';
const dotsOptions = isGrad
? { type: this.dotStyle, gradient: { type: this.gradientType,
colorStops: [{ offset: 0, color: this.gradientFrom }, { offset: 1, color: this.gradientTo }] } }
: { type: this.dotStyle, color: this.fgColor };
const mainColor = isGrad ? this.gradientFrom : this.fgColor;
const eyeSq = this.eyeConfigEnabled
? { type: this.eyeSquareStyle, color: this.eyeColor }
: { type: this.dotStyle === 'dots' ? 'dot' : 'square', color: mainColor };
const eyeDt = this.eyeConfigEnabled
? { type: this.eyeDotStyle, color: this.eyeColor }
: { type: this.dotStyle === 'dots' ? 'dot' : 'square', color: mainColor };
return {
width: +this.size || 300, height: +this.size || 300,
data: this.url, margin: +this.margin || 1,
dotsOptions,
backgroundOptions: this.bgTransparent ? { color: 'rgba(0,0,0,0)' } : { color: this.bgColor },
cornersSquareOptions: eyeSq,
cornersDotOptions: eyeDt,
qrOptions: { errorCorrectionLevel: 'M' },
};
},
render() {
const el = this.$refs.qrCanvas;
if (!el || !window.QRCodeStyling) return;
el.innerHTML = '';
this.qrInstance = new window.QRCodeStyling(this.buildOptions());
this.qrInstance.append(el);
},
syncDom() {
const el = document.getElementById('qr-options-json-input');
if (!el) return;
el.value = JSON.stringify({
size: +this.size, margin: +this.margin, dot_style: this.dotStyle,
color_mode: this.colorMode, foreground_color: this.fgColor,
gradient_from: this.gradientFrom, gradient_to: this.gradientTo,
gradient_type: this.gradientType, bg_transparent: this.bgTransparent,
background_color: this.bgColor, eye_config_enabled: this.eyeConfigEnabled,
eye_square_style: this.eyeSquareStyle, eye_dot_style: this.eyeDotStyle,
eye_color: this.eyeColor,
});
el.dispatchEvent(new Event('input', { bubbles: true }));
},
download(ext) { this.qrInstance?.download({ name: 'qr-code', extension: ext }); },
setHex(field, val) { if (/^#[0-9A-Fa-f]{6}$/.test(val)) this[field] = val; },
}"
class="w-full"
>
{{-- CSS Grid: left=fixed 280px, right=fills remaining space, both columns same height --}}
<div style="display:grid;grid-template-columns:280px 1fr;gap:2rem;width:100%;min-width:0;align-items:stretch">
{{-- ══ LEFT: settings panel ══ --}}
<div style="min-width:0;overflow:hidden">
{{-- Size & Margin --}}
<div class="qr-section">
<div class="grid grid-cols-2 gap-3" style="display:grid;grid-template-columns:repeat(2, minmax(0, 1fr));gap:12px">
<div>
<span class="qr-label">{{ __('filament-short-url::default.qr_label_size') }}</span>
<input type="number" x-model.number="size" min="100" max="1000" step="10" class="qr-input-num" />
</div>
<div>
<span class="qr-label">{{ __('filament-short-url::default.qr_label_margin') }}</span>
<select x-model.number="margin" class="qr-select">
@foreach (range(0, 10) as $m)
<option value="{{ $m }}">{{ $m }}</option>
@endforeach
</select>
</div>
</div>
</div>
{{-- Dot Style --}}
<div class="qr-section">
<span class="qr-label">{{ __('filament-short-url::default.qr_label_style') }}</span>
<select x-model="dotStyle" class="qr-select">
<option value="square">{{ __('filament-short-url::default.qr_option_square') }}</option>
<option value="dots">{{ __('filament-short-url::default.qr_option_dots') }}</option>
<option value="rounded">{{ __('filament-short-url::default.qr_option_rounded') }}</option>
<option value="classy">{{ __('filament-short-url::default.qr_option_classy') }}</option>
<option value="classy-rounded">{{ __('filament-short-url::default.qr_option_classy_rounded') }}</option>
<option value="extra-rounded">{{ __('filament-short-url::default.qr_option_extra_rounded') }}</option>
</select>
</div>
{{-- Foreground Color --}}
<div class="qr-section">
<span class="qr-label">{{ __('filament-short-url::default.qr_label_foreground_color') }}</span>
{{-- Mode radio --}}
<div class="flex gap-2" style="display:flex;gap:8px">
<button type="button"
:class="colorMode === 'solid' ? 'active' : ''"
class="qr-radio-option flex-1 justify-center text-center"
x-on:click="colorMode = 'solid'">
<span class="qr-radio-dot"></span>
{{ __('filament-short-url::default.qr_label_single_color') }}
</button>
<button type="button"
:class="colorMode === 'gradient' ? 'active' : ''"
class="qr-radio-option flex-1 justify-center text-center"
x-on:click="colorMode = 'gradient'">
<span class="qr-radio-dot"></span>
{{ __('filament-short-url::default.qr_label_gradient') }}
</button>
</div>
{{-- Single color picker --}}
<div x-show="colorMode === 'solid'" x-transition style="display:block" class="mt-3 qr-margin-top">
<span class="qr-label">{{ __('filament-short-url::default.qr_label_color') }}</span>
<div class="flex items-center gap-2" style="display:flex;align-items:center;gap:8px">
<div class="qr-color-swatch qr-color-picker" :style="'background:'+fgColor">
<input type="color" x-model="fgColor" />
</div>
<input type="text" class="qr-hex-input"
:value="fgColor"
x-on:change="setHex('fgColor', $event.target.value)"
maxlength="7" placeholder="#000000" />
</div>
</div>
{{-- Gradient pickers --}}
<div x-show="colorMode === 'gradient'" x-transition style="display:none" class="mt-3 space-y-3 qr-space-y qr-margin-top">
<div class="grid grid-cols-2 gap-2" style="display:grid;grid-template-columns:repeat(2, minmax(0, 1fr));gap:8px">
<div>
<span class="qr-label">{{ __('filament-short-url::default.qr_label_from') }}</span>
<div class="flex items-center gap-1.5" style="display:flex;align-items:center;gap:6px">
<div class="qr-color-swatch qr-color-picker" :style="'background:'+gradientFrom">
<input type="color" x-model="gradientFrom" />
</div>
<input type="text" class="qr-hex-input"
:value="gradientFrom"
x-on:change="setHex('gradientFrom', $event.target.value)"
maxlength="7" />
</div>
</div>
<div>
<span class="qr-label">{{ __('filament-short-url::default.qr_label_to') }}</span>
<div class="flex items-center gap-1.5" style="display:flex;align-items:center;gap:6px">
<div class="qr-color-swatch qr-color-picker" :style="'background:'+gradientTo">
<input type="color" x-model="gradientTo" />
</div>
<input type="text" class="qr-hex-input"
:value="gradientTo"
x-on:change="setHex('gradientTo', $event.target.value)"
maxlength="7" />
</div>
</div>
</div>
<div>
<span class="qr-label">{{ __('filament-short-url::default.qr_label_gradient_type') }}</span>
<select x-model="gradientType" class="qr-select">
<option value="linear">{{ __('filament-short-url::default.qr_gradient_linear') }}</option>
<option value="radial">{{ __('filament-short-url::default.qr_gradient_radial') }}</option>
</select>
</div>
</div>
</div>
{{-- Background --}}
<div class="qr-section">
<span class="qr-label">{{ __('filament-short-url::default.qr_label_background') }}</span>
<div class="flex items-center justify-between" style="display:flex;align-items:center;justify-content:space-between">
<span style="font-size:13px;font-weight:500;color:#374151" class="dark:text-gray-300">{{ __('filament-short-url::default.qr_label_transparent') }}</span>
<button type="button"
:class="bgTransparent ? 'on' : 'off'"
class="qr-toggle"
x-on:click="bgTransparent = !bgTransparent">
<span class="qr-toggle-thumb"></span>
</button>
</div>
<div x-show="!bgTransparent" x-transition style="display:block;margin-top:12px" class="mt-3 qr-margin-top">
<span class="qr-label">{{ __('filament-short-url::default.qr_label_color') }}</span>
<div class="flex items-center gap-2" style="display:flex;align-items:center;gap:8px">
<div class="qr-color-swatch qr-color-picker" :style="'background:'+bgColor">
<input type="color" x-model="bgColor" />
</div>
<input type="text" class="qr-hex-input"
:value="bgColor"
x-on:change="setHex('bgColor', $event.target.value)"
maxlength="7" placeholder="#ffffff" />
</div>
</div>
<div x-show="bgTransparent" x-transition style="display:none;margin-top:12px" class="mt-3 qr-margin-top">
<div class="qr-checker flex h-9 w-full items-center justify-center rounded-lg border border-dashed border-gray-300" style="display:flex;align-items:center;justify-content:center;font-size:11px;color:#9ca3af;font-weight:600">
TRANSPARENT
</div>
</div>
</div>
{{-- Eye Config --}}
<div class="qr-section">
<div class="flex items-center justify-between" style="display:flex;align-items:center;justify-content:space-between">
<span class="qr-label" style="margin-bottom:0">{{ __('filament-short-url::default.qr_label_eye_config') }}</span>
<button type="button"
:class="eyeConfigEnabled ? 'on' : 'off'"
class="qr-toggle"
x-on:click="eyeConfigEnabled = !eyeConfigEnabled">
<span class="qr-toggle-thumb"></span>
</button>
</div>
<div x-show="eyeConfigEnabled" x-transition style="display:none" class="mt-3 space-y-3 qr-space-y qr-margin-top">
<div>
<span class="qr-label">{{ __('filament-short-url::default.qr_label_eye_square_style') }}</span>
<select x-model="eyeSquareStyle" class="qr-select">
<option value="square">{{ __('filament-short-url::default.qr_option_square') }}</option>
<option value="dot">{{ __('filament-short-url::default.qr_option_dot') }}</option>
<option value="extra-rounded">{{ __('filament-short-url::default.qr_option_extra_rounded') }}</option>
</select>
</div>
<div>
<span class="qr-label">{{ __('filament-short-url::default.qr_label_eye_dot_style') }}</span>
<select x-model="eyeDotStyle" class="qr-select">
<option value="square">{{ __('filament-short-url::default.qr_option_square') }}</option>
<option value="dot">{{ __('filament-short-url::default.qr_option_dot') }}</option>
</select>
</div>
<div>
<span class="qr-label">{{ __('filament-short-url::default.qr_label_eye_color') }}</span>
<div class="flex items-center gap-2" style="display:flex;align-items:center;gap:8px">
<div class="qr-color-swatch qr-color-picker" :style="'background:'+eyeColor">
<input type="color" x-model="eyeColor" />
</div>
<input type="text" class="qr-hex-input"
:value="eyeColor"
x-on:change="setHex('eyeColor', $event.target.value)"
maxlength="7" />
</div>
</div>
</div>
</div>
</div>
{{-- ══ RIGHT: Preview ══ --}}
<div style="display:flex;flex-direction:column;min-width:0;height:100%">
{{-- Top bar --}}
<div class="mb-4 flex items-center justify-between" style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
<span style="font-size:13px;font-weight:600;color:#9ca3af">{{ __('filament-short-url::default.qr_label_preview') }}</span>
<div class="flex items-center gap-2" style="display:flex;align-items:center;gap:8px">
<button type="button" x-on:click="download('png')" class="qr-dl-btn">
<svg style="width:13px;height:13px" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"/>
</svg>
PNG
</button>
<button type="button" x-on:click="download('svg')" class="qr-dl-btn">
<svg style="width:13px;height:13px" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"/>
</svg>
SVG
</button>
</div>
</div>
{{--
Canvas box ALL styles in :style (object) so Alpine does NOT overwrite
the static style attribute (Alpine :style REPLACES, not merges, with static style).
--}}
<div :class="bgTransparent ? 'qr-checker' : ''"
:style="{
flex: '1',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '15px',
border: '1.5px solid #e5e7eb',
minHeight: '280px',
padding: '32px',
position: 'relative',
background: bgTransparent ? '' : bgColor,
}">
{{-- Spinner --}}
<div x-show="!qrInstance"
style="width:36px;height:36px;border:3px solid #e5e7eb;
border-top-color:#6366f1;border-radius:50%;
animation:qr-spin 0.8s linear infinite">
</div>
{{-- QR canvas --}}
<div x-ref="qrCanvas" style="line-height:0"></div>
</div>
<p class="mt-2 text-center font-mono" style="font-size:11px;color:#9ca3af">
{{ $shortUrl }}
</p>
</div>
</div>
</div>

View File

@@ -0,0 +1,50 @@
<div
x-data="shortUrlQrDesignerPreview({
url: @js($shortUrl)
})"
class="qr-preview-sticky"
>
<div class="qr-preview-card">
<div style="width:100%;display:flex;justify-content:space-between;align-items:center;margin-bottom:18px;flex-shrink:0">
<span style="font-size:12px;font-weight:700;color:#a1a1aa;text-transform:uppercase;letter-spacing:0.05em">{{ __('filament-short-url::default.qr_label_live_preview') }}</span>
<div style="display:flex;gap:6px">
<button type="button" x-on:click="download('png')" class="qr-dl-btn" style="padding:4px 10px;font-size:11px;border-radius:6px">
{{ __('filament-short-url::default.qr_label_png') }}
</button>
<button type="button" x-on:click="download('svg')" class="qr-dl-btn" style="padding:4px 10px;font-size:11px;border-radius:6px">
{{ __('filament-short-url::default.qr_label_svg') }}
</button>
</div>
</div>
<div style="flex:1;display:flex;align-items:center;justify-content:center;width:100%">
<div :class="bgTransparent ? 'qr-checker' : ''"
:style="{
width: '100%',
maxWidth: '280px',
aspectRatio: '1/1',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '12px',
border: '1px solid #e5e7eb',
padding: '24px',
position: 'relative',
background: bgTransparent ? '' : bgColor,
}">
{{-- Spinner --}}
<div x-show="!qrInstance"
style="width:32px;height:32px;border:3px solid #e5e7eb;
border-top-color:#6366f1;border-radius:50%;
animation:qr-spin 0.8s linear infinite">
</div>
{{-- QR canvas --}}
<div wire:ignore x-ref="qrCanvas" class="qr-preview-wrapper" style="line-height:0;width:100%;height:100%;display:flex;align-items:center;justify-content:center"></div>
</div>
</div>
<p class="mt-3 text-center font-mono" style="font-size:11px;color:#71717a;width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-top:14px;flex-shrink:0">
<span x-text="url"></span>
</p>
</div>
</div>

View File

@@ -0,0 +1,97 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="h-full">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ $ogMeta['title'] }}</title>
{{-- Robots Search Indexing configuration --}}
@if($shortUrl->do_index)
<meta name="robots" content="index, follow">
<link rel="canonical" href="{{ $ogMeta['canonical_url'] }}">
@else
<meta name="robots" content="noindex, nofollow">
@endif
{{-- Open Graph / Facebook Metadata --}}
<meta property="og:type" content="website">
<meta property="og:url" content="{{ $ogMeta['short_url'] }}">
<meta property="og:site_name" content="{{ $ogMeta['site_name'] }}">
<meta property="og:title" content="{{ $ogMeta['title'] }}">
@if($ogMeta['description'])
<meta property="og:description" content="{{ $ogMeta['description'] }}">
@endif
@if($ogMeta['image_url'])
<meta property="og:image" content="{{ $ogMeta['image_url'] }}">
@if($ogMeta['image_width'] && $ogMeta['image_height'])
<meta property="og:image:width" content="{{ $ogMeta['image_width'] }}">
<meta property="og:image:height" content="{{ $ogMeta['image_height'] }}">
@endif
@endif
{{-- Twitter Metadata --}}
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:url" content="{{ $ogMeta['short_url'] }}">
<meta name="twitter:title" content="{{ $ogMeta['title'] }}">
@if($ogMeta['description'])
<meta name="twitter:description" content="{{ $ogMeta['description'] }}">
@endif
@if($ogMeta['image_url'])
<meta name="twitter:image" content="{{ $ogMeta['image_url'] }}">
@endif
{{-- Human visitors only: bots must stay on this page to read OG tags --}}
@if(!$shortUrl->is_cloaked && !$isBot)
<meta http-equiv="refresh" content="0;url={{ e($destination) }}">
<script>
window.location.replace(@json($destination));
</script>
@endif
@if($shortUrl->is_cloaked)
<style>
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
background-color: #ffffff;
}
iframe {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
border: none;
margin: 0;
padding: 0;
overflow: hidden;
z-index: 999999;
}
</style>
@endif
</head>
<body class="h-full bg-white text-neutral-800 antialiased font-sans flex items-center justify-center">
@if($shortUrl->is_cloaked)
{{-- Link Cloaking Iframe --}}
<iframe src="{{ e($destination) }}" title="{{ $ogMeta['title'] }}"></iframe>
@elseif(!$isBot)
{{-- Standard JS redirect page fallback for human visitors --}}
<div class="text-center p-6 max-w-sm flex flex-col items-center gap-4 select-none">
<div class="flex items-center justify-center gap-1.5" aria-hidden="true">
<span class="block w-2.5 h-2.5 rounded-full bg-neutral-300 dark:bg-neutral-600 animate-bounce [animation-delay:-0.3s]"></span>
<span class="block w-2.5 h-2.5 rounded-full bg-neutral-400 dark:bg-neutral-500 animate-bounce [animation-delay:-0.15s]"></span>
<span class="block w-2.5 h-2.5 rounded-full bg-neutral-500 dark:bg-neutral-400 animate-bounce"></span>
</div>
<p class="text-lg font-medium text-neutral-600">{{ __('filament-short-url::default.redirect_html_redirecting') }}</p>
<p class="text-xs text-neutral-400">If you are not redirected automatically, <a href="{{ e($destination) }}" class="text-primary-600 underline font-semibold hover:text-primary-500">click here</a>.</p>
</div>
@endif
</body>
</html>

View File

@@ -0,0 +1,34 @@
<div
wire:ignore
class="sidebar-qr-container"
@qr-design-updated.window="const _d = Array.isArray($event.detail) ? $event.detail[0] : $event.detail; applyQrDesign(_d?.options, _d?.logo)"
x-data="shortUrlQrPreview({
options: @js($opts),
logo: @js($currentLogo),
domains: @js($domains),
defaultDomain: @js($defaultDomain),
routePrefix: @js(config('filament-short-url.route_prefix')),
defaultUrlKey: @js($record ? $record->url_key : ''),
protocol: @js(app()->isProduction() ? 'https' : (parse_url(config('app.url'), PHP_URL_SCHEME) ?: 'https'))
})"
>
{{-- Label row matching "Folder" label style --}}
<span class="fi-fo-field-wrp-label flex items-center gap-x-3 mb-1.5">
<label class="fi-label text-sm font-semibold leading-6 text-gray-950 dark:text-white">
{{ __('filament-short-url::default.action_qr') }}
</label>
</span>
{{-- Dotted Box Wrapper --}}
<div class="w-full h-[94px] border border-neutral-200 dark:border-neutral-800 rounded-xl relative overflow-hidden qr-preview-dots-bg flex items-center justify-center">
<!-- QR Code Canvas in the middle (64x64px) -->
<div x-ref="sidebarQrCanvas" class="w-[64px] h-[64px] flex items-center justify-center bg-white rounded-lg p-1 shadow-sm border border-neutral-100 dark:border-neutral-800/60 overflow-hidden"></div>
{{-- Edit Button positioned via inline style to guarantee correct offset --}}
@if ($getAction('designQr'))
{{ $getAction('designQr') }}
@endif
</div>
</div>

View File

@@ -0,0 +1,131 @@
@php
$ogTitle = $ogTitle ?? ($get('og_title') ?: null);
$ogDescription = $ogDescription ?? ($get('og_description') ?: null);
$ogImageUrl = $ogImageUrl ?? null;
$isScraping = $isScraping ?? ($get('is_scraping') ?: false);
@endphp
<div
class="sidebar-social-container"
wire:key="fsu-social-preview-{{ md5(json_encode([$ogTitle, $ogDescription, $ogImageUrl, (bool) $isScraping])) }}"
x-data="{ scraping: @js((bool) $isScraping), passwordProtected: @js((bool) $isPasswordProtected) }"
x-on:fsu-scraping-start.window="scraping = true"
x-on:fsu-scraping-end.window="scraping = false"
x-on:fsu-password-protection-changed.window="passwordProtected = !!$event.detail.protected"
x-on:fsu-og-image-updated.window="scraping = false"
>
{{-- Header: Title only (no "i" icon) --}}
<span class="fi-fo-field-wrp-label flex items-center gap-x-3 mb-1.5">
<label class="fi-label text-sm font-semibold leading-6 text-gray-950 dark:text-white">
{{ __('filament-short-url::default.live_social_preview') }}
</label>
</span>
<div x-show="scraping" x-cloak>
{{-- ===== LOADING / SCRAPING STATE ===== --}}
<div class="relative w-full rounded-xl overflow-hidden border border-neutral-200 dark:border-neutral-700 qr-preview-dots-bg aspect-[16/10] flex items-center justify-center">
<div class="absolute inset-0 bg-neutral-100 dark:bg-neutral-800/20 flex items-center justify-center">
<svg class="w-8 h-8 text-indigo-600 dark:text-indigo-400 animate-spin" fill="none" viewBox="0 0 24 24" style="width: 32px !important; height: 32px !important;">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
</div>
<div class="mt-2.5 space-y-2">
<div class="h-4 bg-neutral-200 dark:bg-neutral-800 rounded animate-pulse w-3/4"></div>
<div class="h-3.5 bg-neutral-200 dark:bg-neutral-800 rounded animate-pulse w-5/6"></div>
<div class="h-3 bg-neutral-200 dark:bg-neutral-800/60 rounded animate-pulse w-1/2"></div>
</div>
</div>
<div x-show="!scraping && passwordProtected" x-cloak>
{{-- ===== PASSWORD STATE ===== --}}
<div class="relative w-full rounded-xl overflow-hidden bg-black aspect-[16/10]">
{{-- Edit button --}}
<button
type="button"
onclick="
const tabs = document.querySelectorAll('[role=tab]');
tabs.forEach(t => { if (t.textContent.trim().includes('SEO') || t.textContent.trim().includes('Social') || t.getAttribute('aria-label')?.includes('seo')) { t.click(); } });
"
class="absolute top-2 right-2 z-10 w-8 h-8 rounded-lg bg-white/90 dark:bg-neutral-900/90 border border-neutral-200 dark:border-neutral-700 shadow-sm flex items-center justify-center hover:bg-white dark:hover:bg-neutral-800 transition cursor-pointer"
style="width: 32px !important; height: 32px !important;"
title="{{ __('filament-short-url::default.action_edit') }}"
>
<svg class="w-4 h-4 text-neutral-500 dark:text-neutral-400" style="width: 20px !important; height: 20px !important;" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125"/>
</svg>
</button>
{{-- Lock icon centered --}}
<div class="absolute inset-0 flex items-center justify-center">
<div class="w-14 h-14 rounded-full bg-neutral-800/80 flex items-center justify-center">
<svg class="w-7 h-7 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"/>
</svg>
</div>
</div>
</div>
{{-- Password caption --}}
<div class="mt-2.5">
<p class="text-sm font-bold text-neutral-800 dark:text-neutral-100">
{{ __('filament-short-url::default.password_status_active') }}
</p>
<p class="text-[12px] text-neutral-500 dark:text-neutral-400 mt-0.5 leading-snug">
{{ __('filament-short-url::default.password_prompt_preview_desc') }}
</p>
</div>
</div>
<div x-show="!scraping && !passwordProtected" x-cloak>
{{-- ===== DEFAULT / IMAGE STATE ===== --}}
<div class="relative w-full rounded-xl overflow-hidden border border-neutral-200 dark:border-neutral-700 qr-preview-dots-bg aspect-[16/10] flex items-center justify-center">
{{-- Edit button (same style as QR preview: small, top-right) --}}
<button
type="button"
onclick="
const tabs = document.querySelectorAll('[role=tab]');
tabs.forEach(t => { if (t.textContent.trim().includes('SEO') || t.textContent.trim().includes('Social') || t.getAttribute('aria-label')?.includes('seo')) { t.click(); } });
"
class="absolute top-2 right-2 z-10 w-8 h-8 rounded-lg bg-white/90 dark:bg-neutral-900/90 border border-neutral-200 dark:border-neutral-700 shadow-sm flex items-center justify-center hover:bg-white dark:hover:bg-neutral-800 transition cursor-pointer"
style="width: 32px !important; height: 32px !important;"
title="{{ __('filament-short-url::default.action_edit') }}"
>
<svg class="w-4 h-4 text-neutral-500 dark:text-neutral-400" style="width: 20px !important; height: 20px !important;" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125"/>
</svg>
</button>
@if ($ogImageUrl)
{{-- OG Image preview --}}
<img src="{{ $ogImageUrl }}" alt="OG Preview" class="absolute inset-0 w-full h-full object-cover">
@else
{{-- Empty state: photo icon + text --}}
<div class="flex flex-col items-center gap-2 text-neutral-400 dark:text-neutral-500 select-none pointer-events-none">
<svg class="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.3">
<rect x="3" y="3" width="18" height="18" rx="3" stroke-width="1.3" stroke="currentColor" fill="none"/>
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16l5-5a2 2 0 012.8 0L16 16m-2-2l1.5-1.5a2 2 0 012.8 0L20 14"/>
<circle cx="8.5" cy="8.5" r="1.5" fill="currentColor" stroke="none"/>
</svg>
<span class="text-[11px] font-medium text-center leading-snug">
{{ __('filament-short-url::default.og_empty_state_hint') }}
</span>
</div>
@endif
</div>
{{-- Title / Description caption --}}
<div class="mt-2.5 space-y-0.5">
<p class="text-sm font-bold {{ $ogTitle ? 'text-neutral-800 dark:text-neutral-100' : 'text-neutral-400 dark:text-neutral-600' }}">
{{ $ogTitle ?: __('filament-short-url::default.og_title_placeholder') }}
</p>
<p class="text-[12px] {{ $ogDescription ? 'text-neutral-500 dark:text-neutral-400' : 'text-neutral-300 dark:text-neutral-600' }} leading-snug">
{{ $ogDescription ?: __('filament-short-url::default.og_description_placeholder') }}
</p>
</div>
</div>
</div>

View File

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

View File

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

View File

@@ -0,0 +1,22 @@
<div class="flex items-center gap-1.5 text-gray-500 dark:text-gray-400 text-xs mt-2">
<svg class="w-3.5 h-3.5 flex-shrink-0 text-gray-400 dark:text-gray-500"
style="transform: scaleY(-1);"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2.5"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 15l6-6m0 0l-6-6m6 6H9a6 6 0 00-6 6v3" />
</svg>
@if(($record->destination_type ?? 'single') === 'split')
<span class="truncate max-w-[50ch] text-[#273144] dark:text-gray-300 text-[15px] leading-[16px] font-semibold italic">
{{ __('filament-short-url::default.destination_type_split') }}
</span>
@else
<span onclick="event.preventDefault(); event.stopPropagation(); window.open('{{ e($record->destination_url) }}', '_blank', 'noopener,noreferrer');"
class="truncate max-w-[50ch] hover:underline text-[#273144] dark:text-gray-350 text-[15px] leading-[16px] font-medium cursor-pointer"
title="{{ e($record->destination_url) }}">
{{ $record->destination_url }}
</span>
@endif
</div>

View File

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

View File

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

View File

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

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