2 Commits

9 changed files with 205 additions and 65 deletions

View File

@@ -363,7 +363,23 @@ $shortUrl->update([
]);
```
#### 3. A/B Split Rotation
#### 3. Browser Language-Based Redirects (new in v2.0.5)
Route visitors to language-specific URLs based on their browser's language preferences (detected from the `Accept-Language` header). Fully supports matching exact regional locales (e.g., `en-US`, `en-GB`) with automatic fallback to base language codes (e.g., `en`, `pl`).
```php
$shortUrl->update([
'targeting_rules' => [
'type' => 'language',
'language' => [
['language_code' => 'pl', 'url' => 'https://pl.example.com'],
['language_code' => 'en-US', 'url' => 'https://us.example.com'],
['language_code' => 'de', 'url' => 'https://de.example.com'],
],
],
]);
```
#### 4. A/B Split Rotation
Distribute traffic across multiple URLs using weighted random selection. Weights are proportional — they do not need to sum to 100.
```php
@@ -576,6 +592,11 @@ curl -X POST https://yourdomain.com/api/short-url/links \
| `pixel_google_id` | string | ❌ | Google Tag / GA4 ID |
| `pixel_linkedin_id` | string | ❌ | LinkedIn Partner ID |
| `webhook_url` | string (URL) | ❌ | Per-link webhook endpoint |
| `targeting_rules` | array | ❌ | JSON targeting rules (device, geo, rotation, language) |
| `password` | string | ❌ | Access protection password |
| `show_warning_page` | boolean | ❌ | Show safety warning page before redirect |
| `track_visits` | boolean | ❌ | Track visitor clicks and logs |
| `track_browser_language` | boolean | ❌ | Track visitor browser language locale |
**Response:** `201 Created` with the created link object.

View File

@@ -299,13 +299,16 @@ 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.',

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

@@ -300,13 +300,16 @@ 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.',

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

