From eadbb32414ea302583cbd8ed16f087bc32fd821c Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Mon, 7 Sep 2026 18:31:23 -0300 Subject: [PATCH 01/25] Store the user's UI locale in the database MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The locale lived in a forever cookie since March 2026, so it was tied to a browser rather than to the person: a new device, a cleared cookie or a second browser all dropped the user back to English, and the register page had no way to know what language the visitor actually reads. Move it back onto `users.locale`, cast to a new `App\Enums\User\Locale` that is now the only list of supported locales — `config/languages.php` duplicated it and is gone. `App\Support\LocaleResolver` is the single place that decides the active locale: the stored value for an authenticated user, and for a guest the submitted `locale`, the flashed old input, a negotiated `Accept-Language`, then the default. The first two guest steps are what keep the backend validation messages, and the re-rendered form after a failed submit, in the language the visitor picked. The register page gains a language picker seeded from `Accept-Language`, which translates the page client-side so choosing a language costs no round trip. The in-app switcher now writes to the database instead of the cookie, and its inline validation moves into a dedicated `UpdateLanguageRequest`. Google and GitHub signups always store English: they have no picker, and the OAuth callback's `Accept-Language` describes the provider's redirect rather than the person. Existing users keep the column default for the same reason. --- AGENTS.md | 29 +++ CLAUDE.md | 29 +++ app/Actions/User/CreateUser.php | 4 +- app/Enums/User/Locale.php | 178 ++++++++++++++++++ .../App/Settings/ProfileController.php | 11 +- .../Auth/RegisteredUserController.php | 3 + .../Middleware/App/HandleInertiaRequests.php | 6 +- app/Http/Middleware/App/SetLocale.php | 24 +-- .../Requests/App/Auth/RegisterRequest.php | 3 + .../App/Settings/UpdateLanguageRequest.php | 23 +++ app/Models/User.php | 3 + app/Support/LocaleResolver.php | 53 ++++++ bootstrap/app.php | 2 +- config/languages.php | 48 ----- database/factories/UserFactory.php | 2 + ...09_07_211907_add_locale_to_users_table.php | 25 +++ lang/ar/auth.php | 4 + lang/de/auth.php | 4 + lang/el/auth.php | 4 + lang/en/auth.php | 4 + lang/es/auth.php | 4 + lang/fr/auth.php | 4 + lang/it/auth.php | 4 + lang/ja/auth.php | 4 + lang/ko/auth.php | 4 + lang/nl/auth.php | 4 + lang/pl/auth.php | 4 + lang/pt-BR/auth.php | 4 + lang/ru/auth.php | 4 + lang/tr/auth.php | 4 + lang/uk/auth.php | 4 + lang/zh/auth.php | 4 + resources/js/components/LanguagePicker.vue | 2 + .../js/components/WorkspaceMenuContent.vue | 7 +- resources/js/pages/auth/Register.vue | 53 +++++- resources/js/types/index.d.ts | 10 + tests/Browser/RegisterLanguagePickerTest.php | 49 +++++ tests/Feature/LocalizationParityTest.php | 12 +- tests/Feature/Middleware/SetLocaleTest.php | 134 ++++++++----- tests/Feature/RegistrationLocaleTest.php | 104 ++++++++++ tests/Feature/Settings/ProfileUpdateTest.php | 35 ++-- tests/Unit/Enums/User/LocaleTest.php | 53 ++++++ 42 files changed, 805 insertions(+), 161 deletions(-) create mode 100644 app/Enums/User/Locale.php create mode 100644 app/Http/Requests/App/Settings/UpdateLanguageRequest.php create mode 100644 app/Support/LocaleResolver.php delete mode 100644 config/languages.php create mode 100644 database/migrations/2026_09_07_211907_add_locale_to_users_table.php create mode 100644 tests/Browser/RegisterLanguagePickerTest.php create mode 100644 tests/Feature/RegistrationLocaleTest.php create mode 100644 tests/Unit/Enums/User/LocaleTest.php diff --git a/AGENTS.md b/AGENTS.md index fbbcec264..bc88d9ab1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -333,3 +333,32 @@ to `Connected`, because it does so after a real `verify()` call. A successful token refresh is not that proof — the refresh token being valid says nothing about whether publishing still works — so `RefreshSocialToken` must not promote, even though it would let a paused repurpose resume sooner. + +## UI locale (`users.locale`) + +The user's UI language lives in the database, on `users.locale`, cast to +`App\Enums\User\Locale`. That enum is the single source of truth for the +supported locales — there is no `config/languages.php` any more, and a case is +only valid if `lang/` exists (`LocalizationParityTest` enforces both that +and parity with `ContentLanguage`). + +- **There is no `locale` cookie.** The app stored the locale in the database + until March 2026, moved it to a forever cookie, and moved it back here. Do not + reintroduce the cookie as a "guest fallback": a second source of truth is what + made the switcher and the register page disagree the first time. +- `App\Support\LocaleResolver` is the only place that decides the active locale. + For an authenticated user it is `$user->locale`, full stop. For a guest it is + the submitted `locale`, then the flashed `old('locale')`, then a negotiated + `Accept-Language`, then `Locale::DEFAULT`. The first two steps exist for the + register page's language picker — the submitted value is what keeps the + **backend validation messages** in the language the visitor picked, and the old + input is what keeps the re-rendered form in it after a failed submit. Drop + either and a Portuguese visitor gets English error messages. +- `locale` is `nullable` on `RegisterRequest` on purpose: the picker is a + convenience, and a missing field falls back to negotiation rather than + refusing the signup. An *unsupported* value is still rejected. +- Google and GitHub signups always store `Locale::DEFAULT`. They have no picker, + and the OAuth callback's `Accept-Language` is the provider's redirect, not a + reliable signal about the person. +- The picker translates the page client-side (`loadLanguageAsync`), so choosing a + language never costs a round trip; the value only reaches the server on submit. diff --git a/CLAUDE.md b/CLAUDE.md index 0824a34c5..8cacda2b0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -260,6 +260,35 @@ One connected identity per social network per workspace is the Cloud default. Th Self-hosted compose / `.env.example` set this `true`. When the env is unset, the config falls back to `SELF_HOSTED` so existing self-hosted installs keep multiple accounts. Do **not** use `selfHosted` for the occupancy check (observer, Telegram connect, `NetworkConnectGrid`). +## UI locale (`users.locale`) + +The user's UI language lives in the database, on `users.locale`, cast to +`App\Enums\User\Locale`. That enum is the single source of truth for the +supported locales — there is no `config/languages.php` any more, and a case is +only valid if `lang/` exists (`LocalizationParityTest` enforces both that +and parity with `ContentLanguage`). + +- **There is no `locale` cookie.** The app stored the locale in the database + until March 2026, moved it to a forever cookie, and moved it back here. Do not + reintroduce the cookie as a "guest fallback": a second source of truth is what + made the switcher and the register page disagree the first time. +- `App\Support\LocaleResolver` is the only place that decides the active locale. + For an authenticated user it is `$user->locale`, full stop. For a guest it is + the submitted `locale`, then the flashed `old('locale')`, then a negotiated + `Accept-Language`, then `Locale::DEFAULT`. The first two steps exist for the + register page's language picker — the submitted value is what keeps the + **backend validation messages** in the language the visitor picked, and the old + input is what keeps the re-rendered form in it after a failed submit. Drop + either and a Portuguese visitor gets English error messages. +- `locale` is `nullable` on `RegisterRequest` on purpose: the picker is a + convenience, and a missing field falls back to negotiation rather than + refusing the signup. An *unsupported* value is still rejected. +- Google and GitHub signups always store `Locale::DEFAULT`. They have no picker, + and the OAuth callback's `Accept-Language` is the provider's redirect, not a + reliable signal about the person. +- The picker translates the page client-side (`loadLanguageAsync`), so choosing a + language never costs a round trip; the value only reaches the server on submit. + ## Icons (@tabler/icons-vue) - This project uses `@tabler/icons-vue` for all icons. NEVER use `lucide-vue-next`. diff --git a/app/Actions/User/CreateUser.php b/app/Actions/User/CreateUser.php index d3a5303f1..fa3e01255 100644 --- a/app/Actions/User/CreateUser.php +++ b/app/Actions/User/CreateUser.php @@ -7,6 +7,7 @@ use App\Actions\Workspace\CreateWorkspace; use App\Enums\Plan\Slug; use App\Enums\PostHog\UserEvent; +use App\Enums\User\Locale; use App\Jobs\PostHog\SyncUser; use App\Models\Account; use App\Models\Plan; @@ -17,7 +18,7 @@ class CreateUser { /** - * @param array{name: string, email: string, password?: string, google_id?: string, github_id?: string, email_verified_at?: \DateTimeInterface|null, is_invite?: bool, registration_ip?: string|null} $data + * @param array{name: string, email: string, password?: string, google_id?: string, github_id?: string, email_verified_at?: \DateTimeInterface|null, is_invite?: bool, registration_ip?: string|null, locale?: string|null} $data * @param array $attributionParameters UTM parameters and ad click IDs (gclid, fbclid, etc.) captured before signup */ public static function execute(array $data, array $attributionParameters = []): User @@ -47,6 +48,7 @@ public static function execute(array $data, array $attributionParameters = []): 'email_verified_at' => data_get($data, 'email_verified_at', $isInviteRegistration ? now() : null), 'account_id' => $account->id, 'registration_ip' => data_get($data, 'registration_ip'), + 'locale' => Locale::tryFrom((string) data_get($data, 'locale')) ?? Locale::DEFAULT, ], $attributionParameters)); $account->update(['owner_id' => $user->id]); diff --git a/app/Enums/User/Locale.php b/app/Enums/User/Locale.php new file mode 100644 index 000000000..1ba02afe0 --- /dev/null +++ b/app/Enums/User/Locale.php @@ -0,0 +1,178 @@ +` + * directory — `LocalizationParityTest` fails when one is missing. + */ +enum Locale: string +{ + case English = 'en'; + case Ukrainian = 'uk'; + case PortugueseBrazil = 'pt-BR'; + case Spanish = 'es'; + case French = 'fr'; + case German = 'de'; + case Italian = 'it'; + case Dutch = 'nl'; + case Polish = 'pl'; + case Greek = 'el'; + case Japanese = 'ja'; + case Korean = 'ko'; + case Chinese = 'zh'; + case Russian = 'ru'; + case Turkish = 'tr'; + case Arabic = 'ar'; + + public const DEFAULT = self::English; + + /** + * The language's own name, shown in the language switcher. + */ + public function label(): string + { + return match ($this) { + self::English => 'English', + self::Ukrainian => 'Українська', + self::PortugueseBrazil => 'Português', + self::Spanish => 'Español', + self::French => 'Français', + self::German => 'Deutsch', + self::Italian => 'Italiano', + self::Dutch => 'Nederlands', + self::Polish => 'Polski', + self::Greek => 'Ελληνικά', + self::Japanese => '日本語', + self::Korean => '한국어', + self::Chinese => '中文', + self::Russian => 'Русский', + self::Turkish => 'Türkçe', + self::Arabic => 'العربية', + }; + } + + /** + * The text direction (`ltr` or `rtl`) for the document root when this is the + * active locale — only Arabic is written right to left. + */ + public function direction(): string + { + return $this === self::Arabic ? 'rtl' : 'ltr'; + } + + /** + * Resolve a BCP 47 language tag (e.g. "pt-PT", "zh-Hans", "en_US") to a + * supported locale, preferring an exact match and falling back to the + * primary subtag, or null when the language is not supported at all. + */ + public static function fromTag(string $tag): ?self + { + $normalized = str_replace('_', '-', trim($tag)); + + foreach (self::cases() as $locale) { + if (strcasecmp($locale->value, $normalized) === 0) { + return $locale; + } + } + + $subtag = self::primarySubtag($normalized); + + if (strlen($subtag) < 2) { + return null; + } + + foreach (self::cases() as $locale) { + if (self::primarySubtag($locale->value) === $subtag) { + return $locale; + } + } + + return null; + } + + /** + * Negotiate an `Accept-Language` header value ("pt-BR,pt;q=0.9,en;q=0.8") + * down to the highest-quality supported locale, or null when none match. + */ + public static function fromAcceptLanguage(?string $header): ?self + { + if (blank($header)) { + return null; + } + + $candidates = []; + + foreach (explode(',', $header) as $position => $part) { + $segments = explode(';', $part); + $tag = trim($segments[0]); + + if ($tag === '' || $tag === '*') { + continue; + } + + $quality = 1.0; + + foreach (array_slice($segments, 1) as $parameter) { + [$key, $value] = array_pad(explode('=', $parameter, 2), 2, null); + + if (strtolower(trim((string) $key)) === 'q') { + $quality = (float) $value; + } + } + + $candidates[] = ['tag' => $tag, 'quality' => $quality, 'position' => $position]; + } + + usort($candidates, fn (array $a, array $b) => [$b['quality'], $a['position']] <=> [$a['quality'], $b['position']]); + + foreach ($candidates as $candidate) { + if ($locale = self::fromTag($candidate['tag'])) { + return $locale; + } + } + + return null; + } + + /** + * The lowercased primary subtag of a BCP 47 language tag ("pt-BR" => "pt"). + */ + private static function primarySubtag(string $tag): string + { + return strtolower(explode('-', trim($tag), 2)[0]); + } + + /** + * @return array + */ + public static function values(): array + { + return array_map(fn (self $locale) => $locale->value, self::cases()); + } + + /** + * The language switcher options, in the order the cases are declared. + * + * @return array + */ + public static function options(): array + { + return array_map( + fn (self $locale) => [ + 'code' => $locale->value, + 'name' => $locale->label(), + 'dir' => $locale->direction(), + ], + self::cases(), + ); + } +} diff --git a/app/Http/Controllers/App/Settings/ProfileController.php b/app/Http/Controllers/App/Settings/ProfileController.php index 906b97942..12164bc09 100644 --- a/app/Http/Controllers/App/Settings/ProfileController.php +++ b/app/Http/Controllers/App/Settings/ProfileController.php @@ -8,6 +8,7 @@ use App\Http\Controllers\Controller; use App\Http\Requests\App\Settings\ProfileDeleteRequest; use App\Http\Requests\App\Settings\ProfileUpdateRequest; +use App\Http\Requests\App\Settings\UpdateLanguageRequest; use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -69,15 +70,11 @@ public function deletePhoto(Request $request): RedirectResponse return back(); } - public function updateLanguage(Request $request): RedirectResponse + public function updateLanguage(UpdateLanguageRequest $request): RedirectResponse { - $request->validate([ - 'locale' => ['required', 'string', 'in:'.implode(',', array_keys(config('languages.available')))], - ]); + $request->user()->update(['locale' => $request->validated('locale')]); - return back()->withCookie( - cookie()->forever('locale', $request->locale, '/', config('session.domain')) - ); + return back(); } public function destroy(ProfileDeleteRequest $request): RedirectResponse diff --git a/app/Http/Controllers/Auth/RegisteredUserController.php b/app/Http/Controllers/Auth/RegisteredUserController.php index 0a0a46b6a..cd5ea19bd 100644 --- a/app/Http/Controllers/Auth/RegisteredUserController.php +++ b/app/Http/Controllers/Auth/RegisteredUserController.php @@ -8,6 +8,7 @@ use App\Http\Controllers\Auth\Concerns\PreservesAttributionParameters; use App\Http\Controllers\Controller; use App\Http\Requests\App\Auth\RegisterRequest; +use App\Support\LocaleResolver; use Illuminate\Auth\Events\Registered; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -26,6 +27,7 @@ public function create(Request $request): Response return Inertia::render('auth/Register', [ 'email' => $request->query('email'), 'invite' => $request->query('invite'), + 'locale' => LocaleResolver::resolve($request)->value, ]); } @@ -40,6 +42,7 @@ public function store(RegisterRequest $request): RedirectResponse 'password' => $request->validated('password'), 'is_invite' => $invite !== null, 'registration_ip' => $request->ip(), + 'locale' => $request->validated('locale') ?? LocaleResolver::resolve($request)->value, ], $attributionParameters); event(new Registered($user)); diff --git a/app/Http/Middleware/App/HandleInertiaRequests.php b/app/Http/Middleware/App/HandleInertiaRequests.php index 1978e0a4e..bd4864ba9 100644 --- a/app/Http/Middleware/App/HandleInertiaRequests.php +++ b/app/Http/Middleware/App/HandleInertiaRequests.php @@ -7,6 +7,7 @@ use App\Actions\Onboarding\ResolveOnboardingStatus; use App\Enums\Auth\SocialAuthProvider; use App\Enums\PostPlatform\ContentType; +use App\Enums\User\Locale; use App\Http\Resources\App\HandleInertiaRequests\AuthAccountResource; use App\Http\Resources\App\HandleInertiaRequests\AuthPlanResource; use App\Http\Resources\App\HandleInertiaRequests\AuthUserResource; @@ -63,10 +64,7 @@ public function share(Request $request): array 'applicationUrl' => config('app.url'), 'env' => config('app.env'), 'locale' => app()->getLocale(), - 'languages' => collect(config('languages.available'))->map(fn ($name, $code) => [ - 'code' => $code, - 'name' => $name, - ])->values()->all(), + 'languages' => Locale::options(), 'aiEnabled' => filled(config('ai.providers.'.config('ai.default').'.key')), 'selfHosted' => $isSelfHosted, 'allowMultipleSocialAccounts' => (bool) config('trypost.allow_multiple_social_accounts'), diff --git a/app/Http/Middleware/App/SetLocale.php b/app/Http/Middleware/App/SetLocale.php index 40afd5b9f..9f0d6b91c 100644 --- a/app/Http/Middleware/App/SetLocale.php +++ b/app/Http/Middleware/App/SetLocale.php @@ -4,7 +4,7 @@ namespace App\Http\Middleware\App; -use App\Enums\Workspace\ContentLanguage; +use App\Support\LocaleResolver; use Closure; use Illuminate\Http\Request; use Illuminate\Support\Facades\App; @@ -18,25 +18,11 @@ class SetLocale */ public function handle(Request $request, Closure $next): Response { - $available = config('languages.available'); - $locale = $request->cookie('locale'); - $isValid = $locale && array_key_exists($locale, $available); - $activeLocale = $isValid ? $locale : config('languages.default'); - $language = ContentLanguage::tryFrom($activeLocale) ?? ContentLanguage::DEFAULT; + $locale = LocaleResolver::resolve($request); - App::setLocale($activeLocale); - View::share('htmlDir', $language->direction()); + App::setLocale($locale->value); + View::share('htmlDir', $locale->direction()); - $response = $next($request); - - // Passport OAuth errors return a raw Symfony Response (no withCookie()). - // Attach via headers so both Illuminate and Symfony responses work. - if (! $isValid) { - $response->headers->setCookie( - cookie()->forever('locale', config('languages.default'), '/', config('session.domain')), - ); - } - - return $response; + return $next($request); } } diff --git a/app/Http/Requests/App/Auth/RegisterRequest.php b/app/Http/Requests/App/Auth/RegisterRequest.php index 13b24f6e5..e82cdd52e 100644 --- a/app/Http/Requests/App/Auth/RegisterRequest.php +++ b/app/Http/Requests/App/Auth/RegisterRequest.php @@ -4,10 +4,12 @@ namespace App\Http\Requests\App\Auth; +use App\Enums\User\Locale; use App\Models\Invite; use App\Models\User; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; use Illuminate\Validation\Rules; use Illuminate\Validation\Validator; @@ -32,6 +34,7 @@ public function rules(): array 'name' => ['required', 'string', 'max:255'], 'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class], 'password' => ['required', Rules\Password::defaults()], + 'locale' => ['nullable', Rule::enum(Locale::class)], ]; } diff --git a/app/Http/Requests/App/Settings/UpdateLanguageRequest.php b/app/Http/Requests/App/Settings/UpdateLanguageRequest.php new file mode 100644 index 000000000..1589a40ae --- /dev/null +++ b/app/Http/Requests/App/Settings/UpdateLanguageRequest.php @@ -0,0 +1,23 @@ +|string> + */ + public function rules(): array + { + return [ + 'locale' => ['required', Rule::enum(Locale::class)], + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 7557cb44c..5f2db890a 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -6,6 +6,7 @@ use App\Enums\Auth\SocialAuthProvider; use App\Enums\Notification\Type as NotificationType; +use App\Enums\User\Locale; use App\Enums\User\Persona; use App\Enums\User\ReferralSource; use App\Models\Traits\HasAccount; @@ -55,6 +56,7 @@ class User extends Authenticatable implements MustVerifyEmail, OAuthenticatable 'persona', 'goals', 'referral_source', + 'locale', ]; /** @@ -99,6 +101,7 @@ protected function casts(): array 'persona' => Persona::class, 'goals' => 'array', 'referral_source' => ReferralSource::class, + 'locale' => Locale::class, ]; } diff --git a/app/Support/LocaleResolver.php b/app/Support/LocaleResolver.php new file mode 100644 index 000000000..31c367d3b --- /dev/null +++ b/app/Support/LocaleResolver.php @@ -0,0 +1,53 @@ +user(); + + if ($user instanceof User) { + return $user->locale ?? Locale::DEFAULT; + } + + return self::fromInput($request) + ?? self::fromOldInput($request) + ?? Locale::fromAcceptLanguage($request->header('Accept-Language')) + ?? Locale::DEFAULT; + } + + private static function fromInput(Request $request): ?Locale + { + $locale = $request->input('locale'); + + return is_string($locale) ? Locale::tryFrom($locale) : null; + } + + private static function fromOldInput(Request $request): ?Locale + { + if (! $request->hasSession()) { + return null; + } + + $locale = $request->old('locale'); + + return is_string($locale) ? Locale::tryFrom($locale) : null; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 7ef532204..5bdd356a7 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -26,7 +26,7 @@ ->withMiddleware(function (Middleware $middleware): void { $middleware->trustProxies(at: '*'); - $middleware->encryptCookies(except: ['sidebar_state', 'locale']); + $middleware->encryptCookies(except: ['sidebar_state']); $middleware->web(append: [ SetLocale::class, diff --git a/config/languages.php b/config/languages.php deleted file mode 100644 index c019ef69d..000000000 --- a/config/languages.php +++ /dev/null @@ -1,48 +0,0 @@ - [ - 'en' => 'English', - 'uk' => 'Українська', - 'es' => 'Español', - 'pt-BR' => 'Português', - 'fr' => 'Français', - 'de' => 'Deutsch', - 'it' => 'Italiano', - 'nl' => 'Nederlands', - 'pl' => 'Polski', - 'el' => 'Ελληνικά', - 'ja' => '日本語', - 'ko' => '한국어', - 'zh' => '中文', - 'ru' => 'Русский', - 'tr' => 'Türkçe', - 'ar' => 'العربية', - ], - - /* - |-------------------------------------------------------------------------- - | Default Language - |-------------------------------------------------------------------------- - | - | The default locale code assigned to new users. - | - */ - - 'default' => 'en', - -]; diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 8caa11623..87f61894d 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -4,6 +4,7 @@ namespace Database\Factories; +use App\Enums\User\Locale; use App\Models\Account; use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; @@ -47,6 +48,7 @@ public function definition(): array 'utm_content' => null, 'registration_ip' => null, 'persona' => null, + 'locale' => Locale::DEFAULT, ]; } diff --git a/database/migrations/2026_09_07_211907_add_locale_to_users_table.php b/database/migrations/2026_09_07_211907_add_locale_to_users_table.php new file mode 100644 index 000000000..b0968d425 --- /dev/null +++ b/database/migrations/2026_09_07_211907_add_locale_to_users_table.php @@ -0,0 +1,25 @@ +string('locale', 10)->default(Locale::DEFAULT->value)->after('current_workspace_id'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('locale'); + }); + } +}; diff --git a/lang/ar/auth.php b/lang/ar/auth.php index e02c18e70..06a5483a5 100644 --- a/lang/ar/auth.php +++ b/lang/ar/auth.php @@ -86,6 +86,10 @@ 'show_password' => 'إظهار كلمة المرور', 'hide_password' => 'إخفاء كلمة المرور', 'submit' => 'إنشاء حساب', + 'language' => 'اللغة', + 'language_placeholder' => 'اختر لغة…', + 'language_search' => 'ابحث عن لغة…', + 'language_empty' => 'لم يتم العثور على لغة.', 'has_account' => 'هل لديك حساب بالفعل؟', 'log_in' => 'تسجيل الدخول', ], diff --git a/lang/de/auth.php b/lang/de/auth.php index 1be25cf6e..12643529e 100644 --- a/lang/de/auth.php +++ b/lang/de/auth.php @@ -88,6 +88,10 @@ 'show_password' => 'Passwort anzeigen', 'hide_password' => 'Passwort verbergen', 'submit' => 'Konto erstellen', + 'language' => 'Sprache', + 'language_placeholder' => 'Sprache auswählen…', + 'language_search' => 'Sprache suchen…', + 'language_empty' => 'Keine Sprache gefunden.', 'has_account' => 'Hast du bereits ein Konto?', 'log_in' => 'Anmelden', ], diff --git a/lang/el/auth.php b/lang/el/auth.php index 7f92bd018..0a9e4a4f3 100644 --- a/lang/el/auth.php +++ b/lang/el/auth.php @@ -86,6 +86,10 @@ 'show_password' => 'Εμφάνιση κωδικού', 'hide_password' => 'Απόκρυψη κωδικού', 'submit' => 'Δημιουργία λογαριασμού', + 'language' => 'Γλώσσα', + 'language_placeholder' => 'Επιλέξτε γλώσσα…', + 'language_search' => 'Αναζήτηση γλώσσας…', + 'language_empty' => 'Δεν βρέθηκε γλώσσα.', 'has_account' => 'Έχετε ήδη λογαριασμό;', 'log_in' => 'Σύνδεση', ], diff --git a/lang/en/auth.php b/lang/en/auth.php index 4fa03da6c..759e4a6ca 100644 --- a/lang/en/auth.php +++ b/lang/en/auth.php @@ -86,6 +86,10 @@ 'show_password' => 'Show password', 'hide_password' => 'Hide password', 'submit' => 'Create account', + 'language' => 'Language', + 'language_placeholder' => 'Select a language…', + 'language_search' => 'Search language…', + 'language_empty' => 'No language found.', 'has_account' => 'Already have an account?', 'log_in' => 'Log in', ], diff --git a/lang/es/auth.php b/lang/es/auth.php index 61bec34b8..28dfcf8a2 100644 --- a/lang/es/auth.php +++ b/lang/es/auth.php @@ -74,6 +74,10 @@ 'show_password' => 'Mostrar contraseña', 'hide_password' => 'Ocultar contraseña', 'submit' => 'Crear cuenta', + 'language' => 'Idioma', + 'language_placeholder' => 'Selecciona un idioma…', + 'language_search' => 'Buscar idioma…', + 'language_empty' => 'No se encontró ningún idioma.', 'has_account' => '¿Ya tienes una cuenta?', 'log_in' => 'Iniciar sesión', ], diff --git a/lang/fr/auth.php b/lang/fr/auth.php index 580693ca2..24b2631fd 100644 --- a/lang/fr/auth.php +++ b/lang/fr/auth.php @@ -86,6 +86,10 @@ 'show_password' => 'Afficher le mot de passe', 'hide_password' => 'Masquer le mot de passe', 'submit' => 'Créer un compte', + 'language' => 'Langue', + 'language_placeholder' => 'Sélectionnez une langue…', + 'language_search' => 'Rechercher une langue…', + 'language_empty' => 'Aucune langue trouvée.', 'has_account' => 'Vous avez déjà un compte ?', 'log_in' => 'Se connecter', ], diff --git a/lang/it/auth.php b/lang/it/auth.php index 5defda153..9ab200fd5 100644 --- a/lang/it/auth.php +++ b/lang/it/auth.php @@ -86,6 +86,10 @@ 'show_password' => 'Mostra password', 'hide_password' => 'Nascondi password', 'submit' => 'Crea account', + 'language' => 'Lingua', + 'language_placeholder' => 'Seleziona una lingua…', + 'language_search' => 'Cerca lingua…', + 'language_empty' => 'Nessuna lingua trovata.', 'has_account' => 'Hai già un account?', 'log_in' => 'Accedi', ], diff --git a/lang/ja/auth.php b/lang/ja/auth.php index 3d6929c6e..d51f92b0e 100644 --- a/lang/ja/auth.php +++ b/lang/ja/auth.php @@ -86,6 +86,10 @@ 'show_password' => 'パスワードを表示', 'hide_password' => 'パスワードを隠す', 'submit' => 'アカウントを作成', + 'language' => '言語', + 'language_placeholder' => '言語を選択…', + 'language_search' => '言語を検索…', + 'language_empty' => '言語が見つかりません。', 'has_account' => 'すでにアカウントをお持ちですか?', 'log_in' => 'ログイン', ], diff --git a/lang/ko/auth.php b/lang/ko/auth.php index 1f55b8482..dcacb8b33 100644 --- a/lang/ko/auth.php +++ b/lang/ko/auth.php @@ -86,6 +86,10 @@ 'show_password' => '비밀번호 표시', 'hide_password' => '비밀번호 숨기기', 'submit' => '계정 만들기', + 'language' => '언어', + 'language_placeholder' => '언어 선택…', + 'language_search' => '언어 검색…', + 'language_empty' => '언어를 찾을 수 없습니다.', 'has_account' => '이미 계정이 있으신가요?', 'log_in' => '로그인', ], diff --git a/lang/nl/auth.php b/lang/nl/auth.php index 995f3ebbd..b79195b89 100644 --- a/lang/nl/auth.php +++ b/lang/nl/auth.php @@ -86,6 +86,10 @@ 'show_password' => 'Wachtwoord tonen', 'hide_password' => 'Wachtwoord verbergen', 'submit' => 'Account aanmaken', + 'language' => 'Taal', + 'language_placeholder' => 'Selecteer een taal…', + 'language_search' => 'Taal zoeken…', + 'language_empty' => 'Geen taal gevonden.', 'has_account' => 'Heb je al een account?', 'log_in' => 'Inloggen', ], diff --git a/lang/pl/auth.php b/lang/pl/auth.php index 1d80a6852..e07199f57 100644 --- a/lang/pl/auth.php +++ b/lang/pl/auth.php @@ -86,6 +86,10 @@ 'show_password' => 'Pokaż hasło', 'hide_password' => 'Ukryj hasło', 'submit' => 'Utwórz konto', + 'language' => 'Język', + 'language_placeholder' => 'Wybierz język…', + 'language_search' => 'Szukaj języka…', + 'language_empty' => 'Nie znaleziono języka.', 'has_account' => 'Masz już konto?', 'log_in' => 'Zaloguj się', ], diff --git a/lang/pt-BR/auth.php b/lang/pt-BR/auth.php index 34e4f8c6c..4a91877d3 100644 --- a/lang/pt-BR/auth.php +++ b/lang/pt-BR/auth.php @@ -86,6 +86,10 @@ 'show_password' => 'Mostrar senha', 'hide_password' => 'Esconder senha', 'submit' => 'Criar conta', + 'language' => 'Idioma', + 'language_placeholder' => 'Selecione um idioma…', + 'language_search' => 'Buscar idioma…', + 'language_empty' => 'Nenhum idioma encontrado.', 'has_account' => 'Já tem uma conta?', 'log_in' => 'Entrar', ], diff --git a/lang/ru/auth.php b/lang/ru/auth.php index df7bc03c9..5caf584a4 100644 --- a/lang/ru/auth.php +++ b/lang/ru/auth.php @@ -86,6 +86,10 @@ 'show_password' => 'Показать пароль', 'hide_password' => 'Скрыть пароль', 'submit' => 'Создать аккаунт', + 'language' => 'Язык', + 'language_placeholder' => 'Выберите язык…', + 'language_search' => 'Поиск языка…', + 'language_empty' => 'Язык не найден.', 'has_account' => 'Уже есть аккаунт?', 'log_in' => 'Войти', ], diff --git a/lang/tr/auth.php b/lang/tr/auth.php index a8ecbc75a..8f578a5b7 100644 --- a/lang/tr/auth.php +++ b/lang/tr/auth.php @@ -88,6 +88,10 @@ 'show_password' => 'Parolayı göster', 'hide_password' => 'Parolayı gizle', 'submit' => 'Hesap oluştur', + 'language' => 'Dil', + 'language_placeholder' => 'Bir dil seçin…', + 'language_search' => 'Dil ara…', + 'language_empty' => 'Dil bulunamadı.', 'has_account' => 'Zaten bir hesabınız var mı?', 'log_in' => 'Giriş yap', ], diff --git a/lang/uk/auth.php b/lang/uk/auth.php index f4ff78ce8..431a6568a 100644 --- a/lang/uk/auth.php +++ b/lang/uk/auth.php @@ -86,6 +86,10 @@ 'show_password' => 'Показати пароль', 'hide_password' => 'Приховати пароль', 'submit' => 'Створити обліковий запис', + 'language' => 'Мова', + 'language_placeholder' => 'Оберіть мову…', + 'language_search' => 'Пошук мови…', + 'language_empty' => 'Мову не знайдено.', 'has_account' => 'Уже маєте обліковий запис?', 'log_in' => 'Увійти', ], diff --git a/lang/zh/auth.php b/lang/zh/auth.php index 0b914697f..ea7153ab2 100644 --- a/lang/zh/auth.php +++ b/lang/zh/auth.php @@ -86,6 +86,10 @@ 'show_password' => '显示密码', 'hide_password' => '隐藏密码', 'submit' => '创建账户', + 'language' => '语言', + 'language_placeholder' => '选择语言…', + 'language_search' => '搜索语言…', + 'language_empty' => '未找到语言。', 'has_account' => '已经有账户了?', 'log_in' => '登录', ], diff --git a/resources/js/components/LanguagePicker.vue b/resources/js/components/LanguagePicker.vue index d8dd6e59c..bbb75af5c 100644 --- a/resources/js/components/LanguagePicker.vue +++ b/resources/js/components/LanguagePicker.vue @@ -46,6 +46,7 @@ const select = (code: string) => { :aria-expanded="open" :disabled="disabled" class="w-full justify-between font-normal" + data-testid="language-picker" > {{ selectedLabel || placeholder }} @@ -64,6 +65,7 @@ const select = (code: string) => { v-for="option in options" :key="option.value" :value="option.value" + :data-testid="`language-option-${option.value}`" @select="select(option.value)" > {{ option.label }} diff --git a/resources/js/components/WorkspaceMenuContent.vue b/resources/js/components/WorkspaceMenuContent.vue index 7ff8eaa5f..2633153e8 100644 --- a/resources/js/components/WorkspaceMenuContent.vue +++ b/resources/js/components/WorkspaceMenuContent.vue @@ -33,7 +33,7 @@ import { create as createWorkspaceRoute, switchMethod, } from '@/routes/app/workspaces'; -import type { User } from '@/types'; +import type { Language, User } from '@/types'; interface Workspace { id: string; @@ -41,11 +41,6 @@ interface Workspace { logo_url: string | null; } -interface Language { - code: string; - name: string; -} - const props = defineProps<{ user: User; currentWorkspace: Workspace | null; diff --git a/resources/js/pages/auth/Register.vue b/resources/js/pages/auth/Register.vue index 2c5c5087f..18cfb3686 100644 --- a/resources/js/pages/auth/Register.vue +++ b/resources/js/pages/auth/Register.vue @@ -1,11 +1,13 @@