From bc43af00611ea87586389b258c4a274ee0604da9 Mon Sep 17 00:00:00 2001 From: "Maksym Hryzodub [DREAM]" Date: Wed, 19 Aug 2026 16:44:39 +0300 Subject: [PATCH 1/2] feat(app): extract the user slice into i18n, en+ru (CLEAN-37) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The auth screens were fully hardcoded and the slice had no i18n block at all. Adds one (registering the shared LOCALES) plus en/ru locale files, and moves all 39 strings out of the templates: the form, the marketing panel on the auth layout, and the closed-registration state. Applies the "script holds keys, not text" rule from the ADR, which this slice is the reason for. Field validation now assigns 'account.email_invalid' rather than "Enter a valid email"; the state-dependent title, subtitle and submit label became *Key computeds rendered with $t. Both providers lose their hardcoded English failure fallback — the form owns that copy and picks the key by mode, so a provider now reports only whether the call failed and what the server said. useI18n() is not used anywhere in the slice. Adds a guard to i18n-sync for the bug this uncovered: "jane@example.com" compiles to a linked-message reference in vue-i18n and fails the build. The escaped literal {'@'} is now required, i18n:check reports a bare @ or | on either side, the translator prompt states the rule, and a reply that breaks it is rejected before it reaches disk. Worth catching there because CI runs typecheck/lint/test and this script — not nuxt build — so a broken message would otherwise surface in production. Verified: nuxt typecheck and nuxt build clean, ru strings in the client chunks, i18n:check green over 5 slice/locale pairs, and a re-scan finds zero hardcoded strings left in the slice. Co-Authored-By: Claude Opus 5 (1M context) --- app/i18n.sync.json | 42 +++++++++ .../user/auth/components/auth/common/Form.vue | 91 +++++++++++-------- .../auth/components/auth/login/Provider.vue | 7 +- .../components/auth/register/Provider.vue | 14 +-- app/slices/user/auth/i18n/locales/en.json | 46 ++++++++++ app/slices/user/auth/i18n/locales/ru.json | 46 ++++++++++ app/slices/user/auth/layouts/auth.vue | 23 +++-- app/slices/user/auth/nuxt.config.ts | 7 ++ scripts/i18n-sync.ts | 34 ++++++- 9 files changed, 251 insertions(+), 59 deletions(-) create mode 100644 app/slices/user/auth/i18n/locales/en.json create mode 100644 app/slices/user/auth/i18n/locales/ru.json diff --git a/app/i18n.sync.json b/app/i18n.sync.json index ba9dd67e..50e804a0 100644 --- a/app/i18n.sync.json +++ b/app/i18n.sync.json @@ -79,6 +79,48 @@ "template_image": "140d5d5895d7", "template_name": "f95e97c2f767", "templates": "56b564b75c7f" + }, + "user/auth:ru": { + "account.closed_body": "0be53513ddac", + "account.closed_cta": "27a0d553bf29", + "account.closed_title": "dd5e053a970d", + "account.create_account": "86033f75a4c0", + "account.create_subtitle": "c5aff35f38e9", + "account.create_title": "9e709348f582", + "account.email_invalid": "c6e81fdf78b3", + "account.email_label": "969ccbd3cf63", + "account.email_placeholder": "9a17bdc59659", + "account.email_required": "aae929110124", + "account.have_account": "e77fea936d3e", + "account.login_failed": "5a96e8452f1e", + "account.name_label": "dcd1d5223f73", + "account.name_placeholder": "01332c876518", + "account.name_required": "604fd1b2e29c", + "account.need_access": "ab1a55b13fa2", + "account.new_here": "38c1c4457cd1", + "account.password_hide": "a60a56c584b3", + "account.password_hint": "e4e54a0028c8", + "account.password_label": "e7cf3ef4f17c", + "account.password_required": "7aeae1412484", + "account.password_show": "6aeaa6a53d09", + "account.password_too_short": "977f3b2676a9", + "account.register_failed": "d40e3b08ef50", + "account.sign_in": "bfd402b2f6f3", + "account.submit_login": "bfd402b2f6f3", + "account.submit_login_pending": "8b9d3de8ae00", + "account.submit_register": "798ca2ce18bf", + "account.submit_register_pending": "f2daf61e527b", + "account.welcome_subtitle": "c2eae8162dd8", + "account.welcome_title": "6621249514b7", + "promo.back_to_landing": "a0653cf86b3c", + "promo.badge": "de065a5c850b", + "promo.feature_deploy": "b8d686e626c7", + "promo.feature_state": "dcb893a52450", + "promo.feature_streaming": "eb803528b70d", + "promo.footer": "8f3bab563458", + "promo.lede": "0877d8f80ff2", + "promo.title": "9f28248b81cc", + "promo.title_accent": "09659d3fbc68" } } } diff --git a/app/slices/user/auth/components/auth/common/Form.vue b/app/slices/user/auth/components/auth/common/Form.vue index b5053ba7..482d6ee3 100644 --- a/app/slices/user/auth/components/auth/common/Form.vue +++ b/app/slices/user/auth/components/auth/common/Form.vue @@ -4,7 +4,10 @@ type Mode = 'login' | 'register'; const props = defineProps<{ mode: Mode; submitting?: boolean; + /** Human text from the API — already English, not a key. Null when the call + * failed without a usable message, in which case `failed` drives our copy. */ errorMessage?: string | null; + failed?: boolean; registrationEnabled?: boolean; }>(); @@ -13,6 +16,9 @@ const emit = defineEmits<{ }>(); const form = reactive({ name: '', email: '', password: '' }); +// Validation state holds i18n KEYS, not sentences: the template renders them +// with $t. Keeping the text here would hide user-visible copy inside branching +// logic, where the next extraction pass would never find it. const errors = reactive< Partial> >({}); @@ -24,21 +30,23 @@ const showPassword = ref(false); function validateField(field: 'name' | 'email' | 'password') { if (field === 'name') { errors.name = - props.mode === 'register' && !form.name.trim() ? 'Name is required' : undefined; + props.mode === 'register' && !form.name.trim() + ? 'account.name_required' + : undefined; return; } if (field === 'email') { const email = form.email.trim(); - if (!email) errors.email = 'Email is required'; + if (!email) errors.email = 'account.email_required'; else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) - errors.email = 'Enter a valid email'; + errors.email = 'account.email_invalid'; else errors.email = undefined; return; } if (field === 'password') { - if (!form.password) errors.password = 'Password is required'; + if (!form.password) errors.password = 'account.password_required'; else if (props.mode === 'register' && form.password.length < 8) - errors.password = 'At least 8 characters'; + errors.password = 'account.password_too_short'; else errors.password = undefined; } } @@ -65,24 +73,29 @@ function onSubmit() { }); } -const title = computed(() => - props.mode === 'login' ? 'Welcome back' : 'Create your account', +const titleKey = computed(() => + props.mode === 'login' ? 'account.welcome_title' : 'account.create_title', ); -const subtitle = computed(() => +const subtitleKey = computed(() => props.mode === 'login' - ? 'Sign in to manage your agents.' - : 'Get started with your AI workers.', + ? 'account.welcome_subtitle' + : 'account.create_subtitle', ); -const submitLabel = computed(() => - props.submitting - ? props.mode === 'login' - ? 'Signing in…' - : 'Creating account…' - : props.mode === 'login' - ? 'Sign in' - : 'Create account', +const submitLabelKey = computed(() => { + if (props.submitting) { + return props.mode === 'login' + ? 'account.submit_login_pending' + : 'account.submit_register_pending'; + } + return props.mode === 'login' + ? 'account.submit_login' + : 'account.submit_register'; +}); + +const failureKey = computed(() => + props.mode === 'login' ? 'account.login_failed' : 'account.register_failed', ); const inputClass = (field: 'name' | 'email' | 'password') => @@ -103,8 +116,8 @@ function showError(field: 'name' | 'email' | 'password') {