@@ -432,6 +432,7 @@ class ShortUrlForm
'none' => __('filament-short-url::default.targeting_type_none'),
'device' => __('filament-short-url::default.targeting_type_device'),
'geo' => __('filament-short-url::default.targeting_type_country'),
'language' => __('filament-short-url::default.targeting_type_language'),
'rotation' => __('filament-short-url::default.targeting_type_rotation'),
])
->default('none')
@@ -487,6 +488,33 @@ class ShortUrlForm
->columns(2)
->visible(fn (Get $get): bool => $get('targeting_rules.type') === 'geo'),
Repeater::make('targeting_rules.language')
->label(__('filament-short-url::default.language_targeting_rules'))
->schema([
Select::make('language_code')
->label(__('filament-short-url::default.language_code'))
->options(function (): array {
$languages = __('filament-short-url::languages');
if (is_array($languages)) {
asort($languages, SORT_LOCALE_STRING);
return $languages;
}
return [];
})
->searchable()
->disableOptionsWhenSelectedInSiblingRepeaterItems()
->required(),
TextInput::make('url')
->label(__('filament-short-url::default.destination_url'))
->url()
->required()
->maxLength(2048),
])
->columns(2)
->visible(fn (Get $get): bool => $get('targeting_rules.type') === 'language'),
Repeater::make('targeting_rules.rotation')
->label(__('filament-short-url::default.rotation_targeting_rules'))
->schema([

View File

@@ -27,37 +27,10 @@ class ShortUrlVisitsRightBreakdown extends Widget
*/
protected function getViewData(): array
{
$languageNames = [
'en' => 'English',
'pl' => 'Polish',
'de' => 'German',
'es' => 'Spanish',
'fr' => 'French',
'it' => 'Italian',
'ru' => 'Russian',
'zh' => 'Chinese',
'ja' => 'Japanese',
'pt' => 'Portuguese',
'nl' => 'Dutch',
'tr' => 'Turkish',
'uk' => 'Ukrainian',
'cs' => 'Czech',
'sv' => 'Swedish',
'no' => 'Norwegian',
'da' => 'Danish',
'fi' => 'Finnish',
'ro' => 'Romanian',
'hu' => 'Hungarian',
'sk' => 'Slovak',
'bg' => 'Bulgarian',
'hr' => 'Croatian',
'sr' => 'Serbian',
'sl' => 'Slovenian',
'et' => 'Estonian',
'lv' => 'Latvian',
'lt' => 'Lithuanian',
'el' => 'Greek',
];
$languageNames = __('filament-short-url::languages');
if (! is_array($languageNames)) {
$languageNames = [];
}
if (! $this->record) {
return [
@@ -84,6 +57,14 @@ class ShortUrlVisitsRightBreakdown extends Widget
public static function getLanguageTranslation(string $langCode): string
{
$langCode = strtolower(trim($langCode));
// 1. Try loading from languages translation file (languages.php)
$translatedLanguages = __('filament-short-url::languages');
if (is_array($translatedLanguages) && isset($translatedLanguages[$langCode])) {
return $translatedLanguages[$langCode];
}
// 2. Try PHP Locale extension
if (class_exists(\Locale::class)) {
try {
$name = \Locale::getDisplayLanguage($langCode, app()->getLocale());
@@ -95,39 +76,8 @@ class ShortUrlVisitsRightBreakdown extends Widget
}
}
$fallback = [
'en' => 'English',
'pl' => 'Polish',
'de' => 'German',
'es' => 'Spanish',
'fr' => 'French',
'it' => 'Italian',
'ru' => 'Russian',
'zh' => 'Chinese',
'ja' => 'Japanese',
'pt' => 'Portuguese',
'nl' => 'Dutch',
'tr' => 'Turkish',
'uk' => 'Ukrainian',
'cs' => 'Czech',
'sv' => 'Swedish',
'no' => 'Norwegian',
'da' => 'Danish',
'fi' => 'Finnish',
'ro' => 'Romanian',
'hu' => 'Hungarian',
'sk' => 'Slovak',
'bg' => 'Bulgarian',
'hr' => 'Croatian',
'sr' => 'Serbian',
'sl' => 'Slovenian',
'et' => 'Estonian',
'lv' => 'Latvian',
'lt' => 'Lithuanian',
'el' => 'Greek',
];
return $fallback[$langCode] ?? strtoupper($langCode);
// 3. Simple fallback
return strtoupper($langCode);
}
/**

View File

@@ -61,6 +61,11 @@ class ShortUrlApiController extends Controller
'pixel_google_id' => 'nullable|string|max:100',
'pixel_linkedin_id' => 'nullable|string|max:100',
'webhook_url' => 'nullable|url|max:2048',
'targeting_rules' => 'nullable|array',
'password' => 'nullable|string|max:255',
'show_warning_page' => 'nullable|boolean',
'track_visits' => 'nullable|boolean',
'track_browser_language' => 'nullable|boolean',
]);
$shortUrl = $this->service->create($validated);
@@ -108,6 +113,11 @@ class ShortUrlApiController extends Controller
'pixel_google_id' => $link->pixel_google_id,
'pixel_linkedin_id' => $link->pixel_linkedin_id,
'webhook_url' => $link->webhook_url,
'targeting_rules' => $link->targeting_rules,
'password' => $link->password,
'show_warning_page' => (bool) $link->show_warning_page,
'track_visits' => (bool) $link->track_visits,
'track_browser_language' => (bool) $link->track_browser_language,
'notes' => $link->notes,
'created_at' => $link->created_at->toIso8601String(),
];

View File

@@ -440,6 +440,43 @@ class ShortUrl extends Model
}
}
if ($type === 'language') {
$acceptedLanguages = $request->getLanguages();
// Pass 1: Exact match (e.g. "en-us" matches "en-us" rule, or "pl" matches "pl" rule)
foreach ($acceptedLanguages as $acceptedLanguage) {
$acceptedLanguage = strtolower(trim(str_replace('_', '-', $acceptedLanguage)));
if (empty($acceptedLanguage)) {
continue;
}
foreach ($rules['language'] ?? [] as $rule) {
$ruleLang = strtolower(trim(str_replace('_', '-', $rule['language_code'] ?? '')));
if ($ruleLang === $acceptedLanguage) {
return $rule['url'] ?? $this->destination_url;
}
}
}
// Pass 2: Primary language fallback match (e.g. "en-us" matches general "en" rule)
foreach ($acceptedLanguages as $acceptedLanguage) {
$acceptedLanguage = strtolower(trim(str_replace('_', '-', $acceptedLanguage)));
if (empty($acceptedLanguage)) {
continue;
}
$parts = explode('-', $acceptedLanguage);
$primaryLang = strtolower(trim($parts[0]));
foreach ($rules['language'] ?? [] as $rule) {
$ruleLang = strtolower(trim(str_replace('_', '-', $rule['language_code'] ?? '')));
if ($ruleLang === $primaryLang) {
return $rule['url'] ?? $this->destination_url;
}
}
}
}
if ($type === 'rotation') {
$items = $rules['rotation'] ?? [];
if (! empty($items)) {