diff --git a/README.md b/README.md index 62c2721..77d169c 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,21 @@ A professional, high-performance **Short URL Manager** plugin for [Filament v5](https://filamentphp.com). Built from scratch with cutting-edge practices, proxy resistance, offline Geo-IP engines, enterprise-grade smart targeting, and zero external shortening API dependencies. +## Screenshots + +

+ + + + + + + + + +
Dashboard StatsShort URL Creation Form
Targeting and RulesInteractive Settings Panel
+

+ --- ## Features @@ -26,7 +41,7 @@ A professional, high-performance **Short URL Manager** plugin for [Filament v5]( - ⚡ **Ultra-Fast Redirects** — Redirections resolve in milliseconds. Analytical tasks, event dispatching, and GA4 payloads are processed asynchronously via Laravel Queue jobs. - 🎯 **Google Analytics 4 server-side tracking** — Native integration with the GA4 Measurement Protocol to bypass client-side AdBlockers completely. - ⚙️ **Dual-way UTM Campaign Builder** — Built-in form builder synchronizes UTM parameters with your destination URLs in real-time (two-way binding). -- 🔒 **Link Rules & Expiry** — Disable links manually, set expiration dates, or activate single-use links that deactivate automatically after the first click. +- 🔒 **Link Validity Ranges & Expiry** — Set activation date ranges (From - To), custom visit limit counters (e.g. active for 3 clicks then expires), single-use restrictions, and custom fallback redirect URLs on expiration instead of static 410 Gone errors. - ➡️ **Query Parameter Forwarding** — Dynamically forward client query parameters (e.g. ad tokens, discount codes) to the destination URL. - 🛠️ **Dedicated Settings GUI** — Manage global configuration (routing, Geo-IP, GA4, cache, rate limiting, aggregation) directly inside the Filament panel without modifying files or `.env` files. - 💻 **Fluent Developer Builder** — Native model query builder pattern and robust programmatic generation APIs. @@ -436,10 +451,14 @@ echo $shortUrl->getShortUrl(); // https://yourdomain.com/s/promo2026 | `notes()` | `string` | Appends internal notes for administrators. | | `singleUse()` | `bool` (default `true`) | Makes the short URL expire immediately after the first successful click. | | `forwardQueryParams()` | `bool` (default `true`) | Forwards client-side incoming parameters (e.g. `?gclid=xxx`) to the final target. | -| `expiresAt()` | `DateTimeInterface\|Carbon\|null` | Automatically sets a timestamp after which the URL returns a `410 Gone` error. | +| `expiresAt()` | `DateTimeInterface\|Carbon\|null` | Automatically sets an expiration timestamp after which the URL is inactive. | +| `activatedAt()` | `DateTimeInterface\|Carbon\|null` | Sets the timestamp from which the URL is active. | +| `deactivatedAt()` | `DateTimeInterface\|Carbon\|null` | Sets the timestamp after which the URL is inactive. | +| `maxVisits()` | `int\|null` | Sets a custom limit of total visits allowed. | +| `expirationRedirectUrl()` | `string\|null` | Sets a custom URL to redirect to when expired/inactive. | | `trackVisits()` | `bool` (default `true`) | Toggles statistical logging for this link. | | `withTracing()` | `array` | Appends non-empty tracking parameters (like UTM codes) directly to the target URL. | -| `create()` | `void` | Persists the model to the database and returns the `ShortUrl` instance. | +| `create()` | `ShortUrl` | Persists the model to the database and returns the `ShortUrl` instance. | --- @@ -501,7 +520,15 @@ All migrations are compatible with **SQLite**, **MySQL**, and **PostgreSQL**: ## Changelog -### v1.3.0 (Latest) +### v1.4.0 (Latest) +- **Validity Date Ranges (From-To)** — Set activation dates (`activated_at` and `expires_at`) to control exactly when a short link is active. +- **Custom Visit Limit Counters** — Define a custom maximum visit limit (`max_visits`) after which a link automatically expires (e.g., active for 3 hits). +- **Custom Expiration Fallbacks** — Redirect expired/inactive visitors to a custom `expiration_redirect_url` rather than showing a static 410 Gone error page. +- **Reactive Validity Controls** — Master switch to toggle date limits, including bidirectional datetime picker constraints (Active From cannot exceed Expires At and vice versa) and custom UI field visibility. +- **Smart Model Observers** — Automatic cleanup of unused parameters (like clearing `max_visits` if `single_use` is enabled, and clearing expiration fallbacks if date limits are off) to guarantee database consistency. +- **Fluent Builder APIs** — Fluent method additions (`activatedAt()`, `deactivatedAt()`, `maxVisits()`, `expirationRedirectUrl()`) in the developer query builder. + +### v1.3.0 - **Automatic Scheduler Registration** — Zero-configuration task registration within the ServiceProvider booted phase (dynamically honors Settings toggles). - **Interactive Settings Validators** — Adds real-time "Test connection" verify action for GA4 Measurement Protocol and "Verify file" action for MaxMind database paths. - **Robust Table Row Copy Action** — High-reliability, conflict-free click-to-copy in table rows with built-in fallback helper for non-HTTPS (secure context) browser environments. diff --git a/art/1.png b/art/1.png new file mode 100644 index 0000000..50ed1ca Binary files /dev/null and b/art/1.png differ diff --git a/art/2.png b/art/2.png new file mode 100644 index 0000000..7ab3e38 Binary files /dev/null and b/art/2.png differ diff --git a/art/3.png b/art/3.png new file mode 100644 index 0000000..ccebce5 Binary files /dev/null and b/art/3.png differ diff --git a/art/4.png b/art/4.png new file mode 100644 index 0000000..3f16675 Binary files /dev/null and b/art/4.png differ diff --git a/config/filament-short-url.php b/config/filament-short-url.php index d34c75a..3be1103 100644 --- a/config/filament-short-url.php +++ b/config/filament-short-url.php @@ -93,6 +93,15 @@ return [ */ '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'), + /* |-------------------------------------------------------------------------- | Redirect Cache TTL @@ -127,8 +136,8 @@ return [ 'foreground_color' => '#000000', 'background_color' => '#ffffff', 'gradient_enabled' => false, - 'gradient_from' => '#000000', - 'gradient_to' => '#ffffff', + 'gradient_from' => '#4f46e5', + 'gradient_to' => '#06b6d4', 'gradient_type' => 'linear', ], diff --git a/database/migrations/2026_06_02_000006_add_max_visits_and_expiration_redirect_to_short_urls_table.php b/database/migrations/2026_06_02_000006_add_max_visits_and_expiration_redirect_to_short_urls_table.php new file mode 100644 index 0000000..e3271f5 --- /dev/null +++ b/database/migrations/2026_06_02_000006_add_max_visits_and_expiration_redirect_to_short_urls_table.php @@ -0,0 +1,23 @@ +unsignedInteger('max_visits')->nullable(); + $table->string('expiration_redirect_url', 2048)->nullable(); + }); + } + + public function down(): void + { + Schema::table('short_urls', function (Blueprint $table): void { + $table->dropColumn(['max_visits', 'expiration_redirect_url']); + }); + } +}; diff --git a/resources/lang/en/countries.php b/resources/lang/en/countries.php new file mode 100644 index 0000000..464b662 --- /dev/null +++ b/resources/lang/en/countries.php @@ -0,0 +1,253 @@ + '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', +]; diff --git a/resources/lang/en/default.php b/resources/lang/en/default.php index 6f18605..7b98e6e 100644 --- a/resources/lang/en/default.php +++ b/resources/lang/en/default.php @@ -24,6 +24,13 @@ 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.', + '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', 'notes' => 'Internal Notes', 'ga_tracking_id' => 'Google Analytics 4 Measurement ID', 'ga_tracking_id_helper' => 'Optional G-XXXXXXXXXX ID to track redirects server-side.', @@ -288,6 +295,39 @@ return [ '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_geoip_stats_cache_ttl' => 'Stats Dashboard Cache TTL', + 'settings_geoip_stats_cache_ttl_helper' => 'Number of seconds to cache query metrics on the dashboard statistics page (prevents database load). Default: 300 (5 minutes).', + + // 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', + + // 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.', diff --git a/resources/lang/pl/countries.php b/resources/lang/pl/countries.php new file mode 100644 index 0000000..71a4d9c --- /dev/null +++ b/resources/lang/pl/countries.php @@ -0,0 +1,253 @@ + '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', +]; diff --git a/resources/lang/pl/default.php b/resources/lang/pl/default.php index 072a1a6..28f8061 100644 --- a/resources/lang/pl/default.php +++ b/resources/lang/pl/default.php @@ -24,6 +24,13 @@ 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.', + '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', '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.', @@ -289,6 +296,39 @@ return [ '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_geoip_stats_cache_ttl' => 'TTL cache statystyk panelu', + 'settings_geoip_stats_cache_ttl_helper' => 'Liczba sekund buforowania wyników zapytań na stronie statystyk (zapobiega obciążeniu bazy danych). Domyślnie: 300 (5 minut).', + + // 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', + + // 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ć.', diff --git a/src/Filament/Resources/ShortUrlResource/Pages/ShortUrlSettingsPage.php b/src/Filament/Resources/ShortUrlResource/Pages/ShortUrlSettingsPage.php index 6e92ce7..9075fec 100644 --- a/src/Filament/Resources/ShortUrlResource/Pages/ShortUrlSettingsPage.php +++ b/src/Filament/Resources/ShortUrlResource/Pages/ShortUrlSettingsPage.php @@ -5,6 +5,7 @@ namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages; use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource; use Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager; use Filament\Actions\Action; +use Filament\Forms\Components\ColorPicker; use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; @@ -44,7 +45,9 @@ class ShortUrlSettingsPage extends Page implements HasForms 'geo_ip_cache_ttl' => $mgr->get('geo_ip_cache_ttl', 86400), 'geo_ip_timeout' => $mgr->get('geo_ip_timeout', 3), 'maxmind_database_path' => $mgr->get('maxmind_database_path', storage_path('geoip/GeoLite2-Country.mmdb')), + 'geo_ip_stats_cache_ttl' => $mgr->get('geo_ip_stats_cache_ttl', 300), 'queue_connection' => $mgr->get('queue_connection', 'sync'), + 'queue_name' => $mgr->get('queue_name', 'default'), 'ga4_api_secret' => $mgr->get('ga4_api_secret'), 'ga4_firebase_app_id' => $mgr->get('ga4_firebase_app_id'), 'counter_buffering_enabled' => $mgr->get('counter_buffering_enabled', false), @@ -54,6 +57,23 @@ class ShortUrlSettingsPage extends Page implements HasForms 'rate_limiting_enabled' => $mgr->get('rate_limiting_enabled', false), 'rate_limiting_max_attempts' => $mgr->get('rate_limiting_max_attempts', 60), 'rate_limiting_decay_seconds' => $mgr->get('rate_limiting_decay_seconds', 60), + 'tracking_enabled' => $mgr->get('tracking_enabled', true), + 'tracking_fields_ip_address' => $mgr->get('tracking_fields_ip_address', true), + 'tracking_fields_browser' => $mgr->get('tracking_fields_browser', true), + 'tracking_fields_browser_version' => $mgr->get('tracking_fields_browser_version', true), + 'tracking_fields_operating_system' => $mgr->get('tracking_fields_operating_system', true), + 'tracking_fields_operating_system_version' => $mgr->get('tracking_fields_operating_system_version', true), + 'tracking_fields_referer_url' => $mgr->get('tracking_fields_referer_url', true), + 'tracking_fields_device_type' => $mgr->get('tracking_fields_device_type', true), + 'qr_size' => $mgr->get('qr_size', 300), + 'qr_margin' => $mgr->get('qr_margin', 1), + 'qr_dot_style' => $mgr->get('qr_dot_style', 'square'), + 'qr_foreground_color' => $mgr->get('qr_foreground_color', '#000000'), + 'qr_background_color' => $mgr->get('qr_background_color', '#ffffff'), + 'qr_gradient_enabled' => $mgr->get('qr_gradient_enabled', false), + 'qr_gradient_from' => $mgr->get('qr_gradient_from', '#4f46e5'), + 'qr_gradient_to' => $mgr->get('qr_gradient_to', '#06b6d4'), + 'qr_gradient_type' => $mgr->get('qr_gradient_type', 'linear'), ]); } @@ -91,6 +111,7 @@ class ShortUrlSettingsPage extends Page implements HasForms ->label(__('filament-short-url::default.settings_key_length')) ->helperText(__('filament-short-url::default.settings_key_length_helper')) ->numeric() + ->integer() ->minValue(4) ->maxValue(20) ->required(), @@ -99,6 +120,7 @@ class ShortUrlSettingsPage extends Page implements HasForms ->label(__('filament-short-url::default.settings_cache_ttl')) ->helperText(__('filament-short-url::default.settings_cache_ttl_helper')) ->numeric() + ->integer() ->minValue(0) ->suffix('s') ->required(), @@ -111,6 +133,7 @@ class ShortUrlSettingsPage extends Page implements HasForms ]), Section::make(__('filament-short-url::default.settings_section_queue')) + ->columns(2) ->schema([ Select::make('queue_connection') ->label(__('filament-short-url::default.settings_queue_connection')) @@ -125,6 +148,11 @@ class ShortUrlSettingsPage extends Page implements HasForms ]; }) ->required(), + + TextInput::make('queue_name') + ->label(__('filament-short-url::default.settings_queue_name')) + ->helperText(__('filament-short-url::default.settings_queue_name_helper')) + ->required(), ]), Section::make(__('filament-short-url::default.settings_section_buffering')) @@ -168,6 +196,18 @@ class ShortUrlSettingsPage extends Page implements HasForms ->label(__('filament-short-url::default.settings_geoip_cache_ttl')) ->helperText(__('filament-short-url::default.settings_geoip_cache_ttl_helper')) ->numeric() + ->integer() + ->minValue(0) + ->suffix('s') + ->required() + ->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled')), + + // ── Stats Cache TTL (only when geo-ip is on) ── + TextInput::make('geo_ip_stats_cache_ttl') + ->label(__('filament-short-url::default.settings_geoip_stats_cache_ttl')) + ->helperText(__('filament-short-url::default.settings_geoip_stats_cache_ttl_helper')) + ->numeric() + ->integer() ->minValue(0) ->suffix('s') ->required() @@ -178,6 +218,7 @@ class ShortUrlSettingsPage extends Page implements HasForms ->label(__('filament-short-url::default.settings_geoip_timeout')) ->helperText(__('filament-short-url::default.settings_geoip_timeout_helper')) ->numeric() + ->integer() ->minValue(1) ->maxValue(30) ->suffix('s') @@ -354,6 +395,7 @@ class ShortUrlSettingsPage extends Page implements HasForms ->label(__('filament-short-url::default.settings_rate_limiting_max_attempts')) ->helperText(__('filament-short-url::default.settings_rate_limiting_max_attempts_helper')) ->numeric() + ->integer() ->minValue(1) ->required() ->visible(fn (Get $get): bool => (bool) $get('rate_limiting_enabled')), @@ -362,12 +404,139 @@ class ShortUrlSettingsPage extends Page implements HasForms ->label(__('filament-short-url::default.settings_rate_limiting_decay_seconds')) ->helperText(__('filament-short-url::default.settings_rate_limiting_decay_seconds_helper')) ->numeric() + ->integer() ->minValue(1) ->suffix('s') ->required() ->visible(fn (Get $get): bool => (bool) $get('rate_limiting_enabled')), ]), ]), + + // ── Tracking Defaults ───────────────────────────────── + Tab::make(__('filament-short-url::default.settings_tab_tracking_defaults')) + ->icon('heroicon-o-chart-bar') + ->schema([ + Section::make(__('filament-short-url::default.settings_section_tracking_defaults')) + ->description(__('filament-short-url::default.settings_section_tracking_defaults_helper')) + ->schema([ + Toggle::make('tracking_enabled') + ->label(__('filament-short-url::default.settings_track_visits_default')) + ->live() + ->inline(false) + ->columnSpanFull(), + + Toggle::make('tracking_fields_ip_address') + ->label(__('filament-short-url::default.settings_track_ip_default')) + ->inline(false) + ->disabled(fn (Get $get): bool => ! $get('tracking_enabled')), + + Toggle::make('tracking_fields_browser') + ->label(__('filament-short-url::default.settings_track_browser_default')) + ->inline(false) + ->disabled(fn (Get $get): bool => ! $get('tracking_enabled')), + + Toggle::make('tracking_fields_browser_version') + ->label(__('filament-short-url::default.settings_track_browser_version_default')) + ->inline(false) + ->disabled(fn (Get $get): bool => ! $get('tracking_enabled')), + + Toggle::make('tracking_fields_operating_system') + ->label(__('filament-short-url::default.settings_track_os_default')) + ->inline(false) + ->disabled(fn (Get $get): bool => ! $get('tracking_enabled')), + + Toggle::make('tracking_fields_operating_system_version') + ->label(__('filament-short-url::default.settings_track_os_version_default')) + ->inline(false) + ->disabled(fn (Get $get): bool => ! $get('tracking_enabled')), + + Toggle::make('tracking_fields_referer_url') + ->label(__('filament-short-url::default.settings_track_referer_default')) + ->inline(false) + ->disabled(fn (Get $get): bool => ! $get('tracking_enabled')), + + Toggle::make('tracking_fields_device_type') + ->label(__('filament-short-url::default.settings_track_device_type_default')) + ->inline(false) + ->disabled(fn (Get $get): bool => ! $get('tracking_enabled')), + ]) + ->columns(4), + ]), + + // ── QR Defaults ────────────────────────────────────── + Tab::make(__('filament-short-url::default.settings_tab_qr')) + ->icon('heroicon-o-qr-code') + ->schema([ + Section::make(__('filament-short-url::default.settings_section_qr_defaults')) + ->description(__('filament-short-url::default.settings_section_qr_defaults_helper')) + ->columns(2) + ->schema([ + TextInput::make('qr_size') + ->label(__('filament-short-url::default.settings_qr_size')) + ->numeric() + ->integer() + ->minValue(100) + ->maxValue(2000) + ->required(), + + Select::make('qr_margin') + ->label(__('filament-short-url::default.settings_qr_margin')) + ->options(array_combine(range(0, 10), range(0, 10))) + ->required(), + + Select::make('qr_dot_style') + ->label(__('filament-short-url::default.settings_qr_dot_style')) + ->options([ + 'square' => __('filament-short-url::default.qr_option_square'), + 'dots' => __('filament-short-url::default.qr_option_dots'), + 'rounded' => __('filament-short-url::default.qr_option_rounded'), + 'classy' => __('filament-short-url::default.qr_option_classy'), + 'classy-rounded' => __('filament-short-url::default.qr_option_classy_rounded'), + 'extra-rounded' => __('filament-short-url::default.qr_option_extra_rounded'), + ]) + ->required(), + + ColorPicker::make('qr_foreground_color') + ->label(__('filament-short-url::default.settings_qr_foreground_color')) + ->regex('/^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})\b$/') + ->placeholder('#000000') + ->required(), + + ColorPicker::make('qr_background_color') + ->label(__('filament-short-url::default.settings_qr_background_color')) + ->regex('/^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})\b$/') + ->placeholder('#ffffff') + ->required(), + + Toggle::make('qr_gradient_enabled') + ->label(__('filament-short-url::default.settings_qr_gradient_enabled')) + ->live() + ->inline(false), + + ColorPicker::make('qr_gradient_from') + ->label(__('filament-short-url::default.settings_qr_gradient_from')) + ->regex('/^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})\b$/') + ->placeholder('#4f46e5') + ->required(fn (Get $get): bool => (bool) $get('qr_gradient_enabled')) + ->visible(fn (Get $get): bool => (bool) $get('qr_gradient_enabled')), + + ColorPicker::make('qr_gradient_to') + ->label(__('filament-short-url::default.settings_qr_gradient_to')) + ->regex('/^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})\b$/') + ->placeholder('#06b6d4') + ->required(fn (Get $get): bool => (bool) $get('qr_gradient_enabled')) + ->visible(fn (Get $get): bool => (bool) $get('qr_gradient_enabled')), + + Select::make('qr_gradient_type') + ->label(__('filament-short-url::default.settings_qr_gradient_type')) + ->options([ + 'linear' => __('filament-short-url::default.qr_gradient_linear'), + 'radial' => __('filament-short-url::default.qr_gradient_radial'), + ]) + ->required(fn (Get $get): bool => (bool) $get('qr_gradient_enabled')) + ->visible(fn (Get $get): bool => (bool) $get('qr_gradient_enabled')), + ]), + ]), ]), ]) ->statePath('data'); diff --git a/src/Filament/Resources/ShortUrlResource/Schemas/ShortUrlForm.php b/src/Filament/Resources/ShortUrlResource/Schemas/ShortUrlForm.php index 6f4dec7..ca99af2 100644 --- a/src/Filament/Resources/ShortUrlResource/Schemas/ShortUrlForm.php +++ b/src/Filament/Resources/ShortUrlResource/Schemas/ShortUrlForm.php @@ -103,34 +103,89 @@ class ShortUrlForm 302 => __('filament-short-url::default.redirect_code_302'), 301 => __('filament-short-url::default.redirect_code_301'), ]) - ->default(302) + ->default(fn () => config('filament-short-url.redirect_status_code', 302)) ->required(), ])->columns(2), - Section::make(__('filament-short-url::default.form_section_options'))->schema([ + Section::make(__('filament-short-url::default.form_section_options'))->schema([ Toggle::make('is_enabled') ->label(__('filament-short-url::default.status')) ->default(true) ->inline(false), - Toggle::make('single_use') - ->label(__('filament-short-url::default.single_use')) - ->helperText(__('filament-short-url::default.single_use_helper')) - ->default(false) - ->inline(false), - Toggle::make('forward_query_params') ->label(__('filament-short-url::default.forward_query_params')) ->helperText(__('filament-short-url::default.forward_query_params_helper')) ->default(false) ->inline(false), - - DateTimePicker::make('expires_at') - ->label(__('filament-short-url::default.expires_at')) - ->nullable() - ->native(false), ])->columns(2), + Section::make(__('filament-short-url::default.form_section_validity')) + ->schema([ + Toggle::make('use_date_validity') + ->label(__('filament-short-url::default.use_date_validity')) + ->dehydrated(false) + ->live() + ->afterStateHydrated(function (Toggle $component, $state, Get $get, Set $set) { + $set('use_date_validity', $get('activated_at') !== null || $get('expires_at') !== null); + }) + ->afterStateUpdated(function ($state, Set $set) { + if ($state) { + $set('activated_at', now()->startOfMinute()); + } else { + $set('activated_at', null); + $set('expires_at', null); + $set('expiration_redirect_url', null); + } + }) + ->columnSpanFull(), + + DateTimePicker::make('activated_at') + ->label(__('filament-short-url::default.activated_at')) + ->nullable() + ->native(false) + ->withoutSeconds() + ->live(onBlur: true) + ->required(fn (Get $get): bool => (bool) $get('use_date_validity')) + ->visible(fn (Get $get): bool => (bool) $get('use_date_validity')) + ->minDate(now()->startOfDay()) + ->maxDate(fn (Get $get) => $get('expires_at')), + + DateTimePicker::make('expires_at') + ->label(__('filament-short-url::default.expires_at')) + ->nullable() + ->native(false) + ->withoutSeconds() + ->live(onBlur: true) + ->visible(fn (Get $get): bool => (bool) $get('use_date_validity')) + ->minDate(fn (Get $get) => $get('activated_at') ?: now()->startOfDay()), + + TextInput::make('expiration_redirect_url') + ->label(__('filament-short-url::default.expiration_redirect_url')) + ->helperText(__('filament-short-url::default.expiration_redirect_url_helper')) + ->url() + ->maxLength(2048) + ->nullable() + ->visible(fn (Get $get): bool => (bool) $get('use_date_validity')) + ->columnSpanFull(), + + Toggle::make('single_use') + ->label(__('filament-short-url::default.single_use')) + ->helperText(__('filament-short-url::default.single_use_helper')) + ->default(false) + ->inline(false) + ->live(), + + TextInput::make('max_visits') + ->label(__('filament-short-url::default.max_visits')) + ->helperText(__('filament-short-url::default.max_visits_helper')) + ->numeric() + ->integer() + ->minValue(1) + ->nullable() + ->hidden(fn (Get $get): bool => (bool) $get('single_use')), + ])->columns(2), + Section::make(__('filament-short-url::default.form_section_notes'))->schema([ Textarea::make('notes') ->label(__('filament-short-url::default.notes')) @@ -183,7 +238,7 @@ class ShortUrlForm ->schema([ Toggle::make('track_visits') ->label(__('filament-short-url::default.track_visits')) - ->default(true) + ->default(fn () => config('filament-short-url.tracking.enabled', true)) ->live() ->inline(false) ->columnSpanFull(), @@ -193,43 +248,43 @@ class ShortUrlForm ->schema([ Toggle::make('track_ip_address') ->label(__('filament-short-url::default.track_ip')) - ->default(true) + ->default(fn () => config('filament-short-url.tracking.fields.ip_address', true)) ->inline(false) ->disabled(fn (Get $get): bool => ! $get('track_visits')), Toggle::make('track_browser') ->label(__('filament-short-url::default.track_browser')) - ->default(true) + ->default(fn () => config('filament-short-url.tracking.fields.browser', true)) ->inline(false) ->disabled(fn (Get $get): bool => ! $get('track_visits')), Toggle::make('track_browser_version') ->label(__('filament-short-url::default.track_browser_version')) - ->default(true) + ->default(fn () => config('filament-short-url.tracking.fields.browser_version', true)) ->inline(false) ->disabled(fn (Get $get): bool => ! $get('track_visits')), Toggle::make('track_operating_system') ->label(__('filament-short-url::default.track_os')) - ->default(true) + ->default(fn () => config('filament-short-url.tracking.fields.operating_system', true)) ->inline(false) ->disabled(fn (Get $get): bool => ! $get('track_visits')), Toggle::make('track_operating_system_version') ->label(__('filament-short-url::default.track_os_version')) - ->default(true) + ->default(fn () => config('filament-short-url.tracking.fields.operating_system_version', true)) ->inline(false) ->disabled(fn (Get $get): bool => ! $get('track_visits')), Toggle::make('track_device_type') ->label(__('filament-short-url::default.track_device_type')) - ->default(true) + ->default(fn () => config('filament-short-url.tracking.fields.device_type', true)) ->inline(false) ->disabled(fn (Get $get): bool => ! $get('track_visits')), Toggle::make('track_referer_url') ->label(__('filament-short-url::default.track_referer')) - ->default(true) + ->default(fn () => config('filament-short-url.tracking.fields.referer_url', true)) ->inline(false) ->disabled(fn (Get $get): bool => ! $get('track_visits')), ]) @@ -378,39 +433,17 @@ class ShortUrlForm ->schema([ Select::make('country_code') ->label(__('filament-short-url::default.country_code')) - ->options([ - 'PL' => 'Poland', - 'US' => 'United States', - 'GB' => 'United Kingdom', - 'DE' => 'Germany', - 'FR' => 'France', - 'ES' => 'Spain', - 'IT' => 'Italy', - 'CA' => 'Canada', - 'AU' => 'Australia', - 'NL' => 'Netherlands', - 'UA' => 'Ukraine', - 'IE' => 'Ireland', - 'BE' => 'Belgium', - 'AT' => 'Austria', - 'CH' => 'Switzerland', - 'SE' => 'Sweden', - 'NO' => 'Norway', - 'DK' => 'Denmark', - 'FI' => 'Finland', - 'CZ' => 'Czech Republic', - 'SK' => 'Slovakia', - 'HU' => 'Hungary', - 'RO' => 'Romania', - 'GR' => 'Greece', - 'PT' => 'Portugal', - 'BR' => 'Brazil', - 'MX' => 'Mexico', - 'CN' => 'China', - 'JP' => 'Japan', - 'IN' => 'India', - ]) + ->options(function (): array { + $countries = __('filament-short-url::countries'); + if (is_array($countries)) { + asort($countries, SORT_LOCALE_STRING); + return $countries; + } + return []; + }) ->searchable() + ->optionsLimit(300) + ->disableOptionsWhenSelectedInSiblingRepeaterItems() ->required(), TextInput::make('url') ->label(__('filament-short-url::default.destination_url')) diff --git a/src/FilamentShortUrlServiceProvider.php b/src/FilamentShortUrlServiceProvider.php index 0278896..6908043 100644 --- a/src/FilamentShortUrlServiceProvider.php +++ b/src/FilamentShortUrlServiceProvider.php @@ -31,6 +31,7 @@ class FilamentShortUrlServiceProvider extends PackageServiceProvider '2026_06_01_000003_add_utm_city_referer_to_short_url_visits_table', '2026_06_01_000004_add_targeting_and_security_to_short_urls_table', '2026_06_01_000005_create_short_url_daily_stats_table', + '2026_06_02_000006_add_max_visits_and_expiration_redirect_to_short_urls_table', ]) ->hasCommands([ SyncBufferedCountersCommand::class, diff --git a/src/Http/Controllers/ShortUrlRedirectController.php b/src/Http/Controllers/ShortUrlRedirectController.php index a2b39c0..384b55a 100644 --- a/src/Http/Controllers/ShortUrlRedirectController.php +++ b/src/Http/Controllers/ShortUrlRedirectController.php @@ -27,8 +27,12 @@ class ShortUrlRedirectController extends Controller abort(404); } - // 410 Gone if disabled or expired + // Redirect to custom expiration URL if defined, otherwise 410 Gone if disabled or expired if (! $shortUrl->isActive()) { + if ($shortUrl->expiration_redirect_url) { + return redirect()->away($shortUrl->expiration_redirect_url, 302); + } + abort(410); } diff --git a/src/Models/ShortUrl.php b/src/Models/ShortUrl.php index 90d8320..b6029e9 100644 --- a/src/Models/ShortUrl.php +++ b/src/Models/ShortUrl.php @@ -78,6 +78,8 @@ class ShortUrl extends Model 'targeting_rules', 'total_visits', 'unique_visits', + 'max_visits', + 'expiration_redirect_url', ]; /** @var array */ @@ -96,6 +98,7 @@ class ShortUrl extends Model 'qr_options' => 'array', 'show_warning_page' => 'boolean', 'targeting_rules' => 'array', + 'max_visits' => 'integer', 'activated_at' => 'datetime', 'deactivated_at' => 'datetime', 'expires_at' => 'datetime', @@ -124,9 +127,21 @@ class ShortUrl extends Model { return $query ->enabled() + ->where(fn (Builder $q) => $q + ->whereNull('activated_at') + ->orWhere('activated_at', '<=', now()) + ) + ->where(fn (Builder $q) => $q + ->whereNull('deactivated_at') + ->orWhere('deactivated_at', '>', now()) + ) ->where(fn (Builder $q) => $q ->whereNull('expires_at') ->orWhere('expires_at', '>', now()) + ) + ->where(fn (Builder $q) => $q + ->whereNull('max_visits') + ->orWhereRaw('total_visits < max_visits') ); } @@ -163,6 +178,16 @@ class ShortUrl extends Model */ protected static function booted(): void { + static::saving(function (self $m) { + if ($m->single_use) { + $m->max_visits = null; + } + + if ($m->activated_at === null && $m->expires_at === null) { + $m->expiration_redirect_url = null; + } + }); + static::saved(fn (self $m) => cache()->forget("filament-short-url:{$m->url_key}")); static::deleted(fn (self $m) => cache()->forget("filament-short-url:{$m->url_key}")); @@ -210,12 +235,25 @@ class ShortUrl extends Model return false; } + // Visit limit reached + if (! $this->single_use && $this->max_visits !== null && $this->total_visits >= $this->max_visits) { + return false; + } + return true; } public function isExpired(): bool { - return $this->expires_at !== null && $this->expires_at->isPast(); + if ($this->expires_at !== null && $this->expires_at->isPast()) { + return true; + } + + if (! $this->single_use && $this->max_visits !== null && $this->total_visits >= $this->max_visits) { + return true; + } + + return false; } public function trackingEnabled(): bool diff --git a/src/Services/ShortUrlBuilder.php b/src/Services/ShortUrlBuilder.php index 8b71b6b..7776810 100644 --- a/src/Services/ShortUrlBuilder.php +++ b/src/Services/ShortUrlBuilder.php @@ -51,6 +51,34 @@ class ShortUrlBuilder return $this; } + public function activatedAt(\DateTimeInterface|Carbon|null $date): static + { + $this->data['activated_at'] = $date ? Carbon::instance($date) : null; + + return $this; + } + + public function deactivatedAt(\DateTimeInterface|Carbon|null $date): static + { + $this->data['deactivated_at'] = $date ? Carbon::instance($date) : null; + + return $this; + } + + public function maxVisits(?int $maxVisits): static + { + $this->data['max_visits'] = $maxVisits; + + return $this; + } + + public function expirationRedirectUrl(?string $url): static + { + $this->data['expiration_redirect_url'] = $url; + + return $this; + } + public function trackVisits(bool $track = true): static { $this->data['track_visits'] = $track; diff --git a/src/Services/ShortUrlSettingsManager.php b/src/Services/ShortUrlSettingsManager.php index 4ed97fb..6a4e078 100644 --- a/src/Services/ShortUrlSettingsManager.php +++ b/src/Services/ShortUrlSettingsManager.php @@ -2,6 +2,7 @@ namespace Bjanczak\FilamentShortUrl\Services; +use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\File; class ShortUrlSettingsManager @@ -45,9 +46,11 @@ class ShortUrlSettingsManager 'geo_ip_cache_ttl' => config('filament-short-url.geo_ip.cache_ttl', 86400), 'geo_ip_timeout' => config('filament-short-url.geo_ip.timeout', 3), 'maxmind_database_path' => config('filament-short-url.geo_ip.maxmind.database_path', storage_path('geoip/GeoLite2-Country.mmdb')), + 'geo_ip_stats_cache_ttl' => config('filament-short-url.geo_ip.stats_cache_ttl', 300), 'ga4_api_secret' => config('filament-short-url.ga4.api_secret'), 'ga4_firebase_app_id' => config('filament-short-url.ga4.firebase_app_id'), 'queue_connection' => config('filament-short-url.queue_connection', 'sync'), + 'queue_name' => config('filament-short-url.queue_name', 'default'), 'cache_ttl' => config('filament-short-url.cache_ttl', 3600), 'counter_buffering_enabled' => config('filament-short-url.counter_buffering.enabled', false), 'trust_cdn_headers' => config('filament-short-url.trust_cdn_headers', false), @@ -56,6 +59,23 @@ class ShortUrlSettingsManager 'rate_limiting_enabled' => config('filament-short-url.rate_limiting.enabled', false), 'rate_limiting_max_attempts' => config('filament-short-url.rate_limiting.max_attempts', 60), 'rate_limiting_decay_seconds' => config('filament-short-url.rate_limiting.decay_seconds', 60), + 'tracking_enabled' => config('filament-short-url.tracking.enabled', true), + 'tracking_fields_ip_address' => config('filament-short-url.tracking.fields.ip_address', true), + 'tracking_fields_browser' => config('filament-short-url.tracking.fields.browser', true), + 'tracking_fields_browser_version' => config('filament-short-url.tracking.fields.browser_version', true), + 'tracking_fields_operating_system' => config('filament-short-url.tracking.fields.operating_system', true), + 'tracking_fields_operating_system_version' => config('filament-short-url.tracking.fields.operating_system_version', true), + 'tracking_fields_referer_url' => config('filament-short-url.tracking.fields.referer_url', true), + 'tracking_fields_device_type' => config('filament-short-url.tracking.fields.device_type', true), + 'qr_size' => config('filament-short-url.qr_defaults.size', 300), + 'qr_margin' => config('filament-short-url.qr_defaults.margin', 1), + 'qr_dot_style' => config('filament-short-url.qr_defaults.dot_style', 'square'), + 'qr_foreground_color' => config('filament-short-url.qr_defaults.foreground_color', '#000000'), + 'qr_background_color' => config('filament-short-url.qr_defaults.background_color', '#ffffff'), + 'qr_gradient_enabled' => config('filament-short-url.qr_defaults.gradient_enabled', false), + 'qr_gradient_from' => config('filament-short-url.qr_defaults.gradient_from', '#4f46e5'), + 'qr_gradient_to' => config('filament-short-url.qr_defaults.gradient_to', '#06b6d4'), + 'qr_gradient_type' => config('filament-short-url.qr_defaults.gradient_type', 'linear'), ], $stored); return $this->cache; @@ -80,6 +100,8 @@ class ShortUrlSettingsManager File::makeDirectory($dir, 0755, true); } + $oldPrefix = $this->get('route_prefix'); + // Keep only supported settings keys to prevent bloat $keys = [ 'route_prefix', @@ -90,9 +112,11 @@ class ShortUrlSettingsManager 'geo_ip_cache_ttl', 'geo_ip_timeout', 'maxmind_database_path', + 'geo_ip_stats_cache_ttl', 'ga4_api_secret', 'ga4_firebase_app_id', 'queue_connection', + 'queue_name', 'cache_ttl', 'counter_buffering_enabled', 'trust_cdn_headers', @@ -101,6 +125,23 @@ class ShortUrlSettingsManager 'rate_limiting_enabled', 'rate_limiting_max_attempts', 'rate_limiting_decay_seconds', + 'tracking_enabled', + 'tracking_fields_ip_address', + 'tracking_fields_browser', + 'tracking_fields_browser_version', + 'tracking_fields_operating_system', + 'tracking_fields_operating_system_version', + 'tracking_fields_referer_url', + 'tracking_fields_device_type', + 'qr_size', + 'qr_margin', + 'qr_dot_style', + 'qr_foreground_color', + 'qr_background_color', + 'qr_gradient_enabled', + 'qr_gradient_from', + 'qr_gradient_to', + 'qr_gradient_type', ]; $filtered = array_intersect_key($data, array_flip($keys)); @@ -121,6 +162,9 @@ class ShortUrlSettingsManager if (isset($filtered['geo_ip_timeout'])) { $filtered['geo_ip_timeout'] = (int) $filtered['geo_ip_timeout']; } + if (isset($filtered['geo_ip_stats_cache_ttl'])) { + $filtered['geo_ip_stats_cache_ttl'] = (int) $filtered['geo_ip_stats_cache_ttl']; + } if (isset($filtered['cache_ttl'])) { $filtered['cache_ttl'] = (int) $filtered['cache_ttl']; } @@ -146,11 +190,60 @@ class ShortUrlSettingsManager $filtered['rate_limiting_decay_seconds'] = (int) $filtered['rate_limiting_decay_seconds']; } + // Tracking defaults + if (isset($filtered['tracking_enabled'])) { + $filtered['tracking_enabled'] = (bool) $filtered['tracking_enabled']; + } + if (isset($filtered['tracking_fields_ip_address'])) { + $filtered['tracking_fields_ip_address'] = (bool) $filtered['tracking_fields_ip_address']; + } + if (isset($filtered['tracking_fields_browser'])) { + $filtered['tracking_fields_browser'] = (bool) $filtered['tracking_fields_browser']; + } + if (isset($filtered['tracking_fields_browser_version'])) { + $filtered['tracking_fields_browser_version'] = (bool) $filtered['tracking_fields_browser_version']; + } + if (isset($filtered['tracking_fields_operating_system'])) { + $filtered['tracking_fields_operating_system'] = (bool) $filtered['tracking_fields_operating_system']; + } + if (isset($filtered['tracking_fields_operating_system_version'])) { + $filtered['tracking_fields_operating_system_version'] = (bool) $filtered['tracking_fields_operating_system_version']; + } + if (isset($filtered['tracking_fields_referer_url'])) { + $filtered['tracking_fields_referer_url'] = (bool) $filtered['tracking_fields_referer_url']; + } + if (isset($filtered['tracking_fields_device_type'])) { + $filtered['tracking_fields_device_type'] = (bool) $filtered['tracking_fields_device_type']; + } + + // QR defaults + if (isset($filtered['qr_size'])) { + $filtered['qr_size'] = (int) $filtered['qr_size']; + } + if (isset($filtered['qr_margin'])) { + $filtered['qr_margin'] = (int) $filtered['qr_margin']; + } + if (isset($filtered['qr_gradient_enabled'])) { + $filtered['qr_gradient_enabled'] = (bool) $filtered['qr_gradient_enabled']; + } + File::put($path, json_encode($filtered, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); $this->cache = null; // Apply immediately to current request config $this->applyConfigOverrides(); + + // Clear route cache if route prefix has changed + $newPrefix = $filtered['route_prefix'] ?? null; + if ($oldPrefix !== null && $newPrefix !== null && $oldPrefix !== $newPrefix) { + try { + if (app()->routesAreCached()) { + Artisan::call('route:clear'); + } + } catch (\Throwable) { + // Ignore route clear errors during boot/test + } + } } /** @@ -169,9 +262,11 @@ class ShortUrlSettingsManager 'filament-short-url.geo_ip.cache_ttl' => $settings['geo_ip_cache_ttl'], 'filament-short-url.geo_ip.timeout' => $settings['geo_ip_timeout'], 'filament-short-url.geo_ip.maxmind.database_path' => $settings['maxmind_database_path'], + 'filament-short-url.geo_ip.stats_cache_ttl' => $settings['geo_ip_stats_cache_ttl'], 'filament-short-url.ga4.api_secret' => $settings['ga4_api_secret'], 'filament-short-url.ga4.firebase_app_id' => $settings['ga4_firebase_app_id'], 'filament-short-url.queue_connection' => $settings['queue_connection'], + 'filament-short-url.queue_name' => $settings['queue_name'], 'filament-short-url.cache_ttl' => $settings['cache_ttl'], 'filament-short-url.counter_buffering.enabled' => $settings['counter_buffering_enabled'], 'filament-short-url.trust_cdn_headers' => $settings['trust_cdn_headers'], @@ -180,6 +275,23 @@ class ShortUrlSettingsManager 'filament-short-url.rate_limiting.enabled' => $settings['rate_limiting_enabled'], 'filament-short-url.rate_limiting.max_attempts' => $settings['rate_limiting_max_attempts'], 'filament-short-url.rate_limiting.decay_seconds' => $settings['rate_limiting_decay_seconds'], + 'filament-short-url.tracking.enabled' => $settings['tracking_enabled'], + 'filament-short-url.tracking.fields.ip_address' => $settings['tracking_fields_ip_address'], + 'filament-short-url.tracking.fields.browser' => $settings['tracking_fields_browser'], + 'filament-short-url.tracking.fields.browser_version' => $settings['tracking_fields_browser_version'], + 'filament-short-url.tracking.fields.operating_system' => $settings['tracking_fields_operating_system'], + 'filament-short-url.tracking.fields.operating_system_version' => $settings['tracking_fields_operating_system_version'], + 'filament-short-url.tracking.fields.referer_url' => $settings['tracking_fields_referer_url'], + 'filament-short-url.tracking.fields.device_type' => $settings['tracking_fields_device_type'], + 'filament-short-url.qr_defaults.size' => $settings['qr_size'], + 'filament-short-url.qr_defaults.margin' => $settings['qr_margin'], + 'filament-short-url.qr_defaults.dot_style' => $settings['qr_dot_style'], + 'filament-short-url.qr_defaults.foreground_color' => $settings['qr_foreground_color'], + 'filament-short-url.qr_defaults.background_color' => $settings['qr_background_color'], + 'filament-short-url.qr_defaults.gradient_enabled' => $settings['qr_gradient_enabled'], + 'filament-short-url.qr_defaults.gradient_from' => $settings['qr_gradient_from'], + 'filament-short-url.qr_defaults.gradient_to' => $settings['qr_gradient_to'], + 'filament-short-url.qr_defaults.gradient_type' => $settings['qr_gradient_type'], ]); } }