feat(i18n): add French translation support (#532)
Hello, Here is my pull request for the official french translation. I can change some traduction if you want --------- Co-authored-by: Víctor Falcón <victoor89@gmail.com>
This commit is contained in:
parent
d27e1622b8
commit
a38ed69d2e
|
|
@ -29,7 +29,7 @@ class SetLocale
|
||||||
protected function determineLocale(Request $request): string
|
protected function determineLocale(Request $request): string
|
||||||
{
|
{
|
||||||
// Priority 1: Check for lang query parameter (user override on welcome page)
|
// Priority 1: Check for lang query parameter (user override on welcome page)
|
||||||
if ($request->has('lang') && in_array($request->get('lang'), ['en', 'es'])) {
|
if ($request->has('lang') && in_array($request->get('lang'), ['en', 'es', 'fr'])) {
|
||||||
$locale = $request->get('lang');
|
$locale = $request->get('lang');
|
||||||
// Store in session so subsequent requests remember this choice
|
// Store in session so subsequent requests remember this choice
|
||||||
$request->session()->put('locale', $locale);
|
$request->session()->put('locale', $locale);
|
||||||
|
|
@ -46,7 +46,7 @@ class SetLocale
|
||||||
if ($request->user()) {
|
if ($request->user()) {
|
||||||
$detected = $this->detectLocaleFromHeader($request);
|
$detected = $this->detectLocaleFromHeader($request);
|
||||||
|
|
||||||
if (in_array($request->session()->get('locale'), ['en', 'es'])) {
|
if (in_array($request->session()->get('locale'), ['en', 'es', 'fr'])) {
|
||||||
$detected = $request->session()->get('locale');
|
$detected = $request->session()->get('locale');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -81,6 +81,11 @@ class SetLocale
|
||||||
return 'es';
|
return 'es';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if French is preferred
|
||||||
|
if (preg_match('/^fr(-|,|;)/i', $acceptLanguage) || $acceptLanguage === 'fr') {
|
||||||
|
return 'fr';
|
||||||
|
}
|
||||||
|
|
||||||
return 'en';
|
return 'en';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ class ProfileUpdateRequest extends FormRequest
|
||||||
Rule::unique(User::class)->ignore($this->user()->id),
|
Rule::unique(User::class)->ignore($this->user()->id),
|
||||||
],
|
],
|
||||||
'currency_code' => ['required', 'string', 'max:3', Rule::in($currencyOptions->primaryCodes())],
|
'currency_code' => ['required', 'string', 'max:3', Rule::in($currencyOptions->primaryCodes())],
|
||||||
'locale' => ['nullable', 'string', Rule::in(['en', 'es'])],
|
'locale' => ['nullable', 'string', Rule::in(['en', 'es', 'fr'])],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ class StoreUserLeadRequest extends FormRequest
|
||||||
return [
|
return [
|
||||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:user_leads,email'],
|
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:user_leads,email'],
|
||||||
'referrer_code' => ['nullable', 'string', 'max:12'],
|
'referrer_code' => ['nullable', 'string', 'max:12'],
|
||||||
'locale' => ['nullable', 'string', 'in:en,es'],
|
'locale' => ['nullable', 'string', 'in:en,es,fr'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,20 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Authentication Language Lines
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The following language lines are used during authentication for various
|
||||||
|
| messages that we need to display to the user. You are free to modify
|
||||||
|
| these language lines according to your application's requirements.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'failed' => 'Aucun compte enregistré avec les identifiants fournis.',
|
||||||
|
'password' => 'Le mot de passe est incorect.',
|
||||||
|
'throttle' => 'Trop de tentatives. Veuillez ressayer dans :seconds secondes.',
|
||||||
|
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Pagination Language Lines
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The following language lines are used by the paginator library to build
|
||||||
|
| the simple pagination links. You are free to change them to anything
|
||||||
|
| you want to customize your views to better match your application.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'previous' => '« précèdent',
|
||||||
|
'next' => 'Suivant »',
|
||||||
|
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Password Reset Language Lines
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The following language lines are the default lines which match reasons
|
||||||
|
| that are given by the password broker for a password update attempt
|
||||||
|
| outcome such as failure due to an invalid password / reset token.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'reset' => 'Votre mot de passe a été réinitialisé.',
|
||||||
|
'sent' => 'Nous vous avons envoyé par e-mail le lien de réinitialisation de votre mot de passe.',
|
||||||
|
'throttled' => 'Veuillez patienter avant de réessayer.',
|
||||||
|
'token' => 'Ce jeton de réinitialisation de mot de passe n\'est pas valide.',
|
||||||
|
'user' => 'Aucun utilisateur ne correspond à cette adresse e-mail.',
|
||||||
|
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,199 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Validation Language Lines
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The following language lines contain the default error messages used by
|
||||||
|
| the validator class. Some of these rules have multiple versions such
|
||||||
|
| as the size rules. Feel free to tweak each of these messages here.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'accepted' => 'Le champ :attribute doit être accepté.',
|
||||||
|
'accepted_if' => 'Le champ :attribute doit être accepté lorsque :other vaut :value.',
|
||||||
|
'active_url' => 'Le champ :attribute doit être une URL valide.',
|
||||||
|
'after' => 'Le champ :attribute doit être une date postérieure à :date.',
|
||||||
|
'after_or_equal' => 'Le champ :attribute doit être une date postérieure ou égale à :date.',
|
||||||
|
'alpha' => 'Le champ :attribute ne doit contenir que des lettres.',
|
||||||
|
'alpha_dash' => 'Le champ :attribute ne doit contenir que des lettres, des chiffres, des tirets et des traits de soulignement.',
|
||||||
|
'alpha_num' => 'Le champ :attribute ne doit contenir que des lettres et des chiffres.',
|
||||||
|
'any_of' => 'Le champ :attribute n\'est pas valide.',
|
||||||
|
'array' => 'Le champ :attribute doit être un tableau.',
|
||||||
|
'ascii' => 'Le champ :attribute ne doit contenir que des caractères alphanumériques et des symboles sur un seul octet.',
|
||||||
|
'before' => 'Le champ :attribute doit être une date antérieure à :date.',
|
||||||
|
'before_or_equal' => 'Le champ :attribute doit être une date antérieure ou égale à :date.',
|
||||||
|
'between' => [
|
||||||
|
'array' => 'Le champ :attribute doit contenir entre :min et :max éléments.',
|
||||||
|
'file' => 'Le champ :attribute doit faire entre :min et :max kilo-octets.',
|
||||||
|
'numeric' => 'Le champ :attribute doit être compris entre :min et :max.',
|
||||||
|
'string' => 'Le champ :attribute doit contenir entre :min et :max caractères.',
|
||||||
|
],
|
||||||
|
'boolean' => 'Le champ :attribute doit être vrai ou faux.',
|
||||||
|
'can' => 'Le champ :attribute contient une valeur non autorisée.',
|
||||||
|
'confirmed' => 'La confirmation du champ :attribute ne correspond pas.',
|
||||||
|
'contains' => 'Le champ :attribute n\'inclut pas une valeur requise.',
|
||||||
|
'current_password' => 'Le mot de passe est incorrect.',
|
||||||
|
'date' => 'Le champ :attribute doit être une date valide.',
|
||||||
|
'date_equals' => 'Le champ :attribute doit être une date égale à :date.',
|
||||||
|
'date_format' => 'Le champ :attribute doit correspondre au format :format.',
|
||||||
|
'decimal' => 'Le champ :attribute doit comporter :decimal décimales.',
|
||||||
|
'declined' => 'Le champ :attribute doit être refusé.',
|
||||||
|
'declined_if' => 'Le champ :attribute doit être refusé lorsque :other vaut :value.',
|
||||||
|
'different' => 'Les champs :attribute et :other doivent être différents.',
|
||||||
|
'digits' => 'Le champ :attribute doit comporter :digits chiffres.',
|
||||||
|
'digits_between' => 'Le champ :attribute doit comporter entre :min et :max chiffres.',
|
||||||
|
'dimensions' => 'Le champ :attribute a des dimensions d\'image non valides.',
|
||||||
|
'distinct' => 'Le champ :attribute a une valeur en double.',
|
||||||
|
'doesnt_contain' => 'Le champ :attribute ne doit contenir aucune des valeurs suivantes : :values.',
|
||||||
|
'doesnt_end_with' => 'Le champ :attribute ne doit pas se terminer par l\'une des valeurs suivantes : :values.',
|
||||||
|
'doesnt_start_with' => 'Le champ :attribute ne doit pas commencer par l\'une des valeurs suivantes : :values.',
|
||||||
|
'email' => 'Le champ :attribute doit être une adresse e-mail valide.',
|
||||||
|
'encoding' => 'Le champ :attribute doit être encodé en :encoding.',
|
||||||
|
'ends_with' => 'Le champ :attribute doit se terminer par l\'une des valeurs suivantes : :values.',
|
||||||
|
'enum' => 'La valeur sélectionnée pour :attribute n\'est pas valide.',
|
||||||
|
'exists' => 'La valeur sélectionnée pour :attribute n\'est pas valide.',
|
||||||
|
'extensions' => 'Le champ :attribute doit avoir l\'une des extensions suivantes : :values.',
|
||||||
|
'file' => 'Le champ :attribute doit être un fichier.',
|
||||||
|
'filled' => 'Le champ :attribute doit avoir une valeur.',
|
||||||
|
'gt' => [
|
||||||
|
'array' => 'Le champ :attribute doit contenir plus de :value éléments.',
|
||||||
|
'file' => 'Le champ :attribute doit être supérieur à :value kilo-octets.',
|
||||||
|
'numeric' => 'Le champ :attribute doit être supérieur à :value.',
|
||||||
|
'string' => 'Le champ :attribute doit contenir plus de :value caractères.',
|
||||||
|
],
|
||||||
|
'gte' => [
|
||||||
|
'array' => 'Le champ :attribute doit contenir :value éléments ou plus.',
|
||||||
|
'file' => 'Le champ :attribute doit être supérieur ou égal à :value kilo-octets.',
|
||||||
|
'numeric' => 'Le champ :attribute doit être supérieur ou égal à :value.',
|
||||||
|
'string' => 'Le champ :attribute doit contenir au moins :value caractères.',
|
||||||
|
],
|
||||||
|
'hex_color' => 'Le champ :attribute doit être une couleur hexadécimale valide.',
|
||||||
|
'image' => 'Le champ :attribute doit être une image.',
|
||||||
|
'in' => 'La valeur sélectionnée pour :attribute n\'est pas valide.',
|
||||||
|
'in_array' => 'Le champ :attribute doit exister dans :other.',
|
||||||
|
'in_array_keys' => 'Le champ :attribute doit contenir au moins l\'une des clés suivantes : :values.',
|
||||||
|
'integer' => 'Le champ :attribute doit être un entier.',
|
||||||
|
'ip' => 'Le champ :attribute doit être une adresse IP valide.',
|
||||||
|
'ipv4' => 'Le champ :attribute doit être une adresse IPv4 valide.',
|
||||||
|
'ipv6' => 'Le champ :attribute doit être une adresse IPv6 valide.',
|
||||||
|
'json' => 'Le champ :attribute doit être une chaîne JSON valide.',
|
||||||
|
'list' => 'Le champ :attribute doit être une liste.',
|
||||||
|
'lowercase' => 'Le champ :attribute doit être en minuscules.',
|
||||||
|
'lt' => [
|
||||||
|
'array' => 'Le champ :attribute doit contenir moins de :value éléments.',
|
||||||
|
'file' => 'Le champ :attribute doit être inférieur à :value kilo-octets.',
|
||||||
|
'numeric' => 'Le champ :attribute doit être inférieur à :value.',
|
||||||
|
'string' => 'Le champ :attribute doit contenir moins de :value caractères.',
|
||||||
|
],
|
||||||
|
'lte' => [
|
||||||
|
'array' => 'Le champ :attribute ne doit pas contenir plus de :value éléments.',
|
||||||
|
'file' => 'Le champ :attribute doit être inférieur ou égal à :value kilo-octets.',
|
||||||
|
'numeric' => 'Le champ :attribute doit être inférieur ou égal à :value.',
|
||||||
|
'string' => 'Le champ :attribute doit contenir au plus :value caractères.',
|
||||||
|
],
|
||||||
|
'mac_address' => 'Le champ :attribute doit être une adresse MAC valide.',
|
||||||
|
'max' => [
|
||||||
|
'array' => 'Le champ :attribute ne doit pas contenir plus de :max éléments.',
|
||||||
|
'file' => 'Le champ :attribute ne doit pas dépasser :max kilo-octets.',
|
||||||
|
'numeric' => 'Le champ :attribute ne doit pas être supérieur à :max.',
|
||||||
|
'string' => 'Le champ :attribute ne doit pas dépasser :max caractères.',
|
||||||
|
],
|
||||||
|
'max_digits' => 'Le champ :attribute ne doit pas comporter plus de :max chiffres.',
|
||||||
|
'mimes' => 'Le champ :attribute doit être un fichier de type : :values.',
|
||||||
|
'mimetypes' => 'Le champ :attribute doit être un fichier de type : :values.',
|
||||||
|
'min' => [
|
||||||
|
'array' => 'Le champ :attribute doit contenir au moins :min éléments.',
|
||||||
|
'file' => 'Le champ :attribute doit faire au moins :min kilo-octets.',
|
||||||
|
'numeric' => 'Le champ :attribute doit être au moins :min.',
|
||||||
|
'string' => 'Le champ :attribute doit contenir au moins :min caractères.',
|
||||||
|
],
|
||||||
|
'min_digits' => 'Le champ :attribute doit comporter au moins :min chiffres.',
|
||||||
|
'missing' => 'Le champ :attribute doit être absent.',
|
||||||
|
'missing_if' => 'Le champ :attribute doit être absent lorsque :other vaut :value.',
|
||||||
|
'missing_unless' => 'Le champ :attribute doit être absent sauf si :other vaut :value.',
|
||||||
|
'missing_with' => 'Le champ :attribute doit être absent lorsque :values est présent.',
|
||||||
|
'missing_with_all' => 'Le champ :attribute doit être absent lorsque :values sont présents.',
|
||||||
|
'multiple_of' => 'Le champ :attribute doit être un multiple de :value.',
|
||||||
|
'not_in' => 'La valeur sélectionnée pour :attribute n\'est pas valide.',
|
||||||
|
'not_regex' => 'Le format du champ :attribute n\'est pas valide.',
|
||||||
|
'numeric' => 'Le champ :attribute doit être un nombre.',
|
||||||
|
'password' => [
|
||||||
|
'letters' => 'Le champ :attribute doit contenir au moins une lettre.',
|
||||||
|
'mixed' => 'Le champ :attribute doit contenir au moins une majuscule et une minuscule.',
|
||||||
|
'numbers' => 'Le champ :attribute doit contenir au moins un chiffre.',
|
||||||
|
'symbols' => 'Le champ :attribute doit contenir au moins un symbole.',
|
||||||
|
'uncompromised' => 'Le :attribute fourni est apparu dans une fuite de données. Veuillez choisir un autre :attribute.',
|
||||||
|
],
|
||||||
|
'present' => 'Le champ :attribute doit être présent.',
|
||||||
|
'present_if' => 'Le champ :attribute doit être présent lorsque :other vaut :value.',
|
||||||
|
'present_unless' => 'Le champ :attribute doit être présent sauf si :other vaut :value.',
|
||||||
|
'present_with' => 'Le champ :attribute doit être présent lorsque :values est présent.',
|
||||||
|
'present_with_all' => 'Le champ :attribute doit être présent lorsque :values sont présents.',
|
||||||
|
'prohibited' => 'Le champ :attribute est interdit.',
|
||||||
|
'prohibited_if' => 'Le champ :attribute est interdit lorsque :other vaut :value.',
|
||||||
|
'prohibited_if_accepted' => 'Le champ :attribute est interdit lorsque :other est accepté.',
|
||||||
|
'prohibited_if_declined' => 'Le champ :attribute est interdit lorsque :other est refusé.',
|
||||||
|
'prohibited_unless' => 'Le champ :attribute est interdit sauf si :other fait partie de :values.',
|
||||||
|
'prohibits' => 'Le champ :attribute empêche :other d\'être présent.',
|
||||||
|
'regex' => 'Le format du champ :attribute n\'est pas valide.',
|
||||||
|
'required' => 'Le champ :attribute est obligatoire.',
|
||||||
|
'required_array_keys' => 'Le champ :attribute doit contenir des entrées pour : :values.',
|
||||||
|
'required_if' => 'Le champ :attribute est obligatoire lorsque :other vaut :value.',
|
||||||
|
'required_if_accepted' => 'Le champ :attribute est obligatoire lorsque :other est accepté.',
|
||||||
|
'required_if_declined' => 'Le champ :attribute est obligatoire lorsque :other est refusé.',
|
||||||
|
'required_unless' => 'Le champ :attribute est obligatoire sauf si :other fait partie de :values.',
|
||||||
|
'required_with' => 'Le champ :attribute est obligatoire lorsque :values est présent.',
|
||||||
|
'required_with_all' => 'Le champ :attribute est obligatoire lorsque :values sont présents.',
|
||||||
|
'required_without' => 'Le champ :attribute est obligatoire lorsque :values n\'est pas présent.',
|
||||||
|
'required_without_all' => 'Le champ :attribute est obligatoire lorsque aucun de :values n\'est présent.',
|
||||||
|
'same' => 'Le champ :attribute doit correspondre à :other.',
|
||||||
|
'size' => [
|
||||||
|
'array' => 'Le champ :attribute doit contenir :size éléments.',
|
||||||
|
'file' => 'Le champ :attribute doit faire :size kilo-octets.',
|
||||||
|
'numeric' => 'Le champ :attribute doit être :size.',
|
||||||
|
'string' => 'Le champ :attribute doit contenir :size caractères.',
|
||||||
|
],
|
||||||
|
'starts_with' => 'Le champ :attribute doit commencer par l\'une des valeurs suivantes : :values.',
|
||||||
|
'string' => 'Le champ :attribute doit être une chaîne de caractères.',
|
||||||
|
'timezone' => 'Le champ :attribute doit être un fuseau horaire valide.',
|
||||||
|
'unique' => 'La valeur du champ :attribute est déjà utilisée.',
|
||||||
|
'uploaded' => 'Le téléversement du champ :attribute a échoué.',
|
||||||
|
'uppercase' => 'Le champ :attribute doit être en majuscules.',
|
||||||
|
'url' => 'Le champ :attribute doit être une URL valide.',
|
||||||
|
'ulid' => 'Le champ :attribute doit être un ULID valide.',
|
||||||
|
'uuid' => 'Le champ :attribute doit être un UUID valide.',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Custom Validation Language Lines
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify custom validation messages for attributes using the
|
||||||
|
| convention "attribute.rule" to name the lines. This makes it quick to
|
||||||
|
| specify a specific custom language line for a given attribute rule.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'custom' => [
|
||||||
|
'attribute-name' => [
|
||||||
|
'rule-name' => 'custom-message',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Custom Validation Attributes
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The following language lines are used to swap our attribute placeholder
|
||||||
|
| with something more reader friendly such as "E-Mail Address" instead
|
||||||
|
| of "email". This simply helps us make our message more expressive.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'attributes' => [],
|
||||||
|
|
||||||
|
];
|
||||||
|
|
@ -13,6 +13,7 @@ import { cn } from '@/lib/utils';
|
||||||
import { store as storeUserLead } from '@/routes/user-leads';
|
import { store as storeUserLead } from '@/routes/user-leads';
|
||||||
import { type SharedData } from '@/types';
|
import { type SharedData } from '@/types';
|
||||||
import { type CategoryColor, getCategoryColorClasses } from '@/types/category';
|
import { type CategoryColor, getCategoryColorClasses } from '@/types/category';
|
||||||
|
import { LANGUAGE_OPTIONS } from '@/types/language';
|
||||||
import { Plan } from '@/types/pricing';
|
import { Plan } from '@/types/pricing';
|
||||||
import { formatCurrency } from '@/utils/currency';
|
import { formatCurrency } from '@/utils/currency';
|
||||||
import { __ } from '@/utils/i18n';
|
import { __ } from '@/utils/i18n';
|
||||||
|
|
@ -2067,7 +2068,11 @@ export default function Welcome({
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
const langParam = urlParams.get('lang');
|
const langParam = urlParams.get('lang');
|
||||||
|
|
||||||
if (langParam && ['en', 'es'].includes(langParam)) {
|
const supportedLocales: readonly string[] = LANGUAGE_OPTIONS.map(
|
||||||
|
(option) => option.code,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (langParam && supportedLocales.includes(langParam)) {
|
||||||
// Store the preference in localStorage
|
// Store the preference in localStorage
|
||||||
localStorage.setItem('whisper_landing_locale', langParam);
|
localStorage.setItem('whisper_landing_locale', langParam);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -2077,7 +2082,7 @@ export default function Welcome({
|
||||||
if (
|
if (
|
||||||
storedLocale &&
|
storedLocale &&
|
||||||
storedLocale !== locale &&
|
storedLocale !== locale &&
|
||||||
['en', 'es'].includes(storedLocale)
|
supportedLocales.includes(storedLocale)
|
||||||
) {
|
) {
|
||||||
// Redirect to stored preference
|
// Redirect to stored preference
|
||||||
window.location.href = `/?lang=${storedLocale}`;
|
window.location.href = `/?lang=${storedLocale}`;
|
||||||
|
|
@ -2858,12 +2863,17 @@ export default function Welcome({
|
||||||
>
|
>
|
||||||
{__('Terms of Service')}
|
{__('Terms of Service')}
|
||||||
</Link>
|
</Link>
|
||||||
<a
|
{LANGUAGE_OPTIONS.filter(
|
||||||
href={`/?lang=${locale === 'es' ? 'en' : 'es'}`}
|
(option) => option.code !== locale,
|
||||||
className="cursor-pointer hover:text-[#1b1b18] dark:hover:text-[#EDEDEC]"
|
).map((option) => (
|
||||||
>
|
<a
|
||||||
{locale === 'es' ? 'English' : 'Español'}
|
key={option.code}
|
||||||
</a>
|
href={`/?lang=${option.code}`}
|
||||||
|
className="cursor-pointer hover:text-[#1b1b18] dark:hover:text-[#EDEDEC]"
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
export const LANGUAGE_OPTIONS = [
|
export const LANGUAGE_OPTIONS = [
|
||||||
{ code: 'en', label: 'English' },
|
{ code: 'en', label: 'English' },
|
||||||
{ code: 'es', label: 'Español' },
|
{ code: 'es', label: 'Español' },
|
||||||
|
{ code: 'fr', label: 'Français' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type LocaleCode = 'en' | 'es';
|
export type LocaleCode = 'en' | 'es' | 'fr';
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,33 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use PHPUnit\Framework\Assert;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Localization completeness tests.
|
* Localization completeness tests.
|
||||||
*
|
*
|
||||||
* These tests ensure every translatable string used in the application
|
* Spanish is the reference locale and is strictly enforced: any English
|
||||||
* has a corresponding Spanish translation, preventing untranslated content
|
* string without a Spanish translation fails the suite, preventing
|
||||||
* from reaching production.
|
* untranslated content from reaching production.
|
||||||
|
*
|
||||||
|
* French is an optional, community-maintained locale. Its gaps only emit a
|
||||||
|
* warning — an incomplete French translation never blocks CI, but the run
|
||||||
|
* still surfaces exactly which keys are missing so they can be filled in.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locales whose translation gaps must fail the suite.
|
||||||
|
*
|
||||||
|
* @var array<string>
|
||||||
|
*/
|
||||||
|
const ENFORCED_LOCALES = ['es'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locales whose translation gaps only emit a warning.
|
||||||
|
*
|
||||||
|
* @var array<string>
|
||||||
|
*/
|
||||||
|
const OPTIONAL_LOCALES = ['fr'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recursively flattens a nested PHP translation array using dot notation.
|
* Recursively flattens a nested PHP translation array using dot notation.
|
||||||
*
|
*
|
||||||
|
|
@ -69,68 +89,144 @@ function extractI18nKeysFromSource(): array
|
||||||
return array_unique($keys);
|
return array_unique($keys);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('Spanish PHP translation files', function () {
|
/**
|
||||||
it('has every key from the English PHP files translated', function () {
|
* Computes the PHP translation keys present in lang/en but missing from the
|
||||||
$englishDir = lang_path('en');
|
* given locale, grouped by filename. A wholly absent file is reported as the
|
||||||
$missingByFile = [];
|
* sentinel "__missing_file__".
|
||||||
|
*
|
||||||
|
* @return array<string, array<string>>
|
||||||
|
*/
|
||||||
|
function missingPhpTranslationKeys(string $locale): array
|
||||||
|
{
|
||||||
|
$englishDir = lang_path('en');
|
||||||
|
$missingByFile = [];
|
||||||
|
|
||||||
/** @var SplFileInfo $file */
|
/** @var SplFileInfo $file */
|
||||||
foreach (new FilesystemIterator($englishDir, FilesystemIterator::SKIP_DOTS) as $file) {
|
foreach (new FilesystemIterator($englishDir, FilesystemIterator::SKIP_DOTS) as $file) {
|
||||||
if ($file->getExtension() !== 'php') {
|
if ($file->getExtension() !== 'php') {
|
||||||
continue;
|
continue;
|
||||||
}
|
|
||||||
|
|
||||||
$filename = $file->getFilename();
|
|
||||||
$spanishPath = lang_path("es/{$filename}");
|
|
||||||
|
|
||||||
if (! file_exists($spanishPath)) {
|
|
||||||
$missingByFile[$filename] = ['__missing_file__'];
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$englishKeys = flattenTranslations(require $file->getPathname());
|
|
||||||
$spanishKeys = flattenTranslations(require $spanishPath);
|
|
||||||
$missingKeys = array_keys(array_diff_key($englishKeys, $spanishKeys));
|
|
||||||
|
|
||||||
if ($missingKeys !== []) {
|
|
||||||
$missingByFile[$filename] = $missingKeys;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$report = implode("\n", array_map(
|
$filename = $file->getFilename();
|
||||||
fn (string $f, array $keys) => "lang/es/{$f}:\n - ".implode("\n - ", $keys),
|
$localePath = lang_path("{$locale}/{$filename}");
|
||||||
array_keys($missingByFile),
|
|
||||||
$missingByFile,
|
if (! file_exists($localePath)) {
|
||||||
));
|
$missingByFile[$filename] = ['__missing_file__'];
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$englishKeys = flattenTranslations(require $file->getPathname());
|
||||||
|
$localeKeys = flattenTranslations(require $localePath);
|
||||||
|
$missingKeys = array_keys(array_diff_key($englishKeys, $localeKeys));
|
||||||
|
|
||||||
|
if ($missingKeys !== []) {
|
||||||
|
$missingByFile[$filename] = $missingKeys;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $missingByFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a human-readable report of missing PHP translation keys.
|
||||||
|
*
|
||||||
|
* @param array<string, array<string>> $missingByFile
|
||||||
|
*/
|
||||||
|
function formatPhpTranslationReport(string $locale, array $missingByFile): string
|
||||||
|
{
|
||||||
|
return implode("\n", array_map(
|
||||||
|
fn (string $f, array $keys) => "lang/{$locale}/{$f}:\n - ".implode("\n - ", $keys),
|
||||||
|
array_keys($missingByFile),
|
||||||
|
$missingByFile,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes the __() source keys missing from the given locale's JSON file.
|
||||||
|
* A missing or malformed file is reported via a sentinel entry.
|
||||||
|
*
|
||||||
|
* @return array<string>
|
||||||
|
*/
|
||||||
|
function missingJsonTranslationKeys(string $locale): array
|
||||||
|
{
|
||||||
|
$jsonPath = lang_path("{$locale}.json");
|
||||||
|
|
||||||
|
if (! file_exists($jsonPath)) {
|
||||||
|
return ['__missing_file__'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = json_decode(file_get_contents($jsonPath), associative: true);
|
||||||
|
|
||||||
|
if (! is_array($json)) {
|
||||||
|
return ['__invalid_json__'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourceKeys = extractI18nKeysFromSource();
|
||||||
|
$missingKeys = array_values(array_filter(
|
||||||
|
$sourceKeys,
|
||||||
|
fn (string $key) => ! array_key_exists($key, $json)
|
||||||
|
));
|
||||||
|
|
||||||
|
sort($missingKeys);
|
||||||
|
|
||||||
|
return $missingKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('enforced PHP translations', function () {
|
||||||
|
it('has every English PHP key translated', function (string $locale) {
|
||||||
|
$missingByFile = missingPhpTranslationKeys($locale);
|
||||||
|
|
||||||
expect($missingByFile)->toBeEmpty(
|
expect($missingByFile)->toBeEmpty(
|
||||||
"Spanish PHP translation files have missing keys:\n{$report}"
|
"{$locale} PHP translation files have missing keys:\n".formatPhpTranslationReport($locale, $missingByFile)
|
||||||
);
|
);
|
||||||
});
|
})->with(ENFORCED_LOCALES);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Spanish JSON translations', function () {
|
describe('enforced JSON translations', function () {
|
||||||
it('has every __() key from TypeScript source files translated in es.json', function () {
|
it('has every __() source key translated', function (string $locale) {
|
||||||
$spanishJsonPath = lang_path('es.json');
|
expect(lang_path("{$locale}.json"))->toBeFile("Missing lang/{$locale}.json translation file");
|
||||||
|
|
||||||
expect($spanishJsonPath)->toBeFile('Missing lang/es.json translation file');
|
$missingKeys = missingJsonTranslationKeys($locale);
|
||||||
|
|
||||||
$spanishJson = json_decode(file_get_contents($spanishJsonPath), associative: true);
|
expect($missingKeys)->toBeEmpty(
|
||||||
|
count($missingKeys)." key(s) used in source via __() are missing from lang/{$locale}.json:\n - ".implode("\n - ", $missingKeys)
|
||||||
|
);
|
||||||
|
})->with(ENFORCED_LOCALES);
|
||||||
|
});
|
||||||
|
|
||||||
expect($spanishJson)->toBeArray('lang/es.json must contain a valid JSON object');
|
describe('optional PHP translations', function () {
|
||||||
|
it('warns about untranslated PHP keys', function (string $locale) {
|
||||||
|
$missingByFile = missingPhpTranslationKeys($locale);
|
||||||
|
|
||||||
$sourceKeys = extractI18nKeysFromSource();
|
if ($missingByFile !== []) {
|
||||||
$missingKeys = array_values(array_filter(
|
// Optional locale: surface the gap as an incomplete test so it stays
|
||||||
$sourceKeys,
|
// visible in the run summary without failing CI (no failOnIncomplete
|
||||||
fn (string $key) => ! array_key_exists($key, $spanishJson)
|
// is configured).
|
||||||
));
|
Assert::markTestIncomplete(
|
||||||
|
"Optional locale '{$locale}' has missing PHP translation keys (not blocking):\n"
|
||||||
sort($missingKeys);
|
.formatPhpTranslationReport($locale, $missingByFile)
|
||||||
|
|
||||||
expect($missingKeys)
|
|
||||||
->toBeEmpty(
|
|
||||||
count($missingKeys)." key(s) used in source via __() are missing from lang/es.json:\n - ".implode("\n - ", $missingKeys)
|
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|
||||||
|
expect($missingByFile)->toBeEmpty();
|
||||||
|
})->with(OPTIONAL_LOCALES);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('optional JSON translations', function () {
|
||||||
|
it('warns about __() source keys missing from the locale JSON', function (string $locale) {
|
||||||
|
$missingKeys = missingJsonTranslationKeys($locale);
|
||||||
|
|
||||||
|
if ($missingKeys !== []) {
|
||||||
|
// Optional locale: surface the gap as an incomplete test so it stays
|
||||||
|
// visible in the run summary without failing CI (no failOnIncomplete
|
||||||
|
// is configured).
|
||||||
|
Assert::markTestIncomplete(
|
||||||
|
count($missingKeys)." key(s) used in source via __() are missing from lang/{$locale}.json (not blocking):\n - "
|
||||||
|
.implode("\n - ", $missingKeys)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect($missingKeys)->toBeEmpty();
|
||||||
|
})->with(OPTIONAL_LOCALES);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Middleware\SetLocale;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\Support\Facades\App;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs a request through the SetLocale middleware and returns the locale the
|
||||||
|
* application ended up using.
|
||||||
|
*/
|
||||||
|
function resolveLocaleFor(Request $request): string
|
||||||
|
{
|
||||||
|
$request->setLaravelSession(app('session.store'));
|
||||||
|
|
||||||
|
(new SetLocale)->handle($request, fn () => new Response);
|
||||||
|
|
||||||
|
return App::getLocale();
|
||||||
|
}
|
||||||
|
|
||||||
|
it('applies a supported locale from the lang query parameter', function (string $locale) {
|
||||||
|
$request = Request::create('/', 'GET', ['lang' => $locale]);
|
||||||
|
|
||||||
|
expect(resolveLocaleFor($request))->toBe($locale);
|
||||||
|
})->with(['en', 'es', 'fr']);
|
||||||
|
|
||||||
|
it('ignores an unsupported lang query parameter', function () {
|
||||||
|
$request = Request::create('/', 'GET', ['lang' => 'de']);
|
||||||
|
|
||||||
|
expect(resolveLocaleFor($request))->not->toBe('de');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects French from the Accept-Language header', function () {
|
||||||
|
$request = Request::create('/', 'GET', server: ['HTTP_ACCEPT_LANGUAGE' => 'fr-FR,fr;q=0.9,en;q=0.8']);
|
||||||
|
|
||||||
|
expect(resolveLocaleFor($request))->toBe('fr');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects Spanish from the Accept-Language header', function () {
|
||||||
|
$request = Request::create('/', 'GET', server: ['HTTP_ACCEPT_LANGUAGE' => 'es-ES,es;q=0.9']);
|
||||||
|
|
||||||
|
expect(resolveLocaleFor($request))->toBe('es');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to English for an unrecognized Accept-Language header', function () {
|
||||||
|
$request = Request::create('/', 'GET', server: ['HTTP_ACCEPT_LANGUAGE' => 'de-DE,de;q=0.9']);
|
||||||
|
|
||||||
|
expect(resolveLocaleFor($request))->toBe('en');
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue