From 23989b14055905797fdb299a300dbce322f132d0 Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Mon, 17 Aug 2026 00:44:57 -0300 Subject: [PATCH] feat(dashboard): localize the tenant app with react-i18next Dashboard slice of the i18n work (split of #1344 as requested in review). Self-contained: it needs nothing from the backend slices, and the backend needs nothing from it. - `react-i18next` wiring in `src/i18n.ts`, language detected from the stored preference and negotiated with the API through `Accept-Language`. - English and Brazilian Portuguese catalogs, split per feature namespace, covering catalog, chat, tickets, files, billing, identity and settings. - Language switcher in the topbar; the chosen language is persisted to the user profile, and `html[lang]` follows it through a `languageChanged` listener. - The topbar stops hydrating the language from the profile once the user has chosen one in the session, so a concurrent profile save cannot silently switch the app back. The underlying lost update on `PUT /identity/profile` is tracked in #1359 and fixed separately. - Impersonation handoff adopts `locale` from the URL before `createRoot`, so an operator arriving from the admin app keeps their language. Harmless on its own: on `main` nothing sends the parameter yet. - Playwright specs pin catalog parity (keys and placeholders, both directions), the switcher, the shell, the hydration guard and the handoff parameter. The `SSH.NET` pin (`2026.0.0`) rides along because `template-smoke.yml` runs on `clients/**` and builds the scaffolded solution, which fails `restore` with `NU1903` until #1333 merges. It is byte-identical to that PR. --- clients/dashboard/package-lock.json | 105 +++++- clients/dashboard/package.json | 3 + clients/dashboard/playwright.config.ts | 7 + clients/dashboard/public/config.json | 3 +- clients/dashboard/src/api/identity.ts | 8 +- .../src/auth/impersonation-handoff.ts | 21 +- .../dashboard/src/auth/protected-route.tsx | 4 +- .../src/components/auth/auth-shell.tsx | 8 +- .../components/auth/demo-accounts-dialog.tsx | 26 +- .../src/components/auth/inactivity-dialog.tsx | 13 +- .../command-palette-dialog.tsx | 166 ++++----- .../src/components/file/file-dropzone.tsx | 44 ++- .../components/file/file-preview-dialog.tsx | 87 +++-- .../src/components/file/image-input.tsx | 22 +- .../components/file/product-image-manager.tsx | 55 +-- .../src/components/identity/user-picker.tsx | 16 +- .../src/components/layout/app-shell.tsx | 4 +- .../src/components/layout/expiry-banner.tsx | 34 +- .../layout/impersonation-banner.tsx | 20 +- .../src/components/layout/mobile-nav.tsx | 9 +- .../src/components/layout/nav-data.ts | 54 +-- .../src/components/layout/sidebar.tsx | 27 +- .../src/components/layout/topbar.tsx | 177 +++++++-- .../src/components/list/combobox.tsx | 6 +- .../src/components/list/entity-shell.tsx | 21 +- .../src/components/list/error-band.tsx | 5 +- .../dashboard/src/components/list/field.tsx | 4 +- .../notifications/chat-global-notifier.tsx | 20 +- .../notifications/chat-unread-badge.tsx | 6 +- .../notifications/notification-bell.tsx | 35 +- .../realtime/realtime-status-pill.tsx | 21 +- .../dashboard/src/components/route-error.tsx | 15 +- .../dashboard/src/components/ui/dialog.tsx | 19 +- clients/dashboard/src/env.ts | 4 + clients/dashboard/src/i18n.ts | 117 ++++++ clients/dashboard/src/lib/api-client.ts | 33 +- clients/dashboard/src/lib/list-helpers.ts | 78 ++-- clients/dashboard/src/lib/ticket-enums.ts | 25 +- .../dashboard/src/locales/en-US/activity.json | 21 ++ .../dashboard/src/locales/en-US/audits.json | 93 +++++ clients/dashboard/src/locales/en-US/auth.json | 113 ++++++ .../dashboard/src/locales/en-US/catalog.json | 204 ++++++++++ clients/dashboard/src/locales/en-US/chat.json | 216 +++++++++++ .../src/locales/en-US/commandPalette.json | 83 +++++ .../dashboard/src/locales/en-US/common.json | 136 +++++++ .../dashboard/src/locales/en-US/files.json | 130 +++++++ .../dashboard/src/locales/en-US/health.json | 48 +++ .../dashboard/src/locales/en-US/identity.json | 347 ++++++++++++++++++ .../src/locales/en-US/notifications.json | 32 ++ .../dashboard/src/locales/en-US/overview.json | 108 ++++++ .../dashboard/src/locales/en-US/settings.json | 219 +++++++++++ .../src/locales/en-US/subscription.json | 125 +++++++ .../dashboard/src/locales/en-US/system.json | 80 ++++ .../dashboard/src/locales/en-US/tickets.json | 119 ++++++ .../dashboard/src/locales/pt-BR/activity.json | 21 ++ .../dashboard/src/locales/pt-BR/audits.json | 93 +++++ clients/dashboard/src/locales/pt-BR/auth.json | 107 ++++++ .../dashboard/src/locales/pt-BR/catalog.json | 204 ++++++++++ clients/dashboard/src/locales/pt-BR/chat.json | 206 +++++++++++ .../src/locales/pt-BR/commandPalette.json | 83 +++++ .../dashboard/src/locales/pt-BR/common.json | 136 +++++++ .../dashboard/src/locales/pt-BR/files.json | 130 +++++++ .../dashboard/src/locales/pt-BR/health.json | 48 +++ .../dashboard/src/locales/pt-BR/identity.json | 331 +++++++++++++++++ .../src/locales/pt-BR/notifications.json | 32 ++ .../dashboard/src/locales/pt-BR/overview.json | 96 +++++ .../dashboard/src/locales/pt-BR/settings.json | 209 +++++++++++ .../src/locales/pt-BR/subscription.json | 118 ++++++ .../dashboard/src/locales/pt-BR/system.json | 80 ++++ .../dashboard/src/locales/pt-BR/tickets.json | 119 ++++++ clients/dashboard/src/main.tsx | 13 +- clients/dashboard/src/pages/activity.tsx | 51 +-- clients/dashboard/src/pages/audits.tsx | 251 +++++++------ .../src/pages/auth/confirm-email.tsx | 32 +- .../src/pages/auth/forgot-password.tsx | 42 ++- .../src/pages/auth/reset-password.tsx | 65 ++-- .../dashboard/src/pages/catalog/brands.tsx | 97 ++--- .../src/pages/catalog/categories.tsx | 112 +++--- .../src/pages/catalog/product-detail.tsx | 196 +++++----- .../dashboard/src/pages/catalog/products.tsx | 191 +++++----- .../dashboard/src/pages/chat/channel-rail.tsx | 78 ++-- .../src/pages/chat/channel-settings.tsx | 84 ++--- .../dashboard/src/pages/chat/chat-page.tsx | 38 +- .../dashboard/src/pages/chat/chat-pinned.tsx | 20 +- .../dashboard/src/pages/chat/chat-search.tsx | 18 +- .../dashboard/src/pages/chat/chat-utils.ts | 17 +- clients/dashboard/src/pages/chat/composer.tsx | 65 ++-- .../src/pages/chat/mention-picker.tsx | 17 +- .../dashboard/src/pages/chat/message-list.tsx | 20 +- clients/dashboard/src/pages/chat/message.tsx | 96 ++--- .../src/pages/chat/typing-indicator.tsx | 42 +-- .../dashboard/src/pages/files/my-files.tsx | 72 ++-- clients/dashboard/src/pages/health.tsx | 114 +++--- .../src/pages/identity/group-detail.tsx | 129 +++---- .../dashboard/src/pages/identity/groups.tsx | 86 ++--- .../src/pages/identity/role-detail.tsx | 136 +++---- .../dashboard/src/pages/identity/roles.tsx | 79 ++-- .../src/pages/identity/user-detail.tsx | 206 +++++------ .../dashboard/src/pages/identity/users.tsx | 118 +++--- .../src/pages/impersonation-ended.tsx | 9 +- .../dashboard/src/pages/invoice-detail.tsx | 68 ++-- clients/dashboard/src/pages/invoices.tsx | 81 ++-- clients/dashboard/src/pages/login.tsx | 34 +- clients/dashboard/src/pages/not-found.tsx | 15 +- clients/dashboard/src/pages/overview.tsx | 254 ++++++------- .../dashboard/src/pages/settings/api-keys.tsx | 15 +- .../src/pages/settings/appearance.tsx | 92 ++--- .../dashboard/src/pages/settings/branding.tsx | 108 +++--- .../src/pages/settings/notifications.tsx | 15 +- .../dashboard/src/pages/settings/profile.tsx | 45 ++- .../dashboard/src/pages/settings/security.tsx | 187 +++++----- .../src/pages/settings/settings-layout.tsx | 46 +-- clients/dashboard/src/pages/subscription.tsx | 92 ++--- .../dashboard/src/pages/system/sessions.tsx | 84 +++-- clients/dashboard/src/pages/system/trash.tsx | 116 +++--- .../src/pages/tenant-deactivated.tsx | 9 +- .../src/pages/tickets/ticket-detail.tsx | 137 +++---- .../dashboard/src/pages/tickets/tickets.tsx | 96 ++--- clients/dashboard/src/pages/wallet.tsx | 60 +-- clients/dashboard/src/routes.tsx | 4 +- .../dashboard/tests/billing/wallet.spec.ts | 4 +- clients/dashboard/tests/helpers/api-mocks.ts | 9 +- clients/dashboard/tests/helpers/auth-seed.ts | 75 +++- .../tests/i18n/hydration-guard.spec.ts | 95 +++++ clients/dashboard/tests/i18n/i18n.spec.ts | 95 +++++ clients/dashboard/tests/i18n/parity.spec.ts | 42 +++ clients/dashboard/tests/i18n/shell.spec.ts | 43 +++ clients/dashboard/tests/i18n/switcher.spec.ts | 266 ++++++++++++++ .../impersonation/handoff-locale.spec.ts | 119 ++++++ .../impersonation/impersonation-ended.spec.ts | 51 +-- .../tests/system/tenant-deactivated.spec.ts | 71 ++++ clients/dashboard/tsconfig.json | 3 +- clients/dashboard/tsconfig.tests.json | 24 ++ src/Directory.Packages.props | 7 + 134 files changed, 7969 insertions(+), 2396 deletions(-) create mode 100644 clients/dashboard/src/i18n.ts create mode 100644 clients/dashboard/src/locales/en-US/activity.json create mode 100644 clients/dashboard/src/locales/en-US/audits.json create mode 100644 clients/dashboard/src/locales/en-US/auth.json create mode 100644 clients/dashboard/src/locales/en-US/catalog.json create mode 100644 clients/dashboard/src/locales/en-US/chat.json create mode 100644 clients/dashboard/src/locales/en-US/commandPalette.json create mode 100644 clients/dashboard/src/locales/en-US/common.json create mode 100644 clients/dashboard/src/locales/en-US/files.json create mode 100644 clients/dashboard/src/locales/en-US/health.json create mode 100644 clients/dashboard/src/locales/en-US/identity.json create mode 100644 clients/dashboard/src/locales/en-US/notifications.json create mode 100644 clients/dashboard/src/locales/en-US/overview.json create mode 100644 clients/dashboard/src/locales/en-US/settings.json create mode 100644 clients/dashboard/src/locales/en-US/subscription.json create mode 100644 clients/dashboard/src/locales/en-US/system.json create mode 100644 clients/dashboard/src/locales/en-US/tickets.json create mode 100644 clients/dashboard/src/locales/pt-BR/activity.json create mode 100644 clients/dashboard/src/locales/pt-BR/audits.json create mode 100644 clients/dashboard/src/locales/pt-BR/auth.json create mode 100644 clients/dashboard/src/locales/pt-BR/catalog.json create mode 100644 clients/dashboard/src/locales/pt-BR/chat.json create mode 100644 clients/dashboard/src/locales/pt-BR/commandPalette.json create mode 100644 clients/dashboard/src/locales/pt-BR/common.json create mode 100644 clients/dashboard/src/locales/pt-BR/files.json create mode 100644 clients/dashboard/src/locales/pt-BR/health.json create mode 100644 clients/dashboard/src/locales/pt-BR/identity.json create mode 100644 clients/dashboard/src/locales/pt-BR/notifications.json create mode 100644 clients/dashboard/src/locales/pt-BR/overview.json create mode 100644 clients/dashboard/src/locales/pt-BR/settings.json create mode 100644 clients/dashboard/src/locales/pt-BR/subscription.json create mode 100644 clients/dashboard/src/locales/pt-BR/system.json create mode 100644 clients/dashboard/src/locales/pt-BR/tickets.json create mode 100644 clients/dashboard/tests/i18n/hydration-guard.spec.ts create mode 100644 clients/dashboard/tests/i18n/i18n.spec.ts create mode 100644 clients/dashboard/tests/i18n/parity.spec.ts create mode 100644 clients/dashboard/tests/i18n/shell.spec.ts create mode 100644 clients/dashboard/tests/i18n/switcher.spec.ts create mode 100644 clients/dashboard/tests/impersonation/handoff-locale.spec.ts create mode 100644 clients/dashboard/tests/system/tenant-deactivated.spec.ts create mode 100644 clients/dashboard/tsconfig.tests.json diff --git a/clients/dashboard/package-lock.json b/clients/dashboard/package-lock.json index cfe3247a57..929e96dec1 100644 --- a/clients/dashboard/package-lock.json +++ b/clients/dashboard/package-lock.json @@ -18,10 +18,13 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "i18next": "^26.3.6", + "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^0.475.0", "qrcode": "^1.5.4", "react": "^19.2.6", "react-dom": "^19.2.6", + "react-i18next": "^17.0.10", "react-router-dom": "^7.15.1", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0" @@ -282,6 +285,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -4815,6 +4827,52 @@ "node": ">= 0.4" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/i18next": { + "version": "26.3.6", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz", + "integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "peerDependencies": { + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz", + "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -6183,6 +6241,33 @@ "react": "^19.2.6" } }, + "node_modules/react-i18next": { + "version": "17.0.10", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.10.tgz", + "integrity": "sha512-XneHftyYA774MJkkccSkZ5oKrUpCnXIPmxio3wemqrVzCRLWiGXOMbIzObrer03fNDEnm8g8R5yYls4HcE+esg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.2.0", + "react": ">= 16.8.0", + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -7000,7 +7085,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -7163,6 +7248,15 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "7.3.5", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", @@ -7238,6 +7332,15 @@ } } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/clients/dashboard/package.json b/clients/dashboard/package.json index 0ccc2d8485..13fbbdf435 100644 --- a/clients/dashboard/package.json +++ b/clients/dashboard/package.json @@ -22,10 +22,13 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "i18next": "^26.3.6", + "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^0.475.0", "qrcode": "^1.5.4", "react": "^19.2.6", "react-dom": "^19.2.6", + "react-i18next": "^17.0.10", "react-router-dom": "^7.15.1", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0" diff --git a/clients/dashboard/playwright.config.ts b/clients/dashboard/playwright.config.ts index 6e494b1c9e..c790a24de1 100644 --- a/clients/dashboard/playwright.config.ts +++ b/clients/dashboard/playwright.config.ts @@ -40,6 +40,13 @@ export default defineConfig({ navigationTimeout: 15_000, }, + // Assertions get the same budget as actions. Left at the 5s default they were + // the tightest deadline in the suite — every test ends in a toBeVisible, and + // under CPU contention (a second suite running, a loaded dev server) the first + // paint of a lazy route lands past 5s while staying well inside the action and + // navigation budgets. That asymmetry, not the specs, is what made runs flaky. + expect: { timeout: 10_000 }, + projects: [ { name: "chromium", diff --git a/clients/dashboard/public/config.json b/clients/dashboard/public/config.json index 2245e87ae2..5af9d5c9b3 100644 --- a/clients/dashboard/public/config.json +++ b/clients/dashboard/public/config.json @@ -3,5 +3,6 @@ "defaultTenant": "root", "demoMode": true, "inactivityIdleMs": 1200000, - "inactivityWarningMs": 60000 + "inactivityWarningMs": 60000, + "defaultLanguage": "en-US" } diff --git a/clients/dashboard/src/api/identity.ts b/clients/dashboard/src/api/identity.ts index 3297c8c18a..3c2e0903d0 100644 --- a/clients/dashboard/src/api/identity.ts +++ b/clients/dashboard/src/api/identity.ts @@ -16,6 +16,7 @@ export type UserDto = { phoneNumber?: string; imageUrl?: string; twoFactorEnabled?: boolean; + locale?: string | null; }; export type UserRoleDto = { @@ -394,6 +395,8 @@ export type UpdateProfileInput = { firstName?: string | null; lastName?: string | null; phoneNumber?: string | null; + /** BCP 47 UI language tag persisted on the user (drives the JWT locale claim). */ + locale?: string | null; }; /** @@ -401,7 +404,9 @@ export type UpdateProfileInput = { * server-side. Image and email changes go through their own dedicated * endpoints — this is for the editable profile fields surfaced in * settings/profile. Reads the current profile first so unset optional - * fields keep their existing values instead of being nulled. + * fields keep their existing values instead of being nulled — the backend + * sets FirstName/LastName unconditionally from the command, so a locale-only + * save (the language switcher) would otherwise wipe the names. */ export async function updateMyProfile(input: UpdateProfileInput): Promise { const profile = await getMyProfile(); @@ -412,6 +417,7 @@ export async function updateMyProfile(input: UpdateProfileInput): Promise firstName: input.firstName ?? profile.firstName ?? null, lastName: input.lastName ?? profile.lastName ?? null, phoneNumber: input.phoneNumber ?? profile.phoneNumber ?? null, + locale: input.locale ?? profile.locale ?? null, email: profile.email, deleteCurrentImage: false, }), diff --git a/clients/dashboard/src/auth/impersonation-handoff.ts b/clients/dashboard/src/auth/impersonation-handoff.ts index c8bdfe972d..7f2e24dea2 100644 --- a/clients/dashboard/src/auth/impersonation-handoff.ts +++ b/clients/dashboard/src/auth/impersonation-handoff.ts @@ -1,11 +1,12 @@ import { tokenStore } from "@/auth/token-store"; +import i18n, { SUPPORTED } from "@/i18n"; /** * Cross-app impersonation handoff. The admin app issues an impersonation * access token server-side, then opens the dashboard with the token in the * URL hash: * - * https://dashboard.example.com/#impersonate?token=&tenant=&expiresAt= + * https://dashboard.example.com/#impersonate?token=&tenant=&expiresAt=&locale= * * We use the hash (not query) for two reasons: * 1. Browsers never send the fragment in HTTP requests, so the token can't @@ -13,12 +14,12 @@ import { tokenStore } from "@/auth/token-store"; * 2. SPA hash routes are already a thing — the bootstrap can scrub the * hash before any router runs, without touching the path. * - * Call this synchronously in main.tsx BEFORE createRoot so the token is - * installed before AuthProvider's first render — otherwise ProtectedRoute + * Await this in main.tsx BEFORE createRoot so both the token and the language + * are installed before AuthProvider's first render — otherwise ProtectedRoute * would see an anonymous session, redirect to /login, and the user would * have to sign in even though we have a valid impersonation token. */ -export function installImpersonationFromHash(): void { +export async function installImpersonationFromHash(): Promise { if (typeof window === "undefined") return; const hash = window.location.hash; if (!hash.startsWith("#impersonate?")) return; @@ -26,6 +27,7 @@ export function installImpersonationFromHash(): void { const params = new URLSearchParams(hash.slice("#impersonate?".length)); const token = params.get("token"); const tenant = params.get("tenant"); + const locale = params.get("locale"); if (!token) { // Malformed handoff — strip the hash and let the normal sign-in flow // take over rather than getting stuck. @@ -40,6 +42,17 @@ export function installImpersonationFromHash(): void { // which mints a real actor token+refresh for the admin operator's account. tokenStore.beginImpersonation(token, tenant); stripHash(); + + // Adopt the operator's language for the impersonation session. The server + // strips the target's `locale` claim on purpose, and this app is normally on a + // different origin than admin, so the handoff parameter is the only channel + // that carries the operator's choice. Applying it here also fixes the API + // side: apiFetch derives Accept-Language from i18n.language, so responses come + // back in the operator's language instead of the browser-detected one. + // Unsupported or absent tags are ignored, leaving normal detection in place. + if (locale && (SUPPORTED as readonly string[]).includes(locale) && i18n.language !== locale) { + await i18n.changeLanguage(locale); + } } function stripHash(): void { diff --git a/clients/dashboard/src/auth/protected-route.tsx b/clients/dashboard/src/auth/protected-route.tsx index 2b43eed164..f7633149c5 100644 --- a/clients/dashboard/src/auth/protected-route.tsx +++ b/clients/dashboard/src/auth/protected-route.tsx @@ -1,7 +1,9 @@ import { Navigate, Outlet, useLocation } from "react-router-dom"; +import { useTranslation } from "react-i18next"; import { useAuth } from "@/auth/use-auth"; export function ProtectedRoute() { + const { t } = useTranslation("common"); const { isAuthenticated, isInitializing } = useAuth(); const location = useLocation(); @@ -15,7 +17,7 @@ export function ProtectedRoute() { role="status" aria-busy="true" > - Restoring your session… + {t("session.restoring")} {/* Atmospheric background — three rose/saffron blur orbs at @@ -77,7 +79,7 @@ export function AuthShell({
- .NET 10 Starter Kit + {t("shell.tagline")}
@@ -95,10 +97,10 @@ export function AuthShell({
- Encrypted in transit · JWT-secured session + {t("shell.encrypted")}

- fullstackhero Administration + {t("shell.footer")}

diff --git a/clients/dashboard/src/components/auth/demo-accounts-dialog.tsx b/clients/dashboard/src/components/auth/demo-accounts-dialog.tsx index db5391adca..4b8a195686 100644 --- a/clients/dashboard/src/components/auth/demo-accounts-dialog.tsx +++ b/clients/dashboard/src/components/auth/demo-accounts-dialog.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; import { ArrowUpRight } from "lucide-react"; import { Dialog, @@ -30,6 +31,7 @@ interface DemoAccountsDialogProps { } export function DemoAccountsDialog({ open, onOpenChange, onPick }: DemoAccountsDialogProps) { + const { t } = useTranslation("auth"); const tenants = DEMO_ACCOUNT_GROUPS; const [activeIdx, setActiveIdx] = useState(0); @@ -48,9 +50,9 @@ export function DemoAccountsDialog({ open, onOpenChange, onPick }: DemoAccountsD return ( - Demo accounts + {t("demo.dialogTitle")} - Pick a demo tenant and account to sign in with. + {t("demo.dialogDescription")} {/* Atmospheric gradients */} @@ -71,14 +73,14 @@ export function DemoAccountsDialog({ open, onOpenChange, onPick }: DemoAccountsD - Live demo + {t("demo.liveDemo")}

- Step into any role. + {t("demo.title")}

- Explore fullstackhero as any user across the demo tenants — we'll sign you in instantly. + {t("demo.subtitle")}

@@ -96,9 +98,9 @@ export function DemoAccountsDialog({ open, onOpenChange, onPick }: DemoAccountsD {/* Footer */}

- demo only + {t("demo.demoOnly")} · - Resets with every reseed. + {t("demo.resets")}

esc @@ -122,10 +124,11 @@ function TenantRail({ activeIdx: number; onSelect: (idx: number) => void; }) { + const { t } = useTranslation("auth"); return (
)} @@ -159,7 +162,7 @@ export function FileDropzone({ options, onUploaded, disabled, accept, className reset(); }} > - Cancel + {t("dropzone.cancel")} )} @@ -196,20 +199,20 @@ function DropzoneIcon({ status }: { status: string | undefined }) { ); } -function captionFor(status: string | undefined, fileName?: string): string { +function captionFor(status: string | undefined, t: TFunction, fileName?: string): string { switch (status) { case "preparing": - return "Preparing upload…"; + return t("dropzone.caption.preparing"); case "uploading": - return `Uploading ${fileName ?? "…"}`; + return fileName ? t("dropzone.caption.uploading", { name: fileName }) : t("dropzone.caption.uploadingFallback"); case "finalizing": - return "Finalizing…"; + return t("dropzone.caption.finalizing"); case "done": - return `Uploaded ${fileName ?? "file"}`; + return fileName ? t("dropzone.caption.done", { name: fileName }) : t("dropzone.caption.doneFallback"); case "error": - return "Upload failed"; + return t("dropzone.caption.error"); default: - return "Drop a file or click to browse"; + return t("dropzone.caption.idle"); } } @@ -217,28 +220,33 @@ function detailFor( status: string | undefined, progress: ReturnType["progress"], options: UploadOptions, + t: TFunction, ): string { if (status === "error" && progress?.error) return progress.error; if (status === "done" && progress?.fileAsset) { return `${progress.fileAsset.contentType} · ${formatBytes(progress.fileAsset.sizeBytes)}`; } if (status === "uploading" && progress) { - return `${formatBytes(progress.loaded)} of ${formatBytes(progress.totalBytes)}`; + return t("dropzone.detail.uploading", { + loaded: formatBytes(progress.loaded), + total: formatBytes(progress.totalBytes), + }); } if (options.allowedExtensions && options.allowedExtensions.length > 0) { const ext = options.allowedExtensions.join(", "); - const cap = options.maxBytes ? ` · up to ${formatBytes(options.maxBytes)}` : ""; - return `Allowed: ${ext}${cap}`; + const cap = options.maxBytes ? t("dropzone.detail.upTo", { size: formatBytes(options.maxBytes) }) : ""; + return t("dropzone.detail.allowed", { ext }) + cap; } - return typeof options.category === "string" ? options.category : "Drop a file"; + return typeof options.category === "string" ? options.category : t("dropzone.detail.dropAFile"); } function ProgressBar({ percent, loaded, total }: { percent: number; loaded: number; total: number }) { + const { t } = useTranslation("files"); return (
deleteFile(fileAssetId!), onSuccess: () => { - toast.success("File deleted"); + toast.success(t("preview.toastDeleted")); onDeleted?.(fileAssetId!); }, onError: (err) => { @@ -86,7 +89,7 @@ export function FilePreviewDialog({ fileAssetId, initial, onClose, onDeleted }: err instanceof ApiRequestError ? (err.problem?.detail ?? err.problem?.title ?? err.message) : (err as Error).message; - toast.error("Delete failed", { description: detail }); + toast.error(t("preview.toastDeleteFailed"), { description: detail }); setConfirmingDelete(false); }, }); @@ -107,8 +110,8 @@ export function FilePreviewDialog({ fileAssetId, initial, onClose, onDeleted }: metaQuery.refetch(); toast.success( dto.visibility === Visibility.Public - ? "File is now public to your tenant" - : "File is now private", + ? t("preview.toastNowPublic") + : t("preview.toastNowPrivate"), ); }, onError: (err) => { @@ -116,7 +119,7 @@ export function FilePreviewDialog({ fileAssetId, initial, onClose, onDeleted }: err instanceof ApiRequestError ? (err.problem?.detail ?? err.problem?.title ?? err.message) : (err as Error).message; - toast.error("Visibility change failed", { description: detail }); + toast.error(t("preview.toastVisibilityFailed"), { description: detail }); }, }); @@ -149,7 +152,7 @@ export function FilePreviewDialog({ fileAssetId, initial, onClose, onDeleted }: - {metaQuery.data?.originalFileName ?? "File"} + {metaQuery.data?.originalFileName ?? t("preview.fallbackName")} {metaQuery.data && ( @@ -164,7 +167,7 @@ export function FilePreviewDialog({ fileAssetId, initial, onClose, onDeleted }: message={ metaQuery.error instanceof ApiRequestError ? (metaQuery.error.problem?.detail ?? metaQuery.error.message) - : "Couldn't load file metadata." + : t("preview.errorMeta") } /> ) : !metaQuery.data ? ( @@ -196,7 +199,7 @@ export function FilePreviewDialog({ fileAssetId, initial, onClose, onDeleted }: confirmingDelete ? (
- Delete this file? + {t("preview.confirmDelete")}
) : ( @@ -228,7 +231,7 @@ export function FilePreviewDialog({ fileAssetId, initial, onClose, onDeleted }: className="text-[var(--color-destructive)] hover:bg-[oklch(from_var(--color-destructive)_l_c_h_/_0.08)] hover:text-[var(--color-destructive)]" > - Delete + {t("preview.delete")} ) ) : ( @@ -241,7 +244,7 @@ export function FilePreviewDialog({ fileAssetId, initial, onClose, onDeleted }: )} - +
@@ -259,6 +262,7 @@ function Preview({ downloadUrl?: string; onUrlError: () => void; }) { + const { t } = useTranslation("files"); // For private files we need the presigned URL; for public we use the durable URL. const url = file.publicUrl ?? downloadUrl ?? null; const isAvailable = file.status === FileAssetStatus.Available; @@ -269,8 +273,8 @@ function Preview({

{file.status === FileAssetStatus.PendingUpload - ? "Upload not yet finalized." - : "This file is quarantined and cannot be previewed."} + ? t("preview.notFinalized") + : t("preview.quarantined")}

); @@ -284,7 +288,7 @@ function Preview({ return (

- Preview link expired or unreachable. + {t("preview.expired")}

); @@ -331,12 +335,12 @@ function Preview({

- Preview not available for this file type. + {t("preview.notAvailable")}

@@ -344,6 +348,7 @@ function Preview({ } function TextPreview({ url }: { url: string }) { + const { t } = useTranslation("files"); const [text, setText] = useState(null); const [err, setErr] = useState(null); @@ -356,7 +361,7 @@ function TextPreview({ url }: { url: string }) { const t = await res.text(); if (!cancelled) setText(t.slice(0, 64 * 1024)); // cap at 64 KiB to keep the modal snappy } catch (e) { - if (!cancelled) setErr(e instanceof Error ? e.message : "Failed to fetch"); + if (!cancelled) setErr(e instanceof Error ? e.message : t("preview.fetchFailed")); } })(); return () => { @@ -365,7 +370,7 @@ function TextPreview({ url }: { url: string }) { }, [url]); if (err) { - return ; + return ; } if (text === null) { return ; @@ -391,21 +396,22 @@ function MetadataPanel({ // Uploader name resolved via the existing identity cache. For files older than // the createdByUserId rollout this comes back empty — fall back to a hyphen so // the row reads cleanly rather than as a broken loader. + const { t } = useTranslation("files"); const uploader = useUserDisplay(file.createdByUserId || null); const uploaderLabel = file.createdByUserId ? uploader.loading - ? "Loading…" + ? t("preview.meta.uploaderLoading") : uploader.name : "—"; const rows: Array<[string, React.ReactNode, string?]> = [ - ["File ID", {file.id}, file.id], - ["Owner type", file.ownerType, file.ownerType], - ["Uploaded by", uploaderLabel, uploaderLabel], - ["Content type", file.contentType, file.contentType], - ["Size", formatBytes(file.sizeBytes), undefined], - ["Status", statusLabel(file.status), undefined], - ["Created", new Date(file.createdAtUtc).toLocaleString(), undefined], + [t("preview.meta.fileId"), {file.id}, file.id], + [t("preview.meta.ownerType"), file.ownerType, file.ownerType], + [t("preview.meta.uploadedBy"), uploaderLabel, uploaderLabel], + [t("preview.meta.contentType"), file.contentType, file.contentType], + [t("preview.meta.size"), formatBytes(file.sizeBytes), undefined], + [t("preview.meta.status"), statusLabel(file.status, t), undefined], + [t("preview.meta.created"), new Date(file.createdAtUtc).toLocaleString(), undefined], ]; const isPublic = file.visibility === Visibility.Public; @@ -418,7 +424,7 @@ function MetadataPanel({
- Visibility + {t("preview.meta.visibility")}
- {isPublic ? "Public" : "Private"} + {isPublic ? t("visibility.public") : t("visibility.private")}

{isPublic - ? "Everyone in your tenant can find this file under Shared." - : "Only you can preview or download this file."} + ? t("preview.meta.publicHint") + : t("preview.meta.privateHint")}

{isUploader ? ( @@ -441,11 +447,11 @@ function MetadataPanel({ onChangeVisibility(checked ? Visibility.Public : Visibility.Private) } disabled={visibilityPending} - aria-label="Toggle public visibility" + aria-label={t("preview.meta.toggleAria")} /> ) : ( - Read-only + {t("preview.meta.readOnly")} )}
@@ -470,6 +476,7 @@ function MetadataPanel({ // private files so we don't reuse the inline URL the iframe is consuming. For public // files there's no inline/attachment distinction — both buttons use the same publicUrl. function DownloadButton({ file }: { file: FileAssetDto }) { + const { t } = useTranslation("files"); const [busy, setBusy] = useState(false); const handle = async () => { if (busy) return; @@ -489,21 +496,21 @@ function DownloadButton({ file }: { file: FileAssetDto }) { return ( ); } -function statusLabel(status: FileAssetStatusValue): string { +function statusLabel(status: FileAssetStatusValue, t: TFunction): string { switch (status) { case FileAssetStatus.PendingUpload: - return "Pending upload"; + return t("preview.status.pending"); case FileAssetStatus.Available: - return "Available"; + return t("preview.status.available"); case FileAssetStatus.Quarantined: - return "Quarantined"; + return t("preview.status.quarantined"); default: - return `Unknown (${status})`; + return t("preview.status.unknown", { status }); } } diff --git a/clients/dashboard/src/components/file/image-input.tsx b/clients/dashboard/src/components/file/image-input.tsx index 204d3c6a1d..a525b2a28d 100644 --- a/clients/dashboard/src/components/file/image-input.tsx +++ b/clients/dashboard/src/components/file/image-input.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; import { useMutation } from "@tanstack/react-query"; import { Image as ImageIcon, Loader2, Upload, X, Link as LinkIcon } from "lucide-react"; import { toast } from "sonner"; @@ -45,6 +46,7 @@ export function ImageInput({ shape = "square", className, }: Props) { + const { t } = useTranslation("files"); const [mode, setMode] = useState<"upload" | "url">("upload"); const { upload, progress, isUploading, reset } = useFileUpload({ ownerType, @@ -60,7 +62,7 @@ export function ImageInput({ mutationFn: async (fileAssetId: string) => { const dto = await getFileMetadata(fileAssetId); if (!dto.publicUrl) { - throw new Error("Server returned no publicUrl for this file."); + throw new Error(t("imageInput.noPublicUrl")); } return dto.publicUrl; }, @@ -77,7 +79,7 @@ export function ImageInput({ const asset = await upload(file); const url = await resolveUrl.mutateAsync(asset.id); onChange(url); - toast.success("Image uploaded"); + toast.success(t("imageInput.toastUploaded")); // Clear progress so the dropzone re-arms for another upload. setTimeout(reset, 1500); } catch (e) { @@ -86,7 +88,7 @@ export function ImageInput({ ? (e.problem?.detail ?? e.problem?.title ?? e.message) : e instanceof Error ? e.message - : "Upload failed"; + : t("imageInput.uploadFailed"); toast.error(message); } }; @@ -108,10 +110,10 @@ export function ImageInput({ {/* Mode toggle */}
setMode("upload")} icon={}> - Upload + {t("imageInput.upload")} setMode("url")} icon={}> - Paste URL + {t("imageInput.pasteUrl")}
@@ -144,12 +146,12 @@ export function ImageInput({ {isWorking ? : } - {showImage ? "Replace image" : "Choose image"} + {showImage ? t("imageInput.replace") : t("imageInput.choose")} {showImage && !isWorking && ( )} {isUploading && progress && ( @@ -163,15 +165,15 @@ export function ImageInput({ type="url" value={value} onChange={(e) => onChange(e.target.value)} - placeholder="https://…" + placeholder={t("imageInput.urlPlaceholder")} maxLength={512} /> )}

{mode === "upload" - ? `JPG/PNG/WebP/GIF · up to ${formatBytes(maxBytes)}` - : "Direct link to an image you host elsewhere."} + ? t("imageInput.hintUpload", { size: formatBytes(maxBytes) }) + : t("imageInput.hintUrl")}

diff --git a/clients/dashboard/src/components/file/product-image-manager.tsx b/clients/dashboard/src/components/file/product-image-manager.tsx index f8091f84c9..eb8eae5b3e 100644 --- a/clients/dashboard/src/components/file/product-image-manager.tsx +++ b/clients/dashboard/src/components/file/product-image-manager.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Loader2, Star, StarOff, Trash2, Upload } from "lucide-react"; import { toast } from "sonner"; @@ -43,6 +44,7 @@ type Props = { * - Clicking an image opens a fullscreen preview modal. */ export function ProductImageManager({ productId, images, invalidateKey, className }: Props) { + const { t } = useTranslation("files"); const queryClient = useQueryClient(); const [previewId, setPreviewId] = useState(null); const [pendingRemove, setPendingRemove] = useState(null); @@ -63,27 +65,27 @@ export function ProductImageManager({ productId, images, invalidateKey, classNam void queryClient.invalidateQueries({ queryKey: invalidateKey }); }, onError: (e: unknown) => { - toast.error(extract(e, "Failed to attach image")); + toast.error(extract(e, t("pim.errAttach"))); }, }); const thumbnailMutation = useMutation({ mutationFn: (imageId: string) => setProductThumbnail(productId, imageId), onSuccess: () => { - toast.success("Cover image updated"); + toast.success(t("pim.toastCoverUpdated")); void queryClient.invalidateQueries({ queryKey: invalidateKey }); }, - onError: (e: unknown) => toast.error(extract(e, "Failed to set cover")), + onError: (e: unknown) => toast.error(extract(e, t("pim.errSetCover"))), }); const removeMutation = useMutation({ mutationFn: (imageId: string) => removeProductImage(productId, imageId), onSuccess: () => { - toast.success("Image removed"); + toast.success(t("pim.toastImageRemoved")); void queryClient.invalidateQueries({ queryKey: invalidateKey }); setPendingRemove(null); }, - onError: (e: unknown) => toast.error(extract(e, "Failed to remove image")), + onError: (e: unknown) => toast.error(extract(e, t("pim.errRemove"))), }); const handlePick = () => { @@ -99,11 +101,11 @@ export function ProductImageManager({ productId, images, invalidateKey, classNam const asset = await upload(file); const meta = await getFileMetadata(asset.id); if (!meta.publicUrl) { - throw new Error("Server returned no publicUrl for the uploaded image."); + throw new Error(t("pim.noPublicUrl")); } await attachMutation.mutateAsync({ fileAssetId: asset.id, url: meta.publicUrl }); } catch (e) { - toast.error(extract(e, `Upload failed: ${file.name}`)); + toast.error(extract(e, t("pim.errUploadNamed", { name: file.name }))); } } reset(); @@ -122,7 +124,7 @@ export function ProductImageManager({ productId, images, invalidateKey, classNam {isUploading || attachMutation.isPending ? : } - Upload images + {t("pim.upload")} {progress && progress.status !== "done" && ( @@ -130,14 +132,14 @@ export function ProductImageManager({ productId, images, invalidateKey, classNam )} - {sorted.length} image{sorted.length === 1 ? "" : "s"} · JPG / PNG / WebP / GIF · up to 10 MB + {t("pim.count", { count: sorted.length })} {/* Gallery grid */} {sorted.length === 0 ? (
- No images yet. Upload one to set the product's cover. + {t("pim.empty")}
) : (
@@ -178,6 +180,7 @@ function ImageTile({ onRemove: () => void; busy: boolean; }) { + const { t } = useTranslation("files"); return (
- Cover + {t("pim.cover")} )} @@ -219,8 +222,8 @@ function ImageTile({ onSetThumbnail(); }} disabled={busy} - title="Set as cover" - aria-label="Set as cover" + title={t("pim.setCover")} + aria-label={t("pim.setCover")} className="bg-[var(--color-overlay)] text-[var(--color-overlay-foreground)] hover:bg-[oklch(0_0_0/0.65)]" > @@ -235,8 +238,8 @@ function ImageTile({ onRemove(); }} disabled={busy} - title="Remove image" - aria-label="Remove image" + title={t("pim.removeImage")} + aria-label={t("pim.removeImage")} className="bg-[var(--color-overlay)] text-[var(--color-overlay-foreground)] hover:bg-[var(--color-destructive)]" > @@ -247,13 +250,14 @@ function ImageTile({ } function PreviewDialog({ image, onClose }: { image: ProductImageDto | null; onClose: () => void }) { + const { t } = useTranslation("files"); return ( (o ? undefined : onClose())}> - Image preview + {t("pim.previewTitle")} - {image?.isThumbnail ? "Current cover image." : "Click outside to close."} + {image?.isThumbnail ? t("pim.currentCover") : t("pim.clickOutside")} {image && ( @@ -267,7 +271,7 @@ function PreviewDialog({ image, onClose }: { image: ProductImageDto | null; onCl )} - + @@ -286,28 +290,29 @@ function RemoveDialog({ onConfirm: () => void; busy: boolean; }) { + const { t } = useTranslation("files"); return ( (o ? undefined : onCancel())}> - Remove image + {t("pim.removeImage")} - Detach this image? + {t("pim.detachTitle")} - The image is removed from this product. {image?.isThumbnail - ? "It's currently the cover — another image will be promoted automatically." + {t("pim.detachBody")}{image?.isThumbnail + ? t("pim.detachCoverNote") : ""} diff --git a/clients/dashboard/src/components/identity/user-picker.tsx b/clients/dashboard/src/components/identity/user-picker.tsx index 1b83b98f28..88b5fd5751 100644 --- a/clients/dashboard/src/components/identity/user-picker.tsx +++ b/clients/dashboard/src/components/identity/user-picker.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; import { Loader2, Search, UserCheck, X } from "lucide-react"; import { Input } from "@/components/ui/input"; @@ -26,7 +27,7 @@ export function UserPicker({ value, onChange, initialSelected, - placeholder = "Search by name or email…", + placeholder, disabled, }: { value: string | null; @@ -35,6 +36,7 @@ export function UserPicker({ placeholder?: string; disabled?: boolean; }) { + const { t } = useTranslation("tickets"); const [selected, setSelected] = useState(initialSelected ?? null); const [query, setQuery] = useState(""); const [debounced, setDebounced] = useState(""); @@ -128,11 +130,11 @@ export function UserPicker({ variant="ghost" size="sm" onClick={clear} - aria-label="Clear selection" + aria-label={t("userPicker.clearSelection")} className="shrink-0" > - Clear + {t("userPicker.clear")} )}
@@ -145,7 +147,7 @@ export function UserPicker({ /> { setQuery(e.target.value); @@ -169,14 +171,14 @@ export function UserPicker({ {resultsQuery.isFetching && results.length === 0 ? (
- Searching for "{debounced}"… + {t("userPicker.searching", { term: debounced })}
) : results.length === 0 ? (
- No users match "{debounced}". + {t("userPicker.noMatch", { term: debounced })}
) : ( -
+
{results.map((u) => (
); diff --git a/clients/dashboard/src/components/layout/mobile-nav.tsx b/clients/dashboard/src/components/layout/mobile-nav.tsx index 5ac6f81630..9d3ee0781f 100644 --- a/clients/dashboard/src/components/layout/mobile-nav.tsx +++ b/clients/dashboard/src/components/layout/mobile-nav.tsx @@ -8,6 +8,7 @@ import { type ReactNode, } from "react"; import { useLocation } from "react-router-dom"; +import { useTranslation } from "react-i18next"; import { Menu } from "lucide-react"; import { Sheet, @@ -61,6 +62,7 @@ export function MobileNavProvider({ children }: { children: ReactNode }) { * or any non-NavLink navigation while the drawer is open). */ export function MobileNavRoot() { + const { t } = useTranslation("common"); const { open, setOpen } = useMobileNav(); const location = useLocation(); @@ -87,9 +89,9 @@ export function MobileNavRoot() { {/* Radix Dialog requires a Title for the accessible name; keep it visually hidden so the drawer chrome is unchanged. */} - Primary navigation + {t("nav.primary")} - Site sections and account links. + {t("nav.primaryDescription")} {/* Brand row — matches Topbar height so the drawer top aligns with the rest of the chrome. */} @@ -129,12 +131,13 @@ export function MobileNavRoot() { * the first child so it sits at the leading edge on small screens). */ export function MobileNavTrigger({ className }: { className?: string }) { + const { t } = useTranslation("common"); const { setOpen } = useMobileNav(); const onClick = useCallback(() => setOpen(true), [setOpen]); return (
)} @@ -114,9 +116,9 @@ export function Sidebar() { diff --git a/clients/dashboard/src/components/list/combobox.tsx b/clients/dashboard/src/components/list/combobox.tsx index 2a1f3726e9..71f5d4f9b9 100644 --- a/clients/dashboard/src/components/list/combobox.tsx +++ b/clients/dashboard/src/components/list/combobox.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; import { Check, ChevronDown, Search, X } from "lucide-react"; import { DropdownMenu, @@ -63,6 +64,7 @@ export function Combobox({ id?: string; className?: string; }) { + const { t } = useTranslation(); const [open, setOpen] = useState(false); const [filter, setFilter] = useState(""); const inputRef = useRef(null); @@ -169,7 +171,7 @@ export function Combobox({ inputRef.current?.focus(); }} className="grid h-5 w-5 cursor-pointer place-items-center rounded text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)] hover:text-[var(--color-foreground)]" - aria-label="Clear filter" + aria-label={t("list.clearFilter")} > @@ -194,7 +196,7 @@ export function Combobox({ {filtered.length === 0 ? (
  • - No matches. + {t("list.noMatches")}
  • ) : ( filtered.map((opt) => ( diff --git a/clients/dashboard/src/components/list/entity-shell.tsx b/clients/dashboard/src/components/list/entity-shell.tsx index 5b373ef198..d05772c418 100644 --- a/clients/dashboard/src/components/list/entity-shell.tsx +++ b/clients/dashboard/src/components/list/entity-shell.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import { useTranslation } from "react-i18next"; import { ChevronLeft, ChevronRight, Search } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import { cn } from "@/lib/cn"; @@ -30,6 +31,7 @@ export function EntityPageHeader({ /** Action buttons rendered on the right (stack full-width on mobile). */ children?: React.ReactNode; }) { + const { t } = useTranslation(); return (
    @@ -41,7 +43,7 @@ export function EntityPageHeader({ {total !== undefined && total !== null && ( - {total} {total === 1 ? unit : `${unit}s`} + {t(`unit.${unit}`, { count: total })} )}
    @@ -75,13 +77,14 @@ export function EntitySearch({ placeholder?: string; autoFocus?: boolean; }) { + const { t } = useTranslation(); return (
    onChange(e.target.value)} autoFocus={autoFocus} @@ -97,11 +100,11 @@ export function EntitySearch({ {value && ( )}
    @@ -172,18 +175,19 @@ export function EntityPager({ onPrev: () => void; onNext: () => void; }) { + const { t } = useTranslation(); if (totalPages <= 1) return null; return (

    - Page {page} of {totalPages} + {t("list.pageOf", { page, total: totalPages })}

    @@ -193,7 +195,7 @@ function ChatToast({

    ) : (

    - (attachment or empty body) + {t("toast.emptyBody")}

    )}
    @@ -207,8 +209,8 @@ function ChatToast({ e.stopPropagation(); onDismiss(); }} - aria-label="Dismiss notification" - title="Dismiss" + aria-label={t("toast.dismissAria")} + title={t("toast.dismiss")} className={cn( "absolute right-2 top-2 grid h-6 w-6 cursor-pointer place-items-center rounded-md", "text-[var(--color-muted-foreground)] hover:bg-[var(--color-accent)] hover:text-[var(--color-foreground)]", diff --git a/clients/dashboard/src/components/notifications/chat-unread-badge.tsx b/clients/dashboard/src/components/notifications/chat-unread-badge.tsx index 2094f449a5..3bb38f5b28 100644 --- a/clients/dashboard/src/components/notifications/chat-unread-badge.tsx +++ b/clients/dashboard/src/components/notifications/chat-unread-badge.tsx @@ -1,4 +1,5 @@ import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { MessageCircle } from "lucide-react"; @@ -18,6 +19,7 @@ import { cn } from "@/lib/cn"; * - the composer invalidates it when the user sends */ export function ChatUnreadBadge() { + const { t } = useTranslation("notifications"); const navigate = useNavigate(); const { data: channels } = useQuery({ queryKey: ["chat", "my-channels"], @@ -33,8 +35,8 @@ export function ChatUnreadBadge() { return ( )}
    - Recent + {t("recent")}
    {inboxQuery.isLoading ? (

    - Loading… + {t("loading")}

    ) : inbox.length === 0 ? (

    - Nothing yet. Mentions and channel updates will appear here. + {t("empty")}

    ) : (
      @@ -181,7 +184,7 @@ export function NotificationBell() { }} className="text-[11px] text-[var(--color-muted-foreground)] transition-colors hover:text-[var(--color-foreground)]" > - Settings ↗ + {t("settings")}
    @@ -258,14 +261,14 @@ function NotificationRow({ function relativeTime(iso: string): string { const diffMs = Date.now() - new Date(iso).getTime(); const seconds = Math.max(0, Math.round(diffMs / 1000)); - if (seconds < 60) return "just now"; + if (seconds < 60) return i18n.t("notifications:rel.justNow"); const minutes = Math.round(seconds / 60); - if (minutes < 60) return `${minutes}m`; + if (minutes < 60) return i18n.t("notifications:rel.minutes", { n: minutes }); const hours = Math.round(minutes / 60); - if (hours < 24) return `${hours}h`; + if (hours < 24) return i18n.t("notifications:rel.hours", { n: hours }); const days = Math.round(hours / 24); - if (days < 7) return `${days}d`; + if (days < 7) return i18n.t("notifications:rel.days", { n: days }); const weeks = Math.round(days / 7); - if (weeks < 5) return `${weeks}w`; - return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric" }); + if (weeks < 5) return i18n.t("notifications:rel.weeks", { n: weeks }); + return new Date(iso).toLocaleDateString(i18n.language, { month: "short", day: "numeric" }); } diff --git a/clients/dashboard/src/components/realtime/realtime-status-pill.tsx b/clients/dashboard/src/components/realtime/realtime-status-pill.tsx index 64203c5fe8..bbe2f88a25 100644 --- a/clients/dashboard/src/components/realtime/realtime-status-pill.tsx +++ b/clients/dashboard/src/components/realtime/realtime-status-pill.tsx @@ -1,14 +1,7 @@ +import { useTranslation } from "react-i18next"; import { useRealtime } from "@/realtime/realtime-context"; import { cn } from "@/lib/cn"; -const LABEL: Record = { - idle: "Offline", - connecting: "Connecting", - connected: "Live", - reconnecting: "Reconnecting", - error: "Offline", -}; - /** * Compact connection-state indicator backed by the shared SignalR hub. Mono * caption + colored dot — green when live, amber pulsing while reconnecting, @@ -26,14 +19,22 @@ export function RealtimeStatusPill({ className?: string; announce?: boolean; }) { + const { t } = useTranslation("common"); const { status } = useRealtime(); - const label = LABEL[status] ?? "Offline"; + const labels: Record = { + idle: t("realtimeStatus.offline"), + connecting: t("realtimeStatus.connecting"), + connected: t("realtimeStatus.live"), + reconnecting: t("realtimeStatus.reconnecting"), + error: t("realtimeStatus.offline"), + }; + const label = labels[status] ?? t("realtimeStatus.offline"); return ( {label} diff --git a/clients/dashboard/src/components/route-error.tsx b/clients/dashboard/src/components/route-error.tsx index 505c847928..c928418809 100644 --- a/clients/dashboard/src/components/route-error.tsx +++ b/clients/dashboard/src/components/route-error.tsx @@ -1,3 +1,5 @@ +import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; import { isRouteErrorResponse, useNavigate, useRouteError } from "react-router-dom"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; @@ -12,17 +14,18 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } * exposing the call stack to end users gives nothing actionable and leaks internals. */ export function RouteError() { + const { t } = useTranslation(); const error = useRouteError(); const navigate = useNavigate(); - const { title, detail } = describe(error); + const { title, detail } = describe(error, t); const showDetail = import.meta.env.DEV && detail; return (
    - Something went wrong + {t("routeError.title")} {title} {showDetail && ( @@ -33,15 +36,15 @@ export function RouteError() { )} - - + +
    ); } -function describe(error: unknown): { title: string; detail?: string } { +function describe(error: unknown, t: TFunction): { title: string; detail?: string } { if (isRouteErrorResponse(error)) { return { title: `${error.status} ${error.statusText}`, @@ -51,5 +54,5 @@ function describe(error: unknown): { title: string; detail?: string } { if (error instanceof Error) { return { title: error.message, detail: error.stack }; } - return { title: "Unexpected error", detail: String(error) }; + return { title: t("routeError.unexpected"), detail: String(error) }; } diff --git a/clients/dashboard/src/components/ui/dialog.tsx b/clients/dashboard/src/components/ui/dialog.tsx index c97bd31bb5..a8831116b9 100644 --- a/clients/dashboard/src/components/ui/dialog.tsx +++ b/clients/dashboard/src/components/ui/dialog.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import { useTranslation } from "react-i18next"; import * as DialogPrimitive from "@radix-ui/react-dialog"; import { X } from "lucide-react"; import { cn } from "@/lib/cn"; @@ -45,7 +46,9 @@ DialogOverlay.displayName = "DialogOverlay"; export const DialogContent = React.forwardRef< React.ComponentRef, React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( +>(({ className, children, ...props }, ref) => { + const { t } = useTranslation(); + return ( -)); + ); +}); DialogContent.displayName = "DialogContent"; export function DialogHeader({ className, ...props }: React.HTMLAttributes) { @@ -153,7 +157,9 @@ export const SheetContent = React.forwardRef< /** Render the built-in close button. Defaults to true. */ showClose?: boolean; } ->(({ className, children, side = "right", showClose = true, ...props }, ref) => ( +>(({ className, children, side = "right", showClose = true, ...props }, ref) => { + const { t } = useTranslation(); + return ( -)); + ); +}); SheetContent.displayName = "SheetContent"; // Aliases — when authors prefer reading // diff --git a/clients/dashboard/src/env.ts b/clients/dashboard/src/env.ts index e56dd35480..e021c159db 100644 --- a/clients/dashboard/src/env.ts +++ b/clients/dashboard/src/env.ts @@ -15,6 +15,8 @@ type RuntimeConfig = { inactivityIdleMs: number; /** Warning-countdown length (ms) before auto sign-out. */ inactivityWarningMs: number; + /** Per-deployment default UI language (BCP 47); the i18n fallback when nothing is persisted/detected. */ + defaultLanguage: string; }; // Dashboard defaults: 20 minutes idle, then a 60-second warning. @@ -41,6 +43,7 @@ export async function loadRuntimeConfig(): Promise { demoMode: cfg.demoMode ?? false, inactivityIdleMs: positiveOr(cfg.inactivityIdleMs, DEFAULT_INACTIVITY_IDLE_MS), inactivityWarningMs: positiveOr(cfg.inactivityWarningMs, DEFAULT_INACTIVITY_WARNING_MS), + defaultLanguage: cfg.defaultLanguage ?? "en-US", }; } @@ -59,4 +62,5 @@ export const env = { get demoMode(): boolean { return get().demoMode; }, get inactivityIdleMs(): number { return get().inactivityIdleMs; }, get inactivityWarningMs(): number { return get().inactivityWarningMs; }, + get defaultLanguage(): string { return get().defaultLanguage; }, }; diff --git a/clients/dashboard/src/i18n.ts b/clients/dashboard/src/i18n.ts new file mode 100644 index 0000000000..863ff9bab7 --- /dev/null +++ b/clients/dashboard/src/i18n.ts @@ -0,0 +1,117 @@ +import i18n from "i18next"; +import LanguageDetector from "i18next-browser-languagedetector"; +import { initReactI18next } from "react-i18next"; +import enCommon from "@/locales/en-US/common.json"; +import ptCommon from "@/locales/pt-BR/common.json"; +import enAuth from "@/locales/en-US/auth.json"; +import ptAuth from "@/locales/pt-BR/auth.json"; +import enSettings from "@/locales/en-US/settings.json"; +import ptSettings from "@/locales/pt-BR/settings.json"; +import enIdentity from "@/locales/en-US/identity.json"; +import ptIdentity from "@/locales/pt-BR/identity.json"; +import enOverview from "@/locales/en-US/overview.json"; +import ptOverview from "@/locales/pt-BR/overview.json"; +import enSubscription from "@/locales/en-US/subscription.json"; +import ptSubscription from "@/locales/pt-BR/subscription.json"; +import enActivity from "@/locales/en-US/activity.json"; +import ptActivity from "@/locales/pt-BR/activity.json"; +import enCatalog from "@/locales/en-US/catalog.json"; +import ptCatalog from "@/locales/pt-BR/catalog.json"; +import enTickets from "@/locales/en-US/tickets.json"; +import ptTickets from "@/locales/pt-BR/tickets.json"; +import enFiles from "@/locales/en-US/files.json"; +import ptFiles from "@/locales/pt-BR/files.json"; +import enAudits from "@/locales/en-US/audits.json"; +import ptAudits from "@/locales/pt-BR/audits.json"; +import enCommandPalette from "@/locales/en-US/commandPalette.json"; +import ptCommandPalette from "@/locales/pt-BR/commandPalette.json"; +import enNotifications from "@/locales/en-US/notifications.json"; +import ptNotifications from "@/locales/pt-BR/notifications.json"; +import enSystem from "@/locales/en-US/system.json"; +import ptSystem from "@/locales/pt-BR/system.json"; +import enChat from "@/locales/en-US/chat.json"; +import ptChat from "@/locales/pt-BR/chat.json"; +import enHealth from "@/locales/en-US/health.json"; +import ptHealth from "@/locales/pt-BR/health.json"; + +// Canonical tags: specific (what the switcher offers, User.Locale persists, the claim carries). +export const SUPPORTED = ["en-US", "pt-BR"] as const; + +type Catalog = Record; + +// Translation catalogs keyed by namespace, then by locale. To add a namespace +// in a later wave: import its two JSON files and add one row here — `resources`, +// the namespace list, and parity tests all derive from this single map, so no +// other wiring changes. +const catalogs: Record> = { + common: { "en-US": enCommon, "pt-BR": ptCommon }, + auth: { "en-US": enAuth, "pt-BR": ptAuth }, + settings: { "en-US": enSettings, "pt-BR": ptSettings }, + identity: { "en-US": enIdentity, "pt-BR": ptIdentity }, + overview: { "en-US": enOverview, "pt-BR": ptOverview }, + subscription: { "en-US": enSubscription, "pt-BR": ptSubscription }, + activity: { "en-US": enActivity, "pt-BR": ptActivity }, + catalog: { "en-US": enCatalog, "pt-BR": ptCatalog }, + tickets: { "en-US": enTickets, "pt-BR": ptTickets }, + files: { "en-US": enFiles, "pt-BR": ptFiles }, + audits: { "en-US": enAudits, "pt-BR": ptAudits }, + commandPalette: { "en-US": enCommandPalette, "pt-BR": ptCommandPalette }, + notifications: { "en-US": enNotifications, "pt-BR": ptNotifications }, + system: { "en-US": enSystem, "pt-BR": ptSystem }, + chat: { "en-US": enChat, "pt-BR": ptChat }, + health: { "en-US": enHealth, "pt-BR": ptHealth }, +}; + +export const NAMESPACES = Object.keys(catalogs); + +// Pivot the namespace-first map into i18next's locale-first `resources` shape: +// { "en-US": { common: {…} }, "pt-BR": { common: {…} } }. +const resources = Object.fromEntries( + SUPPORTED.map((lng) => [ + lng, + Object.fromEntries(Object.entries(catalogs).map(([ns, byLng]) => [ns, byLng[lng]])), + ]), +); + +// i18next's nonExplicitSupportedLngs does NOT rewrite pt-PT->pt-BR. Normalize explicitly via +// convertDetectedLanguage: map any variant onto a canonical tag by its language part. +const CANON: Record = { pt: "pt-BR", en: "en-US" }; +const toCanonical = (lng: string) => + (SUPPORTED as readonly string[]).includes(lng) ? lng : (CANON[lng.split("-")[0]] ?? lng); + +// Keep the document's language attribute in step with the active locale. index.html ships a +// static lang="en"; without this, a Portuguese UI still declares itself English to screen +// readers, browser translation and hyphenation. Registered once, before init, so it also fires +// for the initial language. +if (typeof document !== "undefined") { + i18n.on("languageChanged", (lng) => { + document.documentElement.lang = lng; + }); +} + +// Called from main.tsx AFTER loadRuntimeConfig(), so fallbackLng reads the per-deployment +// default: the browser/persisted locale wins, the deployment default is only the fallback. +export function initI18n(deploymentDefault: string) { + return i18n + .use(LanguageDetector) + .use(initReactI18next) + .init({ + resources, + fallbackLng: (SUPPORTED as readonly string[]).includes(deploymentDefault) + ? deploymentDefault + : "en-US", + supportedLngs: [...SUPPORTED], + ns: NAMESPACES, + defaultNS: "common", + interpolation: { escapeValue: false }, + detection: { + // NO cookie — localStorage only (the library default; key i18nextLng). + order: ["querystring", "localStorage", "navigator"], + caches: ["localStorage"], + lookupQuerystring: "culture", + convertDetectedLanguage: toCanonical, // pt/pt-PT->pt-BR, en/en-GB->en-US + }, + }); +} + +export default i18n; diff --git a/clients/dashboard/src/lib/api-client.ts b/clients/dashboard/src/lib/api-client.ts index 417eab0ca8..b0a1902e7b 100644 --- a/clients/dashboard/src/lib/api-client.ts +++ b/clients/dashboard/src/lib/api-client.ts @@ -1,4 +1,5 @@ import { env } from "@/env"; +import i18n from "@/i18n"; import { tokenStore } from "@/auth/token-store"; import { decodeJwt } from "@/auth/jwt"; @@ -9,6 +10,10 @@ export type ApiError = { // FluentValidation errors arrive keyed by field (Record); CustomException // (e.g. Identity registration failures) sends a flat string[]. Handle both. errors?: Record | string[]; + // Stable, culture-independent error key (the server's MessageKey) — present + // whenever the thrown exception carried one. `detail` is localized prose, so + // branch on this, never on the text. + code?: string; // Dev-only extension surfaced on 401 by ConfigureJwtBearerOptions. reason?: string; // Allow any other ProblemDetails extensions through. @@ -26,19 +31,22 @@ export class ApiRequestError extends Error { } } +/** The deactivated-tenant guard's error key (MultitenancyModule). */ +const TENANT_DEACTIVATED_CODE = "Multitenancy.TenantDeactivated"; + /** - * True when an error is the API's "tenant has been deactivated" 403. The - * deactivated-tenant guard (MultitenancyModule) rejects *every* request once a - * tenant is switched off, so this can surface from any query/mutation while a - * user is mid-session. There is no machine-readable code on the ProblemDetails, - * so we match the guard's detail text. A global query/mutation error hook uses - * this to route the user to the dedicated `/tenant-deactivated` page rather than - * leaving the dead 403 banner stuck under a half-loaded surface. + * True when an error is the API's deactivated-tenant 403. The guard + * (MultitenancyModule) rejects *every* request once a tenant is switched off, so + * this can surface from any query/mutation while a user is mid-session. We match + * the ProblemDetails `code` — the server's MessageKey — because `detail` is + * localized under the request's Accept-Language and matching prose would only + * work for English readers. A global query/mutation error hook uses this to + * route the user to the dedicated `/tenant-deactivated` page rather than leaving + * the dead 403 banner stuck under a half-loaded surface. */ export function isTenantDeactivatedError(error: unknown): boolean { if (!(error instanceof ApiRequestError) || error.status !== 403) return false; - const detail = error.problem?.detail ?? error.message ?? ""; - return detail.toLowerCase().includes("tenant has been deactivated"); + return error.problem?.code === TENANT_DEACTIVATED_CODE; } /** @@ -186,6 +194,13 @@ export async function apiFetch( mergedHeaders.set("tenant", tenant); } + // Tell the backend which culture to localize responses in. The active UI + // locale drives it; the backend resolution chain still falls through to its + // own default for anything unsupported. + if (!mergedHeaders.has("Accept-Language")) { + mergedHeaders.set("Accept-Language", i18n.language || "en-US"); + } + const url = path.startsWith("http") ? path : `${env.apiBase}${path}`; const initialTimer = withTimeout({ ...rest, headers: mergedHeaders }, timeoutMs, signal); let response: Response; diff --git a/clients/dashboard/src/lib/list-helpers.ts b/clients/dashboard/src/lib/list-helpers.ts index 186e63b044..36a01528cb 100644 --- a/clients/dashboard/src/lib/list-helpers.ts +++ b/clients/dashboard/src/lib/list-helpers.ts @@ -1,32 +1,60 @@ +import i18n from "@/i18n"; import { ApiRequestError } from "@/lib/api-client"; -const dateLong = new Intl.DateTimeFormat("en-US", { - month: "short", - day: "2-digit", - year: "numeric", -}); +// Formatters read the active UI locale by default; callers may pin an explicit +// locale. Building an Intl formatter per row is wasteful in long ledgers and +// the locale only changes on a language switch, so cache one formatter per +// (locale) — the cache re-fills lazily under the new locale after a switch. +const activeLocale = (locale?: string) => locale ?? i18n.language ?? "en-US"; -export function formatDate(iso: string | null | undefined) { +const dateLongByLocale = new Map(); +function dateLongFor(locale: string) { + let fmt = dateLongByLocale.get(locale); + if (!fmt) { + fmt = new Intl.DateTimeFormat(locale, { + month: "short", + day: "2-digit", + year: "numeric", + }); + dateLongByLocale.set(locale, fmt); + } + return fmt; +} + +// "3:42 PM" — local wall-clock time. Intl renders in the browser's timezone. +const timeShortByLocale = new Map(); +function timeShortFor(locale: string) { + let fmt = timeShortByLocale.get(locale); + if (!fmt) { + fmt = new Intl.DateTimeFormat(locale, { + hour: "numeric", + minute: "2-digit", + }); + timeShortByLocale.set(locale, fmt); + } + return fmt; +} + +export function formatDate(iso: string | null | undefined, locale?: string) { if (!iso) return "—"; - return dateLong.format(new Date(iso)); + return dateLongFor(activeLocale(locale)).format(new Date(iso)); } // "APR 30 2026" — mono-caps tabular form for ledger/registry rows. -export function formatDateMono(iso: string | null | undefined) { +export function formatDateMono(iso: string | null | undefined, locale?: string) { if (!iso) return "—"; - return dateLong.format(new Date(iso)).toUpperCase().replace(",", ""); + return dateLongFor(activeLocale(locale)).format(new Date(iso)).toUpperCase().replace(",", ""); } -// "3:42 PM" — local wall-clock time. Intl renders in the browser's timezone. -const timeShort = new Intl.DateTimeFormat("en-US", { - hour: "numeric", - minute: "2-digit", -}); - // "APR 30 2026 · 3:42 PM" — date + local time for audit/detail panels. -export function formatDateTimeMono(iso: string | null | undefined) { +export function formatDateTimeMono(iso: string | null | undefined, locale?: string) { if (!iso) return "—"; - return `${formatDateMono(iso)} · ${timeShort.format(new Date(iso))}`; + return `${formatDateMono(iso, locale)} · ${timeShortFor(activeLocale(locale)).format(new Date(iso))}`; +} + +// Locale-grouped integer/decimal (e.g. en-US "1,234" · pt-BR "1.234"). +export function formatNumber(value: number, locale?: string) { + return new Intl.NumberFormat(activeLocale(locale)).format(value); } // "3d ago", "2mo ago" — terse relative time for the secondary line. @@ -35,17 +63,17 @@ export function formatRelative(iso: string | null | undefined) { const diffMs = Date.now() - new Date(iso).getTime(); if (Number.isNaN(diffMs) || diffMs < 0) return ""; const sec = Math.floor(diffMs / 1000); - if (sec < 60) return "just now"; + if (sec < 60) return i18n.t("relative.justNow"); const min = Math.floor(sec / 60); - if (min < 60) return `${min}m ago`; + if (min < 60) return i18n.t("relative.minutesAgo", { n: min }); const hr = Math.floor(min / 60); - if (hr < 24) return `${hr}h ago`; + if (hr < 24) return i18n.t("relative.hoursAgo", { n: hr }); const day = Math.floor(hr / 24); - if (day < 30) return `${day}d ago`; + if (day < 30) return i18n.t("relative.daysAgo", { n: day }); const mo = Math.floor(day / 30); - if (mo < 12) return `${mo}mo ago`; + if (mo < 12) return i18n.t("relative.monthsAgo", { n: mo }); const yr = Math.floor(day / 365); - return `${yr}y ago`; + return i18n.t("relative.yearsAgo", { n: yr }); } export function pad2(n: number) { @@ -61,9 +89,9 @@ export function slugify(value: string) { return s; } -export function formatMoney(amount: number, currency: string) { +export function formatMoney(amount: number, currency: string, locale?: string) { try { - return new Intl.NumberFormat(undefined, { + return new Intl.NumberFormat(activeLocale(locale), { style: "currency", currency, }).format(amount); diff --git a/clients/dashboard/src/lib/ticket-enums.ts b/clients/dashboard/src/lib/ticket-enums.ts index f3f388ef0c..f03e3e7fad 100644 --- a/clients/dashboard/src/lib/ticket-enums.ts +++ b/clients/dashboard/src/lib/ticket-enums.ts @@ -1,14 +1,15 @@ import type { TicketPriority, TicketStatus } from "@/api/tickets"; import type { EntityStatusTone } from "@/components/list"; -// Shared label + tone maps for ticket status/priority, used by the tickets list and -// the ticket detail page so the two never drift. +// Shared tone maps + i18n key maps for ticket status/priority, used by the tickets list +// and the ticket detail page so the two never drift. Labels are resolved at each call +// site via t(STATUS_LABEL_KEY[status]) against the "tickets" namespace. -export const STATUS_LABEL: Record = { - Open: "Open", - InProgress: "In progress", - Resolved: "Resolved", - Closed: "Closed", +export const STATUS_LABEL_KEY: Record = { + Open: "status.Open", + InProgress: "status.InProgress", + Resolved: "status.Resolved", + Closed: "status.Closed", }; export const STATUS_TONE: Record = { @@ -18,11 +19,11 @@ export const STATUS_TONE: Record = { Closed: "default", }; -export const PRIORITY_LABEL: Record = { - Low: "Low", - Medium: "Medium", - High: "High", - Critical: "Critical", +export const PRIORITY_LABEL_KEY: Record = { + Low: "priority.Low", + Medium: "priority.Medium", + High: "priority.High", + Critical: "priority.Critical", }; export const PRIORITY_TONE: Record = { diff --git a/clients/dashboard/src/locales/en-US/activity.json b/clients/dashboard/src/locales/en-US/activity.json new file mode 100644 index 0000000000..a9d9ce4848 --- /dev/null +++ b/clients/dashboard/src/locales/en-US/activity.json @@ -0,0 +1,21 @@ +{ + "title": "Live activity", + "unit": "event", + "description": "Full event log streamed from the API over Server-Sent Events.", + "ariaLabel": "Activity events", + "status.streaming": "streaming", + "status.offline": "offline", + "status.idle": "idle", + "status.connecting": "connecting", + "status.reconnecting": "reconnecting", + "shown_one": "{{count}} event shown", + "shown_other": "{{count}} events shown", + "totalCount": "{{total}} total", + "empty.listeningTitle": "Listening for activity", + "empty.noEventsTitle": "No events yet", + "empty.listeningBody": "The stream is open. Events will appear here as the backend publishes them.", + "empty.offlineBody": "The activity stream is not connected. Events will queue once the connection comes online.", + "col.action": "Action", + "col.entity": "Entity", + "col.time": "Time" +} diff --git a/clients/dashboard/src/locales/en-US/audits.json b/clients/dashboard/src/locales/en-US/audits.json new file mode 100644 index 0000000000..315cc22b79 --- /dev/null +++ b/clients/dashboard/src/locales/en-US/audits.json @@ -0,0 +1,93 @@ +{ + "eventType.none": "Unknown", + "eventType.entity": "Entity", + "eventType.security": "Security", + "eventType.activity": "Activity", + "eventType.exception": "Exception", + "severity.none": "—", + "severity.trace": "Trace", + "severity.debug": "Debug", + "severity.info": "Info", + "severity.warn": "Warn", + "severity.error": "Error", + "severity.critical": "Critical", + "tag.piiMasked": "PII masked", + "tag.quotaExceeded": "Quota exceeded", + "tag.sampled": "Sampled", + "tag.retained": "Retained", + "tag.healthCheck": "Health check", + "tag.auth": "Auth", + "tag.authz": "Authz", + "page.title": "Audit trail", + "page.unit": "event", + "page.description": "Activity, security, entity-change, and exception events across the platform. Window enforced server-side; max 90 days.", + "page.refresh": "Refresh", + "page.searchPlaceholder": "Search payload, source, user…", + "page.count_one": "{{count}} event found", + "page.count_other": "{{count}} events found", + "page.loadError": "Failed to load audits", + "empty.title": "No audits in this window", + "empty.body": "Try widening the time range or relaxing the filters. Activity events arrive as soon as the platform handles a request.", + "empty.reset": "Reset filters", + "col.actor": "Actor", + "col.event": "Event", + "col.severity": "Severity", + "col.timestamp": "Timestamp", + "actor.system": "System", + "summary.window": "Window {{range}}", + "summary.events": "events", + "summary.legend.activity": "Activity", + "summary.legend.entity": "Entity", + "summary.legend.security": "Security", + "summary.legend.exception": "Exception", + "summary.severity.warn": "Warn", + "summary.severity.err": "Err", + "summary.topSources": "Top sources", + "filter.advanced": "Advanced", + "filter.hideSystemTitle": "Hide the per-request system Activity events (api.*) so only meaningful audits show", + "filter.hideSystem": "Hide system activity", + "filter.clearSearch": "Clear search", + "filter.reset": "Reset", + "filter.type": "Type", + "filter.severity": "Severity", + "filter.field.source": "Source", + "filter.field.sourcePlaceholder": "api.identity.RegisterUser", + "filter.field.user": "User ID", + "filter.field.userPlaceholder": "00000000-0000-…", + "filter.field.correlation": "Correlation", + "filter.field.correlationPlaceholder": "0HMxxxx…", + "filter.field.trace": "Trace", + "filter.field.tracePlaceholder": "hex traceparent", + "filter.tags": "Tags", + "drawer.srTitle": "Audit detail", + "drawer.srDescription": "Full payload, identifiers, and related actions for the selected audit event.", + "drawer.close": "Close", + "drawer.eventFallback": "Audit event", + "drawer.utc": "UTC", + "drawer.section.identity": "Identity", + "drawer.section.trace": "Trace", + "drawer.section.payload": "Payload", + "drawer.section.pipeline": "Pipeline", + "drawer.section.related": "Related events", + "drawer.field.tenant": "Tenant", + "drawer.field.user": "User", + "drawer.field.userId": "User ID", + "drawer.field.source": "Source", + "drawer.field.traceId": "Trace ID", + "drawer.field.spanId": "Span ID", + "drawer.field.correlationId": "Correlation ID", + "drawer.field.requestId": "Request ID", + "drawer.field.occurred": "Occurred", + "drawer.field.received": "Received", + "drawer.field.sinkDelay": "Sink delay", + "drawer.field.auditId": "Audit ID", + "drawer.allByCorrelation": "All by correlation", + "drawer.allByTrace": "All by trace", + "drawer.copy": "Copy", + "drawer.copied": "Copied", + "drawer.related.count": "{{count}} on this correlation", + "drawer.related.empty": "No other events share this correlation. The full lifecycle of this request is contained in the payload above.", + "drawer.related.thisEvent": "this event", + "drawer.error.title": "Could not load audit", + "drawer.error.body": "The server returned an error fetching this audit. The record may have been purged by the retention job." +} diff --git a/clients/dashboard/src/locales/en-US/auth.json b/clients/dashboard/src/locales/en-US/auth.json new file mode 100644 index 0000000000..e48293302e --- /dev/null +++ b/clients/dashboard/src/locales/en-US/auth.json @@ -0,0 +1,113 @@ +{ + "shell.tagline": ".NET 10 Starter Kit", + "shell.encrypted": "Encrypted in transit · JWT-secured session", + "shell.footer": "fullstackhero Administration", + + "login.title": "Welcome back", + "login.subtitle": "Sign in to your account", + "login.inactivityNotice": "You were signed out due to inactivity.", + "login.tenant": "Tenant", + "login.tenantPlaceholder": "root", + "login.email": "Email", + "login.emailPlaceholder": "name@example.com", + "login.password": "Password", + "login.passwordPlaceholder": "Enter your password", + "login.forgot": "Forgot?", + "login.errorFallback": "Login failed", + "login.showPassword": "Show password", + "login.hidePassword": "Hide password", + "login.submit": "Sign in", + "login.submitting": "Signing in…", + "login.demoButton": "Sign in with a demo account", + + "demo.dialogTitle": "Demo accounts", + "demo.dialogDescription": "Pick a demo tenant and account to sign in with.", + "demo.liveDemo": "Live demo", + "demo.title": "Step into any role.", + "demo.subtitle": "Explore fullstackhero as any user across the demo tenants — we'll sign you in instantly.", + "demo.tenantsAria": "Demo tenants", + "demo.userCount_one": "{{count}} user", + "demo.userCount_other": "{{count}} users", + "demo.users": "Users", + "demo.tapToSignIn": "tap to sign in", + "demo.demoOnly": "demo only", + "demo.resets": "Resets with every reseed.", + + "inactivity.seconds": "seconds", + "inactivity.title": "Still there?", + "inactivity.description": "You've been inactive for a while. We'll sign you out shortly to keep your account secure.", + "inactivity.signOutNow": "Sign out now", + "inactivity.stay": "I'm here", + + "confirm.malformed": "This confirmation link is missing required parameters. It may have been clipped by your email client.", + "confirm.successFallback": "Your email is confirmed. You can now sign in.", + "confirm.backLink": "← Back to sign in", + "confirm.verifyingLead": "Verifying your", + "confirm.verifyingAccent": "email…", + "confirm.verifyingBody": "One moment — checking the confirmation token with the server.", + "confirm.successLead": "Email", + "confirm.successAccent": "confirmed", + "confirm.continueSignIn": "Continue to sign in", + "confirm.errorLead": "Couldn't", + "confirm.errorAccent": "confirm", + "confirm.errorTrail": " your email", + "confirm.errorHint": "The link may have expired or been used already. If you've signed in since this email was sent, you can ignore it.", + "confirm.backToSignIn": "Back to sign in", + "confirm.resetInstead": "Reset password instead", + + "forgot.titleLead": "Reset your", + "forgot.titleAccent": "password", + "forgot.subtitle": "Enter the email you sign in with. We'll send a one-time link.", + "forgot.tenant": "Tenant", + "forgot.tenantPlaceholder": "root", + "forgot.email": "Email", + "forgot.emailPlaceholder": "you@example.com", + "forgot.submit": "Send reset link", + "forgot.submitting": "Sending link…", + "forgot.successLead": "Check your", + "forgot.successAccent": "inbox", + "forgot.successPre": "If an account exists for", + "forgot.successMid": "in tenant", + "forgot.successPost": ", a one-time reset link is on its way. The link expires in 30 minutes.", + "forgot.tip1": "Didn't get it? Wait a minute, then check spam.", + "forgot.tip2": "Still nothing? Confirm the email + tenant and try again.", + "forgot.tryDifferent": "Try a different address", + "forgot.backToSignIn": "Back to sign in", + "forgot.remembered": "Remembered it?", + "forgot.signIn": "Sign in", + + "reset.incompleteLead": "This link is", + "reset.incompleteAccent": "incomplete", + "reset.incompletePre": "The reset link is missing one of", + "reset.fieldToken": "token", + "reset.fieldEmail": "email", + "reset.fieldTenant": "tenant", + "reset.or": "or", + "reset.incompletePost": "Some email clients clip long URLs — try copy-pasting the full link from the original email into your browser's address bar.", + "reset.requestNewLink": "Request a new link", + "reset.backToSignIn": "Back to sign in", + "reset.titleLead": "Set a new", + "reset.titleAccent": "password", + "reset.subtitlePre": "Resetting password for", + "reset.subtitleMid": "on", + "reset.subtitlePost": ".", + "reset.newPassword": "New password", + "reset.newPasswordPlaceholder": "At least 8 characters", + "reset.confirmPassword": "Confirm password", + "reset.confirmPasswordPlaceholder": "Re-enter password", + "reset.showPassword": "Show password", + "reset.hidePassword": "Hide password", + "reset.strength_weak": "Weak", + "reset.strength_fair": "Fair", + "reset.strength_strong": "Strong", + "reset.matches": "Passwords match", + "reset.notMatchYet": "Doesn't match yet", + "reset.errMismatch": "Passwords don't match.", + "reset.errTooShort": "Use at least 8 characters.", + "reset.submit": "Set new password", + "reset.submitting": "Updating password…", + "reset.toastTitle": "Password updated", + "reset.toastDesc": "Sign in with your new password to continue.", + "reset.changedMind": "Changed your mind?", + "reset.signIn": "Sign in" +} diff --git a/clients/dashboard/src/locales/en-US/catalog.json b/clients/dashboard/src/locales/en-US/catalog.json new file mode 100644 index 0000000000..c07cfb6d9c --- /dev/null +++ b/clients/dashboard/src/locales/en-US/catalog.json @@ -0,0 +1,204 @@ +{ + "products.title": "Products", + "products.description": "Browse and manage the catalog. Each product carries a SKU, brand, category, price, and live stock count.", + "products.searchPlaceholder": "Search by name, SKU, or slug…", + "products.count_one": "{{count}} product found", + "products.count_other": "{{count}} products found", + "brands.title": "Brands", + "brands.unit": "brand", + "brands.description": "Curate the maker imprints behind every product. Each brand carries its own slug, story, and logo.", + "brands.searchPlaceholder": "Search by name or slug…", + "brands.count_one": "{{count}} brand found", + "brands.count_other": "{{count}} brands found", + "categories.title": "Categories", + "categories.unit": "category", + "categories.description": "Group products into shelves. Categories nest under parents to form the taxonomy customers browse.", + "categories.searchPlaceholder": "Search by name or slug…", + "categories.count_one": "{{count}} category found", + "categories.count_other": "{{count}} categories found", + "filter.brand": "Brand", + "filter.category": "Category", + "filter.activeLabel": "Active filter", + "filter.all": "All", + "filter.active": "Active", + "filter.hidden": "Hidden", + "col.product": "Product", + "col.sku": "SKU", + "col.brand": "Brand", + "col.price": "Price", + "col.slug": "Slug", + "col.created": "Created", + "col.category": "Category", + "badge.hidden": "Hidden", + "badge.active": "Active", + "aria.openProduct": "Open product {{name}}", + "aria.editProduct": "Edit {{name}}", + "aria.deleteProduct": "Delete {{name}}", + "aria.editBrand": "Edit brand {{name}}", + "aria.editCategory": "Edit category {{name}}", + "row.under": "under {{name}}", + "row.parentFallback": "(parent)", + "row.root": "root", + "action.newProduct": "New product", + "action.addProduct": "Add product", + "action.newBrand": "New brand", + "action.addBrand": "Add brand", + "action.newCategory": "New category", + "action.addCategory": "Add category", + "action.clear": "Clear", + "action.clearFilters": "Clear filters", + "action.clearSearch": "Clear search", + "action.cancel": "Cancel", + "action.saving": "Saving…", + "action.saveChanges": "Save changes", + "action.deleting": "Deleting…", + "action.changePrice": "Change price", + "action.adjustStock": "Adjust stock", + "action.adjusting": "Adjusting…", + "action.refresh": "Refresh", + "action.edit": "Edit", + "action.delete": "Delete", + "empty.products.searchTitle": "No products found", + "empty.products.title": "No products yet", + "empty.products.searchBodyTerm": "Nothing matches \"{{term}}\". Try a different term or clear the filters.", + "empty.products.searchBody": "No products match the current filters.", + "empty.products.body": "Add your first product to start selling. Each carries its own SKU, price, stock, and image.", + "empty.brands.searchTitle": "No brands found", + "empty.brands.title": "No brands yet", + "empty.brands.searchBodyTerm": "Nothing matches \"{{term}}\". Try a different term or clear the search.", + "empty.brands.searchBody": "No brands match the current filters.", + "empty.brands.body": "Add your first brand to start building the catalog. Each brand carries its own slug, description, and logo.", + "empty.categories.searchTitle": "No categories found", + "empty.categories.title": "No categories yet", + "empty.categories.searchBodyTerm": "Nothing matches \"{{term}}\". Try a different term or clear the search.", + "empty.categories.searchBody": "No categories match the current filters.", + "empty.categories.body": "Categories give your catalog its tree. Create root shelves, then nest sub-shelves under them.", + "toast.productCreated": "Product created", + "toast.productUpdated": "Product updated", + "toast.productDeleted": "Product deleted", + "toast.brandCreated": "Brand created", + "toast.brandUpdated": "Brand updated", + "toast.brandDeleted": "Brand deleted", + "toast.categoryCreated": "Category created", + "toast.categoryUpdated": "Category updated", + "toast.categoryDeleted": "Category deleted", + "toast.priceUpdated": "Price updated", + "toast.stockAdjusted": "Stock adjusted", + "toast.createFailed": "Create failed", + "toast.updateFailed": "Update failed", + "toast.deleteFailed": "Delete failed", + "toast.priceChangeFailed": "Price change failed", + "toast.adjustmentFailed": "Adjustment failed", + "field.name": "Name", + "field.sku": "SKU", + "field.brand": "Brand", + "field.category": "Category", + "field.price": "Price", + "field.currency": "Currency", + "field.stock": "Stock", + "field.description": "Description", + "field.slug": "Slug", + "field.parent": "Parent", + "field.logoUrl": "Logo URL", + "field.newAmount": "New amount", + "placeholder.productName": "Classic Cotton Tee", + "placeholder.sku": "ACM-TS-001", + "placeholder.selectBrand": "Select a brand…", + "placeholder.selectCategory": "Select a category…", + "placeholder.productDescription": "100% organic cotton crew-neck.", + "placeholder.brandName": "Acme Goods", + "placeholder.brandDescription": "Quality essentials for the modern home.", + "placeholder.logoUrl": "https://…", + "placeholder.categoryName": "Outdoor", + "placeholder.categoryDescription": "Gear for the great outdoors.", + "placeholder.noParent": "No parent (root)", + "hint.skuFixed": "SKU is fixed after creation.", + "hint.skuNew": "Stock-keeping unit. Becomes the canonical identifier.", + "hint.description": "Shown on listing and product detail pages.", + "hint.slug": "Auto-derived from the name. Used in URLs.", + "hint.brandDescription": "Shown on listing and product detail pages.", + "hint.logoUrl": "Optional. Public URL to the brand's logo image.", + "hint.parent": "Optional. Leave empty to make this a root category.", + "hint.categoryDescription": "Shown on category browse pages.", + "parentOption.label": "Parent category", + "visibility.title": "Visibility", + "visibility.listed": "Listed for customers.", + "visibility.hidden": "Hidden from listings.", + "visibility.active": "Active", + "productEditor.editTitle": "Edit product", + "productEditor.addTitle": "Add a product", + "productEditor.editDesc": "Update details for {{name}}. Use the inline price/stock chips on the row to change those — they emit domain events.", + "productEditor.addDesc": "Add a product to your catalog. Price and stock can be adjusted inline after creation.", + "brandEditor.editTitle": "Edit brand", + "brandEditor.addTitle": "Add a brand", + "brandEditor.editDesc": "Update details for {{name}}. The slug is re-derived from the name.", + "brandEditor.addDesc": "Add a brand to your catalog. The slug is generated automatically from the name.", + "categoryEditor.editTitle": "Edit category", + "categoryEditor.addTitle": "Add a category", + "categoryEditor.editDesc": "Update details for {{name}}. The slug is re-derived from the name.", + "categoryEditor.addDesc": "Add a category to your catalog. The slug is generated automatically from the name.", + "price.title": "Change price", + "price.emitsPrefix": "Emits a ", + "price.emitsSuffix": " domain event for {{name}}.", + "price.emitsDetailPrefix": "{{name}} — emits a ", + "price.emitsDetailSuffix": " domain event.", + "price.was": "Was", + "price.becomes": "Becomes", + "stock.title": "Adjust stock", + "stock.emitsPrefix": "Add or remove units for {{name}}. Emits a ", + "stock.emitsSuffix": " event.", + "stock.emitsDetailPrefix": "{{name}} — add or remove units. Emits a ", + "stock.emitsDetailSuffix": " event.", + "stock.current": "Current", + "stock.becomes": "Becomes", + "stock.deltaLabel": "Delta", + "stock.negativePrefix": "Stock cannot go negative. Maximum decrement is ", + "stock.negativeSuffix": ".", + "stock.negativeDetailPrefix": "Stock cannot go negative. The maximum decrement here is ", + "delete.productTitle": "Delete product", + "delete.productBody": ". The product will no longer appear in any listing or report.", + "delete.brandTitle": "Delete brand", + "delete.brandBody": ". Products referencing this brand will need to be reassigned.", + "delete.categoryTitle": "Delete category", + "delete.categoryBody": ". Categories with child categories cannot be deleted — move or delete the children first.", + "delete.removesPrefix": "This permanently removes ", + "delete.createdOn": "(created {{date}})", + "delete.dateOnly": "({{date}})", + "detail.back": "Back to products", + "detail.section.pricing": "Pricing", + "detail.section.inventory": "Inventory", + "detail.section.identifiers": "Identifiers", + "detail.section.description": "Description", + "detail.section.descriptionDesc": "Customer-facing copy shown on the product page.", + "detail.section.images": "Images", + "detail.section.imagesDesc": "Drop more to add. Star one to make it the cover.", + "detail.section.audit": "Audit", + "detail.stat.price": "price", + "detail.stat.outOfStock": "out of stock", + "detail.stat.low": "low (< {{n}})", + "detail.stat.inStock": "in stock", + "detail.stat.images": "images", + "detail.meta.created": "Created {{rel}}", + "detail.meta.updated": "Updated {{rel}}", + "detail.pricing.listed": "Listed price · {{currency}}", + "detail.inventory.outOfStock": "Out of stock", + "detail.inventory.below": "Below {{n}} units", + "detail.inventory.unitsOnHand": "Units on hand", + "detail.id.sku": "SKU", + "detail.id.slug": "Slug", + "detail.id.productId": "Product ID", + "detail.id.brandId": "Brand ID", + "detail.id.categoryId": "Category ID", + "detail.audit.created": "Created", + "detail.audit.revised": "Revised", + "detail.audit.never": "Never", + "detail.audit.noEdits": "no edits since creation", + "detail.audit.status": "Status", + "detail.audit.active": "Active", + "detail.audit.hidden": "Hidden", + "detail.description.empty": "No description on file. Customers will see a blank description on the product page until you add one.", + "detail.notFound.title": "Product not found", + "detail.notFound.body": "It may have been deleted, or the link may be wrong.", + "detail.editor.title": "Edit product", + "detail.editor.desc": "Update details for {{name}}. Use the price/stock actions in the sidebar to change those — they emit domain events." +} diff --git a/clients/dashboard/src/locales/en-US/chat.json b/clients/dashboard/src/locales/en-US/chat.json new file mode 100644 index 0000000000..b3cd6741a7 --- /dev/null +++ b/clients/dashboard/src/locales/en-US/chat.json @@ -0,0 +1,216 @@ +{ + "util.unnamedChannel": "(unnamed channel)", + "util.directMessage": "Direct message", + "util.emptyGroup": "Empty group", + "day.today": "Today", + "day.yesterday": "Yesterday", + "unnamed": "(unnamed)", + + "page.emptyTitle": "Pick a conversation", + "page.emptyBody": "Choose a channel on the left to jump in. Channels are public to your tenant; DMs are private to the people in them. Mentions land in the notification bell, top right.", + "page.hintSend": "Send", + "page.hintNewline": "Newline", + "page.hintMention": "Mention", + "page.channelUnreachable": "That channel isn't reachable. It may have been archived or you're no longer a member.", + "page.loadingChannel": "Loading channel…", + "page.backToChannels": "Back to channels", + "page.member_one": "{{count}} member", + "page.member_other": "{{count}} members", + "page.searchMessages": "Search messages", + "page.channelSettings": "Channel settings", + "page.olderThanWindow": "That message is older than the loaded window.", + + "rail.brand": "Chat", + "rail.filterPlaceholder": "Filter channels…", + "rail.clearFilter": "Clear filter", + "rail.sectionChannels": "Channels", + "rail.newChannel": "New channel", + "rail.sectionDms": "Direct Messages", + "rail.newDm": "New direct message", + "rail.loading": "Loading…", + "rail.noMatches": "No matches.", + "rail.noChannels": "No channels yet.", + "rail.noConversations": "No conversations.", + "rail.footerFiltered": "{{shown}} of {{total}}", + "rail.footerCount_one": "{{count}} channel", + "rail.footerCount_other": "{{count}} channels", + "rail.unreadAria": "{{n}} unread", + "rail.createTitle": "Create a channel", + "rail.createDescription": "Channels are where teams talk. Anyone in your tenant can join public channels.", + "rail.nameLabel": "Name", + "rail.namePlaceholder": "engineering, design-feedback…", + "rail.descLabel": "Description (optional)", + "rail.descPlaceholder": "What's this channel about?", + "rail.privateLabel": "Private", + "rail.privateHint": "Only invited members can find or join this channel.", + "rail.cancel": "Cancel", + "rail.createSubmit": "Create channel", + "rail.dmTitle": "New direct message", + "rail.dmDescription": "Find someone in your tenant and we'll open a DM with them. Existing DMs are reused — you won't create duplicates.", + "rail.dmSearchLabel": "Search", + "rail.dmSearchPlaceholder": "Name, username, or email…", + "rail.dmSearching": "Searching…", + "rail.dmLoadingPeople": "Loading people…", + "rail.dmNoMatch": "No one matches \"{{term}}\".", + "rail.dmNoTeammates": "No teammates in your tenant yet.", + "rail.dmSearchResults": "Search results", + "rail.dmSuggested": "Suggested", + + "composer.replyPlaceholder": "Type your reply…", + "composer.messageChannel": "Message #{{name}}", + "composer.messageOther": "Message {{name}}", + "composer.ariaChannel": "Message channel {{name}}", + "composer.ariaOther": "Message {{name}}", + "composer.ariaReply": "Type your reply", + "composer.attachFile": "Attach a file", + "composer.sendMessage": "Send message", + "composer.hintReply": "Enter to send · Shift+Enter for newline · Esc to clear", + "composer.hintDefault": "Enter to send · Shift+Enter for newline · @ + 2 chars to mention", + "composer.sendFailedRetry": "Send failed — retry?", + "composer.replaceAttachment": "Replace the existing attachment first.", + "composer.attachError": "Couldn't attach that file.", + "composer.sendFailed": "Message failed to send — try again.", + "composer.replyingTo": "Replying to", + "composer.noTextEmpty": "(no text — attachment or empty)", + "composer.clearReplyAria": "Clear reply context", + "composer.clearReply": "Clear reply", + "composer.uploadPreparing": "Preparing…", + "composer.uploadUploading": "Uploading… {{percent}}%", + "composer.uploadFinalizing": "Finalizing…", + "composer.uploadFailed": "Upload failed", + "composer.uploadDone": "Done", + "composer.uploadProgressAria": "Upload progress", + "composer.uploadCancelAria": "Cancel upload", + "composer.uploadCancel": "Cancel", + "composer.readyToSend": "ready to send", + "composer.removeAttachment": "Remove attachment", + + "typing.one": "{{name}} is typing…", + "typing.two": "{{a}} and {{b}} are typing…", + "typing.many": "{{a}} and {{count}} others are typing…", + + "pinned.bar_one": "{{count}} pinned message", + "pinned.bar_other": "{{count}} pinned messages", + "pinned.countAria_one": "{{count}} pinned message, click to review", + "pinned.countAria_other": "{{count}} pinned messages, click to review", + "pinned.panelTitle": "Pinned messages", + "pinned.noText": "(no text)", + "pinned.attachment": "📎 {{name}}", + "pinned.attachmentMore": "📎 {{name}} (+{{count}})", + + "search.close": "Close search", + "search.placeholder": "Search messages in this channel", + "search.clear": "Clear search", + "search.searching": "Searching…", + "search.noMatches": "No matches for \"{{term}}\".", + "search.footer_one": "{{count}} match · click to jump · Esc to close", + "search.footer_other": "{{count}} matches · click to jump · Esc to close", + "search.footerEsc": "Esc to close", + + "mention.title": "Mention", + "mention.hint": "↑↓ navigate · Enter select · Esc cancel", + "mention.searching": "Searching…", + "mention.listboxAria": "Mention suggestions", + + "message.edited": "· edited", + "message.pinned": "Pinned", + "message.deleted": "[message deleted]", + "message.replyingTo": "Replying to", + "message.aMessage": "a message", + "message.noTextParent": "(no text — attachment or empty)", + "message.seen": "Seen", + "message.seenEveryone": "Seen by everyone", + "message.seenByN": "Seen by {{n}}", + "message.mentionTitle": "Mention of {{username}}", + "message.mentionAria": "Mention of {{username}}, click to open profile", + "message.you": "you", + "message.lookingUp": "Looking up…", + "message.userNotFound": "User not found.", + "message.loadUserError": "Couldn't load this user.", + "message.copyHandle": "Copy @{{username}}", + "message.opening": "Opening…", + "message.openDm": "Open DM", + "message.thatsYou": "That's you", + "message.copied": "Copied {{text}}", + "message.copyFailed": "Couldn't copy to clipboard", + "message.openDmFailed": "Couldn't open DM", + "message.reactAria": "React with {{emoji}}", + "message.reactionAddAria": "Add {{emoji}} reaction, {{n}} so far", + "message.reactionRemoveAria": "Remove your {{emoji}} reaction, {{n}} so far", + "message.react": "React", + "message.reply": "Reply", + "message.unpin": "Unpin", + "message.pin": "Pin", + "message.edit": "Edit", + "message.delete": "Delete", + "message.toastDeleted": "Message deleted", + "message.deleteFailed": "Couldn't delete the message", + "message.unpinned": "Unpinned", + "message.pinnedToast": "Pinned", + "message.pinUpdateFailed": "Couldn't update the pin", + "message.deleteTitle": "Delete this message?", + "message.deleteDescription": "This can't be undone. Everyone in the channel will see the message disappear in real time.", + "message.deleteNoPreview": "No text preview — attachments or empty body.", + "message.deleteCancel": "Cancel", + "message.deleting": "Deleting…", + "message.deleteConfirm": "Delete message", + "message.editAria": "Edit message", + "message.editHint": "Enter to save · Shift+Enter for newline · Esc to cancel", + "message.editCancel": "Cancel", + "message.editSaving": "Saving…", + "message.editSave": "Save", + + "list.loading": "Loading messages…", + "list.emptyTitle": "No messages yet", + "list.emptyBody": "This is the very beginning of the conversation. Send the first message to break the silence.", + "list.loadingOlder": "Loading older…", + "list.beginning": "Beginning of the conversation", + "list.logAria": "Channel messages", + "list.new": "New", + "list.jumpAria_one": "Jump to latest, {{count}} unseen message", + "list.jumpAria_other": "Jump to latest, {{count}} unseen messages", + "list.jump_one": "new message", + "list.jump_other": "new messages", + + "settings.title": "Channel settings", + "settings.description": "Manage the channel name, members, and lifecycle.", + "settings.general": "General", + "settings.nameLabel": "Name", + "settings.descLabel": "Description", + "settings.descPlaceholder": "What's this channel about?", + "settings.privateLabel": "Private", + "settings.privateHint": "Only invited members can find or join this channel.", + "settings.members": "Members", + "settings.danger": "Danger zone", + "settings.archiveTitle": "Archive channel", + "settings.archiveHint": "Hides the channel for everyone. Restore from the admin panel if you change your mind.", + "settings.archiveConfirm": "Archive this channel for everyone?", + "settings.archiving": "Archiving…", + "settings.archive": "Archive", + "settings.leaveTitle": "Leave channel", + "settings.leaveHint": "You'll stop receiving messages. Rejoin via channel discovery if it's public.", + "settings.leaveConfirm": "Leave this channel?", + "settings.leaving": "Leaving…", + "settings.leave": "Leave", + "settings.close": "Close", + "settings.saving": "Saving…", + "settings.saveChanges": "Save changes", + "settings.toastUpdated": "Channel updated.", + "settings.toastUpdateFailed": "Couldn't save channel changes.", + "settings.toastArchived": "Channel archived.", + "settings.toastArchiveFailed": "Couldn't archive the channel.", + "settings.toastLeft": "You left the channel.", + "settings.toastLeaveFailed": "Couldn't leave the channel.", + "settings.you": "you", + "settings.admin": "admin", + "settings.removeConfirm": "Remove {{name}} from the channel?", + "settings.removeAria": "Remove {{name}}", + "settings.toastMemberRemoved": "Member removed.", + "settings.toastRemoveFailed": "Couldn't remove the member.", + "settings.addMember": "Add member", + "settings.addPlaceholder": "Name, username, or email…", + "settings.addSearching": "Searching…", + "settings.noMatchesOutside": "No matches outside the current member list.", + "settings.toastMemberAdded": "Member added.", + "settings.toastAddFailed": "Couldn't add the member." +} diff --git a/clients/dashboard/src/locales/en-US/commandPalette.json b/clients/dashboard/src/locales/en-US/commandPalette.json new file mode 100644 index 0000000000..58bfe09aa3 --- /dev/null +++ b/clients/dashboard/src/locales/en-US/commandPalette.json @@ -0,0 +1,83 @@ +{ + "group.navigate": "Navigate", + "group.create": "Create", + "group.account": "Account", + "group.theme": "Theme", + "group.accent": "Accent", + "group.session": "Session", + "nav.overview.label": "Overview", + "nav.overview.hint": "Tenant telemetry & usage", + "nav.activity.label": "Live activity", + "nav.activity.hint": "Real-time event stream", + "nav.chat.label": "Chat", + "nav.chat.hint": "Channels & direct messages", + "nav.files.label": "Files", + "nav.files.hint": "My uploaded assets", + "nav.users.label": "Users", + "nav.users.hint": "Identity directory", + "nav.roles.label": "Roles", + "nav.roles.hint": "Permissions & role assignment", + "nav.groups.label": "Groups", + "nav.groups.hint": "Org groups & membership", + "nav.products.label": "Products", + "nav.products.hint": "Catalog inventory", + "nav.brands.label": "Brands", + "nav.brands.hint": "Catalog brands", + "nav.categories.label": "Categories", + "nav.categories.hint": "Catalog categories", + "nav.tickets.label": "Tickets", + "nav.tickets.hint": "Support requests", + "nav.invoices.label": "Invoices", + "nav.invoices.hint": "Billing history", + "nav.health.label": "Health", + "nav.health.hint": "Readiness probe & dependencies", + "nav.audits.label": "Audit trail", + "nav.audits.hint": "Activity, security, entity-change events", + "nav.trash.label": "Trash", + "nav.trash.hint": "Soft-deleted records", + "nav.sessions.label": "Sessions", + "nav.sessions.hint": "Active user sessions", + "nav.settings.label": "Settings", + "create.user.label": "Create user", + "create.user.hint": "Register a new account", + "create.role.label": "Create role", + "create.role.hint": "Define a new permission set", + "create.group.label": "Create group", + "create.group.hint": "Organize members", + "create.product.label": "Create product", + "create.product.hint": "Add to catalog", + "create.brand.label": "Create brand", + "create.brand.hint": "Add a catalog brand", + "create.category.label": "Create category", + "create.category.hint": "Add a catalog category", + "create.ticket.label": "Create ticket", + "create.ticket.hint": "File a support request", + "create.channel.label": "Create chat channel", + "create.channel.hint": "Start a new conversation space", + "create.file.label": "Upload file", + "create.file.hint": "Add to your storage", + "account.profile.label": "Profile", + "account.profile.hint": "Name, email, contact", + "account.security.label": "Security", + "account.security.hint": "Password, 2FA, sessions", + "account.keys.label": "API keys", + "account.keys.hint": "Generate & rotate", + "account.notifications.label": "Notifications", + "account.notifications.hint": "Email preferences", + "account.appearance.label": "Appearance", + "account.appearance.hint": "Theme, accent, font, density", + "theme.light": "Switch to light", + "theme.dark": "Switch to dark", + "theme.system": "Follow system theme", + "accent.set": "Set accent: {{name}}", + "session.logout.label": "Sign out", + "session.logout.hint": "End this session", + "ui.searchPlaceholder": "Type a command or search…", + "ui.searchAria": "Search commands", + "ui.srTitle": "Command palette", + "ui.srDescription": "Search across pages, account actions, theme and accent. Use arrow keys to navigate; Enter to select.", + "ui.emptyTitle": "No matches", + "ui.emptyBody": "Try a different keyword — page name, entity, theme, accent, or sign-out.", + "ui.navigate": "navigate", + "ui.select": "select" +} diff --git a/clients/dashboard/src/locales/en-US/common.json b/clients/dashboard/src/locales/en-US/common.json new file mode 100644 index 0000000000..33e56d8979 --- /dev/null +++ b/clients/dashboard/src/locales/en-US/common.json @@ -0,0 +1,136 @@ +{ + "language": "Language", + "language.enUS": "English (US)", + "language.ptBR": "Português (BR)", + "search": "Search", + "commandPalette.open": "Open command palette", + "profileMenu.open": "Open profile menu", + "unknownUser": "Unknown", + "skipToContent": "Skip to main content", + "brand.dashboard": "Dashboard", + "presence.connected_one": "Connected · {{formatted}} event", + "presence.connected_other": "Connected · {{formatted}} events", + "presence.offline": "Stream offline", + "presence.connecting": "Connecting…", + "presence.reconnecting": "Reconnecting…", + "presence.idle": "Idle", + "theme.title": "Theme", + "theme.light": "Light", + "theme.dark": "Dark", + "theme.system": "System", + "account.title": "Account", + "account.profile": "Profile", + "account.settings": "Settings", + "account.apiKeys": "API keys", + "account.signOut": "Sign out", + "signOut.title": "Sign out of fullstackhero?", + "signOut.description": "You'll need to sign in again to access this tenant. Any unsaved work in this session will be lost.", + "signOut.cancel": "Cancel", + "signOut.confirm": "Sign out", + "sidebar.collapse": "Collapse sidebar", + "sidebar.expand": "Expand sidebar", + "nav.primary": "Primary navigation", + "nav.primaryDescription": "Site sections and account links.", + "nav.openMenu": "Open navigation menu", + "nav.overview": "Overview", + "nav.chat": "Chat", + "nav.myFiles": "My Files", + "nav.settings": "Settings", + "nav.section.operations": "Operations", + "nav.section.catalog": "Catalog", + "nav.section.helpdesk": "Helpdesk", + "nav.section.identity": "Identity", + "nav.section.system": "System", + "nav.liveActivity": "Live activity", + "nav.subscription": "Subscription", + "nav.wallet": "WhatsApp wallet", + "nav.invoices": "Invoices", + "nav.products": "Products", + "nav.brands": "Brands", + "nav.categories": "Categories", + "nav.tickets": "Tickets", + "nav.users": "Users", + "nav.roles": "Roles", + "nav.groups": "Groups", + "nav.health": "Health", + "nav.audits": "Audit trail", + "nav.sessions": "Sessions", + "nav.trash": "Trash", + "expiryBanner.expired": "Your subscription has expired. Contact your operator to renew and restore full access.", + "expiryBanner.grace": "Your subscription expired — {{days}} of grace left (until {{date}}). Contact your operator to renew.", + "expiryBanner.nearing": "Your subscription expires in {{days}}.", + "expiryBanner.days_one": "{{count}} day", + "expiryBanner.days_other": "{{count}} days", + "expiryBanner.graceSoon": "soon", + "expiryBanner.dismiss": "Dismiss", + "expiryBanner.dismissNotice": "Dismiss subscription notice", + "impersonationBanner.crossTenant": "Cross-tenant impersonation", + "impersonationBanner.impersonating": "Impersonating", + "impersonationBanner.operator": "operator", + "impersonationBanner.end": "End impersonation", + "impersonationBanner.ending": "Ending…", + "impersonationBanner.toastEnded": "Impersonation ended", + "impersonationBanner.toastReturned": "Returned to your session", + "impersonationBanner.toastErrorTitle": "Could not end impersonation cleanly", + "impersonationBanner.toastErrorDesc": "Restored local session.", + "backToSignIn": "Back to sign in", + "relative.justNow": "just now", + "relative.secondsAgo": "{{n}}s ago", + "relative.minutesAgo": "{{n}}m ago", + "relative.hoursAgo": "{{n}}h ago", + "relative.daysAgo": "{{n}}d ago", + "relative.monthsAgo": "{{n}}mo ago", + "relative.yearsAgo": "{{n}}y ago", + "notFound.title": "Page not found", + "notFound.body": "We couldn't find anything at that address. It may be a stale link, a renamed route, or a path that never existed.", + "notFound.requested": "Requested", + "notFound.backHome": "Back home", + "notFound.goBack": "Go back to the previous page", + "tenantDeactivated.title": "Tenant deactivated", + "tenantDeactivated.body": "This tenant has been deactivated and is no longer available. If you think this is a mistake, please contact your administrator.", + "impersonationEnded.title": "Impersonation ended", + "impersonationEnded.body": "The operator's access to this session was revoked or has expired. Sign in with your own account to continue.", + "close": "Close", + "list.clear": "Clear", + "list.clearSearch": "Clear search", + "list.clearFilter": "Clear filter", + "list.noMatches": "No matches.", + "routeError.title": "Something went wrong", + "routeError.reload": "Reload", + "routeError.goHome": "Go home", + "routeError.unexpected": "Unexpected error", + "list.loading": "Loading…", + "list.pageOf": "Page {{page}} of {{total}}", + "list.prevPage": "Previous page", + "list.nextPage": "Next page", + "unit.item_one": "{{count}} item", + "unit.item_other": "{{count}} items", + "unit.event_one": "{{count}} event", + "unit.event_other": "{{count}} events", + "unit.brand_one": "{{count}} brand", + "unit.brand_other": "{{count}} brands", + "unit.category_one": "{{count}} category", + "unit.category_other": "{{count}} categories", + "unit.file_one": "{{count}} file", + "unit.file_other": "{{count}} files", + "unit.group_one": "{{count}} group", + "unit.group_other": "{{count}} groups", + "unit.invoice_one": "{{count}} invoice", + "unit.invoice_other": "{{count}} invoices", + "unit.role_one": "{{count}} role", + "unit.role_other": "{{count}} roles", + "unit.user_one": "{{count}} user", + "unit.user_other": "{{count}} users", + "unit.session_one": "{{count}} session", + "unit.session_other": "{{count}} sessions", + "unit.ticket_one": "{{count}} ticket", + "unit.ticket_other": "{{count}} tickets", + "realtimeStatus.offline": "Offline", + "realtimeStatus.connecting": "Connecting", + "realtimeStatus.live": "Live", + "realtimeStatus.reconnecting": "Reconnecting", + "realtimeStatus.title": "Realtime: {{label}}", + "session.restoring": "Restoring your session…", + "field.required": "required", + "errorBand.failure": "Failure" +} diff --git a/clients/dashboard/src/locales/en-US/files.json b/clients/dashboard/src/locales/en-US/files.json new file mode 100644 index 0000000000..95599df5bc --- /dev/null +++ b/clients/dashboard/src/locales/en-US/files.json @@ -0,0 +1,130 @@ +{ + "page.title": "Files", + "page.unit": "file", + "page.description": "Drop images, documents, or archives. Uploads are private to you by default; flip a file to public from its preview to make it visible to the rest of the tenant under Shared.", + "tabs.aria": "File scopes", + "tabs.mine": "My files", + "tabs.shared": "Shared in tenant", + "search.placeholder": "Search by filename…", + "search.aria": "Search files", + "search.clear": "Clear search", + "chip.all": "All", + "chip.images": "Images", + "chip.documents": "Documents", + "chip.archives": "Archives", + "chip.other": "Other", + "error.mine": "Couldn't load your files.", + "error.shared": "Couldn't load shared files.", + "empty.mine.title": "No files yet", + "empty.mine.body": "Drop a file above to get started. Your uploads are private by default.", + "empty.shared.title": "Nothing shared yet", + "empty.shared.body": "When a teammate flips one of their files to public, it shows up here for everyone in the tenant.", + "empty.refresh": "Refresh", + "empty.noMatch.title": "No matches", + "empty.noMatch.body": "Nothing matches the current filter.", + "empty.noMatch.bodyTerm": "Nothing matches the current filter \"{{term}}\".", + "empty.noMatch.reset": "Reset filters", + "col.filename": "Filename", + "col.uploadedBy": "Uploaded by", + "col.visibility": "Visibility", + "col.size": "Size", + "col.uploaded": "Uploaded", + "count.showing_one": "Showing {{shown}} of {{count}} file", + "count.showing_other": "Showing {{shown}} of {{count}} files", + "count.total_one": "{{count}} file", + "count.total_other": "{{count}} files", + "visibility.public": "Public", + "visibility.private": "Private", + "card.by": "by", + "preview.fallbackName": "File", + "preview.toastDeleted": "File deleted", + "preview.toastDeleteFailed": "Delete failed", + "preview.toastNowPublic": "File is now public to your tenant", + "preview.toastNowPrivate": "File is now private", + "preview.toastVisibilityFailed": "Visibility change failed", + "preview.errorMeta": "Couldn't load file metadata.", + "preview.confirmDelete": "Delete this file?", + "preview.cancel": "Cancel", + "preview.deleting": "Deleting…", + "preview.confirmDeleteButton": "Confirm delete", + "preview.delete": "Delete", + "preview.close": "Close", + "preview.notFinalized": "Upload not yet finalized.", + "preview.quarantined": "This file is quarantined and cannot be previewed.", + "preview.expired": "Preview link expired or unreachable.", + "preview.retry": "Retry", + "preview.notAvailable": "Preview not available for this file type.", + "preview.openNewTab": "Open in new tab", + "preview.textError": "Couldn't fetch text body: {{err}}", + "preview.fetchFailed": "Failed to fetch", + "preview.meta.fileId": "File ID", + "preview.meta.ownerType": "Owner type", + "preview.meta.uploadedBy": "Uploaded by", + "preview.meta.contentType": "Content type", + "preview.meta.size": "Size", + "preview.meta.status": "Status", + "preview.meta.created": "Created", + "preview.meta.uploaderLoading": "Loading…", + "preview.meta.visibility": "Visibility", + "preview.meta.publicHint": "Everyone in your tenant can find this file under Shared.", + "preview.meta.privateHint": "Only you can preview or download this file.", + "preview.meta.readOnly": "Read-only", + "preview.meta.toggleAria": "Toggle public visibility", + "preview.status.pending": "Pending upload", + "preview.status.available": "Available", + "preview.status.quarantined": "Quarantined", + "preview.status.unknown": "Unknown ({{status}})", + "preview.download": "Download", + "dropzone.toastUploaded": "File uploaded", + "dropzone.dismiss": "Dismiss", + "dropzone.cancel": "Cancel", + "dropzone.progressAria": "Upload progress", + "dropzone.caption.preparing": "Preparing upload…", + "dropzone.caption.uploading": "Uploading {{name}}", + "dropzone.caption.uploadingFallback": "Uploading …", + "dropzone.caption.finalizing": "Finalizing…", + "dropzone.caption.done": "Uploaded {{name}}", + "dropzone.caption.doneFallback": "Uploaded file", + "dropzone.caption.error": "Upload failed", + "dropzone.caption.idle": "Drop a file or click to browse", + "dropzone.detail.uploading": "{{loaded}} of {{total}}", + "dropzone.detail.allowed": "Allowed: {{ext}}", + "dropzone.detail.upTo": " · up to {{size}}", + "dropzone.detail.dropAFile": "Drop a file", + "imageInput.upload": "Upload", + "imageInput.pasteUrl": "Paste URL", + "imageInput.replace": "Replace image", + "imageInput.choose": "Choose image", + "imageInput.remove": "Remove", + "imageInput.urlPlaceholder": "https://…", + "imageInput.hintUpload": "JPG/PNG/WebP/GIF · up to {{size}}", + "imageInput.hintUrl": "Direct link to an image you host elsewhere.", + "imageInput.toastUploaded": "Image uploaded", + "imageInput.uploadFailed": "Upload failed", + "imageInput.noPublicUrl": "Server returned no publicUrl for this file.", + "pim.upload": "Upload images", + "pim.count_one": "{{count}} image · JPG / PNG / WebP / GIF · up to 10 MB", + "pim.count_other": "{{count}} images · JPG / PNG / WebP / GIF · up to 10 MB", + "pim.empty": "No images yet. Upload one to set the product's cover.", + "pim.toastCoverUpdated": "Cover image updated", + "pim.toastImageRemoved": "Image removed", + "pim.errAttach": "Failed to attach image", + "pim.errSetCover": "Failed to set cover", + "pim.errRemove": "Failed to remove image", + "pim.errUploadNamed": "Upload failed: {{name}}", + "pim.noPublicUrl": "Server returned no publicUrl for the uploaded image.", + "pim.cover": "Cover", + "pim.setCover": "Set as cover", + "pim.removeImage": "Remove image", + "pim.previewImage": "Preview image", + "pim.previewTitle": "Image preview", + "pim.currentCover": "Current cover image.", + "pim.clickOutside": "Click outside to close.", + "pim.close": "Close", + "pim.detachTitle": "Detach this image?", + "pim.detachBody": "The image is removed from this product. ", + "pim.detachCoverNote": "It's currently the cover — another image will be promoted automatically.", + "pim.cancel": "Cancel", + "pim.removing": "Removing…", + "pim.remove": "Remove" +} diff --git a/clients/dashboard/src/locales/en-US/health.json b/clients/dashboard/src/locales/en-US/health.json new file mode 100644 index 0000000000..841913a043 --- /dev/null +++ b/clients/dashboard/src/locales/en-US/health.json @@ -0,0 +1,48 @@ +{ + "eyebrow": "System · Health", + "title": "Health", + "subtitlePre": "Live readiness probe across every registered dependency. Polled every ", + "subtitleSuffix": ".", + "pauseTitle": "Pause auto-refresh", + "resumeTitle": "Resume auto-refresh", + "live": "Live", + "paused": "Paused", + "refresh": "Refresh", + "dependencies": "Dependencies", + "checksTotal_one": "{{count}} check · total", + "checksTotal_other": "{{count}} checks · total", + "hero.Healthy.headline": "All systems operational", + "hero.Healthy.subline": "Every dependency is responding within tolerance.", + "hero.Degraded.headline": "Partial degradation", + "hero.Degraded.subline": "One or more checks are reporting elevated latency or warnings.", + "hero.Unhealthy.headline": "Disruption detected", + "hero.Unhealthy.subline": "At least one critical dependency is unreachable. Investigate the failing checks below.", + "status.Healthy": "Healthy", + "status.Degraded": "Degraded", + "status.Unhealthy": "Unhealthy", + "http": "HTTP {{code}}", + "vital.checks": "Checks", + "vital.checksHint": "passing", + "vital.roundTrip": "Round-trip", + "vital.roundTripHint": "end-to-end", + "vital.slowest": "Slowest", + "vital.slowestHint": "single check", + "vital.lastPoll": "Last poll", + "recentPolls": "Recent polls", + "sessionCount": "{{n}} / {{limit}} this session", + "historyAria": "Recent poll history", + "historyTick": "{{status}} · {{time}}", + "historyNoData": "no data", + "noDescription": "No description registered.", + "latencyBudget": "Latency budget", + "latencyBudgetValue": "{{value}} / 500 ms", + "latencyBudgetHint": "Scaled against a soft 500 ms readiness budget. Bars in the upper third are early-warning territory.", + "detail": "Detail", + "noDetails": "No additional details reported.", + "moreDetails": "+{{count}} more", + "error.title": "Health endpoint unreachable", + "error.default": "The browser couldn't reach /health/ready. The API may be down, or a network policy is blocking the request.", + "empty.title": "No checks registered", + "empty.bodyPre": "Modules can register dependency checks via ", + "empty.bodyPost": ". None are reporting yet." +} diff --git a/clients/dashboard/src/locales/en-US/identity.json b/clients/dashboard/src/locales/en-US/identity.json new file mode 100644 index 0000000000..b1879afbcf --- /dev/null +++ b/clients/dashboard/src/locales/en-US/identity.json @@ -0,0 +1,347 @@ +{ + "unnamedUser": "Unnamed user", + "backToUsers": "Back to users", + "notFound": "User not found.", + "member": "Member", + "cancel": "Cancel", + + "badge.active": "Active", + "badge.inactive": "Inactive", + "badge.emailConfirmed": "Email confirmed", + "badge.emailPending": "Email pending", + + "actions.impersonate": "Impersonate", + "actions.impersonateDisabledTitle": "Cannot impersonate an inactive user", + "actions.sending": "Sending…", + "actions.resendConfirmation": "Resend confirmation", + "actions.confirming": "Confirming…", + "actions.confirmEmail": "Confirm email", + "actions.deactivate": "Deactivate", + "actions.reactivate": "Reactivate", + "actions.delete": "Delete", + + "stat.role_one": "role", + "stat.role_other": "roles", + "stat.session_one": "session", + "stat.session_other": "sessions", + + "card.title": "Identity card", + "card.description": "Read-only here. Members update their own profile from settings.", + "field.username": "Username", + "field.email": "Email", + "field.firstName": "First name", + "field.lastName": "Last name", + "field.phone": "Phone", + "field.id": "ID", + + "roles.title": "Role assignment", + "roles.description": "Toggle which roles apply. Changes are staged until saved.", + "roles.pending_one": "{{count}} pending", + "roles.pending_other": "{{count}} pending", + "roles.discard": "Discard", + "roles.saving": "Saving…", + "roles.save": "Save changes", + "roles.emptyPre": "No roles defined.", + "roles.emptyLink": "Create one", + "roles.emptyPost": "to start assigning access.", + "roles.untitled": "Untitled role", + "roles.modified": "modified", + "roles.toggleAria": "Toggle {{name}}", + "roles.roleFallback": "role", + "roles.updated": "Roles updated", + "roles.updatedDesc_one": "{{count}} role changed.", + "roles.updatedDesc_other": "{{count}} roles changed.", + "roles.updateFailed": "Update failed", + + "status.deactivated": "User deactivated", + "status.reactivated": "User reactivated", + "status.changeFailed": "Status change failed", + "status.deactivateTitle": "Deactivate user?", + "status.reactivateTitle": "Reactivate user?", + "status.deactivateDesc": "{{name}} will not be able to sign in until reactivated. Existing sessions remain unless revoked.", + "status.reactivateDesc": "{{name}} will regain sign-in access immediately.", + "status.working": "Working…", + + "email.confirmed": "Email confirmed", + "email.confirmFailed": "Confirm email failed", + "email.resent": "Confirmation email sent", + "email.resendFailed": "Couldn't send confirmation email", + + "delete.deleted": "User deleted", + "delete.failed": "Delete failed", + "delete.title": "Delete this member", + "delete.descPre": "This permanently removes", + "delete.descPost": "They will lose access immediately. This cannot be undone.", + "delete.deleting": "Deleting…", + "delete.confirm": "Delete user", + + "sessions.revoked": "Session revoked", + "sessions.revokeFailed": "Revoke failed", + "sessions.revokedCount_one": "Revoked {{count}} session", + "sessions.revokedCount_other": "Revoked {{count}} sessions", + "sessions.revokeAllFailed": "Revoke-all failed", + "sessions.unknownBrowser": "Unknown browser", + "sessions.unknownOs": "Unknown OS", + "sessions.title": "Active sessions", + "sessions.loading": "Loading sessions…", + "sessions.summary": "{{active}} active · {{total}} total recorded", + "sessions.revokeAll": "Revoke all", + "sessions.empty": "No sessions on file. The user hasn't signed in recently.", + "sessions.badgeActive": "Active", + "sessions.badgeEnded": "Ended", + "sessions.lastSeen": "last seen {{date}}", + "sessions.started": "started {{date}}", + "sessions.revoking": "Revoking…", + "sessions.revoke": "Revoke", + "sessions.revokeAllTitle": "Revoke all sessions for {{name}}?", + "sessions.revokeAllDesc": "Every active session — desktop, mobile, browser tab — will be ended immediately. The user will need to sign in again on each device.", + "sessions.revokingAll": "Revoking…", + + "impersonate.started": "Impersonation started", + "impersonate.startedDesc": "You're now acting as this user. Use the banner to end.", + "impersonate.failed": "Impersonation failed", + "impersonate.title": "Impersonate {{name}}?", + "impersonate.description": "You'll act as this user across the dashboard. Every action you take will be attributed to them in audit logs (with your operator id preserved as the actor). End impersonation from the banner at the top of the page when you're done.", + "impersonate.reasonLabel": "Reason (optional, recorded in the audit log)", + "impersonate.reasonPlaceholder": "Investigating a bug report from this user…", + "impersonate.starting": "Starting…", + "impersonate.start": "Start impersonation", + + "field.name": "Name", + "field.description": "Description", + "field.password": "Password", + "field.confirm": "Confirm", + + "rolesList.title": "Roles", + "rolesList.description": "Roles bundle permissions into named bands of authority. Assign them from the user detail page.", + "rolesList.newRole": "New role", + "rolesList.searchPlaceholder": "Search by name or description…", + "rolesList.emptyFoundTitle": "No roles found", + "rolesList.emptyTitle": "No roles yet", + "rolesList.emptySearchBody": "Nothing matches \"{{term}}\". Try a different term or clear the search.", + "rolesList.emptyBody": "Create the first role to start grouping permissions. Without roles, members default to no access.", + "rolesList.clearSearch": "Clear search", + "rolesList.addRole": "Add role", + "rolesList.found_one": "{{count}} role found", + "rolesList.found_other": "{{count}} roles found", + "rolesList.colName": "Name", + "rolesList.colDescription": "Description", + "rolesList.colPermissions": "Permissions", + "rolesList.system": "System", + "rolesList.noDescription": "No description on file.", + "rolesList.openAria": "Open role {{name}}", + "rolesList.permNone": "—", + "rolesList.perm_one": "{{count}} permission", + "rolesList.perm_other": "{{count}} permissions", + "rolesList.createTitle": "Create a role", + "rolesList.createDescription": "Roles bundle permissions. After creating, you'll be taken to the editor to assign permissions and tune access.", + "rolesList.namePlaceholder": "Manager", + "rolesList.descHint": "Shown to admins when assigning roles. Plain English helps.", + "rolesList.descPlaceholder": "Can manage users and assign roles", + "rolesList.creating": "Creating…", + "rolesList.createSubmit": "Create role", + "rolesList.toastCreated": "Role created", + "rolesList.toastCreatedDesc": "Now configure {{name}}'s permissions.", + "rolesList.toastCreateFailed": "Create failed", + + "groupsList.title": "Groups", + "groupsList.description": "Groups bundle members and roles into reusable cohorts. Add a user to a group to grant every role attached to that group.", + "groupsList.newGroup": "New group", + "groupsList.searchPlaceholder": "Search by name or description…", + "groupsList.emptyFoundTitle": "No groups found", + "groupsList.emptyTitle": "No groups yet", + "groupsList.emptySearchBodyTerm": "Nothing matches \"{{term}}\". Try a different term.", + "groupsList.emptySearchBodyNoTerm": "No groups match the current filters.", + "groupsList.emptyBody": "Create the first group to bundle members and roles. Useful for teams, departments, or feature cohorts.", + "groupsList.clearSearch": "Clear search", + "groupsList.addGroup": "Add group", + "groupsList.found_one": "{{count}} group found", + "groupsList.found_other": "{{count}} groups found", + "groupsList.colGroup": "Group", + "groupsList.colComposition": "Composition", + "groupsList.colFlags": "Flags", + "groupsList.default": "Default", + "groupsList.system": "System", + "groupsList.noDescription": "No description on file.", + "groupsList.openAria": "Open group {{name}}", + "groupsList.member_one": "{{count}} member", + "groupsList.member_other": "{{count}} members", + "groupsList.role_one": "{{count}} role", + "groupsList.role_other": "{{count}} roles", + "groupsList.createTitle": "Create a group", + "groupsList.createDescription": "Groups bundle members and roles. After creating, you'll be taken to the editor to attach roles and add members.", + "groupsList.namePlaceholder": "Engineering", + "groupsList.descHint": "Helps admins understand what this cohort represents.", + "groupsList.descPlaceholder": "Engineers across web, mobile, and platform.", + "groupsList.defaultLabel": "Default group", + "groupsList.defaultHint": "Newly registered users join automatically.", + "groupsList.creating": "Creating…", + "groupsList.createSubmit": "Create group", + "groupsList.toastCreated": "Group created", + "groupsList.toastCreatedDesc": "Add members and roles next.", + "groupsList.toastCreateFailed": "Create failed", + + "usersList.title": "Users", + "usersList.description": "Every member with access to this tenant. Register newcomers, review status, and manage roles.", + "usersList.registerUser": "Register user", + "usersList.searchPlaceholder": "Search by name, username, or email…", + "usersList.filterAccountStatus": "Account status", + "usersList.filterAll": "All", + "usersList.filterActive": "Active", + "usersList.filterInactive": "Inactive", + "usersList.filterEmailStatus": "Email status", + "usersList.filterAnyEmail": "Any email", + "usersList.filterConfirmed": "Confirmed", + "usersList.filterPending": "Pending", + "usersList.filterRole": "Role", + "usersList.emptyFoundTitle": "No users found", + "usersList.emptyTitle": "No users yet", + "usersList.emptySearchBodyTerm": "Nothing matches \"{{term}}\". Try a different term or clear the filters.", + "usersList.emptySearchBodyNoTerm": "No users match the current filters.", + "usersList.emptyBody": "Register the first member to seed this tenant. They'll receive a confirmation email if email confirmation is enabled.", + "usersList.clearFilters": "Clear filters", + "usersList.found_one": "{{count}} user found", + "usersList.found_other": "{{count}} users found", + "usersList.colName": "Name", + "usersList.colUsername": "Username", + "usersList.colStatus": "Status", + "usersList.noEmail": "no email", + "usersList.noEmailFile": "no email on file", + "usersList.openAria": "Open user {{name}}", + "usersList.badgeConfirmed": "Confirmed", + "usersList.badgePending": "Pending", + "usersList.registerTitle": "Register a member", + "usersList.registerDescription": "Add a new user to this tenant. Username and email must be unique. Passwords need an uppercase letter, lowercase letter, and a digit.", + "usersList.firstPlaceholder": "Ada", + "usersList.lastPlaceholder": "Lovelace", + "usersList.usernameHint": "Used at sign-in. Lowercase, no spaces.", + "usersList.usernamePlaceholder": "ada.lovelace", + "usersList.emailPlaceholder": "ada@example.com", + "usersList.phoneHint": "Optional contact number.", + "usersList.phonePlaceholder": "+44 …", + "usersList.confirmMismatch": "Passwords don't match.", + "usersList.registering": "Registering…", + "usersList.registerSubmit": "Register user", + "usersList.toastRegistered": "User registered", + "usersList.toastRegisteredDesc": "An email confirmation may be required before sign-in.", + "usersList.toastRegisterFailed": "Registration failed", + + "roleDetail.back": "Back to roles", + "roleDetail.notFound": "Role not found.", + "roleDetail.catalogError": "Couldn't load the permission catalog: {{error}}", + "roleDetail.system": "System", + "roleDetail.subtitleSystem": "Built-in role managed by the framework.", + "roleDetail.subtitleCustom": "Custom role.", + "roleDetail.deleteRole": "Delete role", + "roleDetail.deleteSystemTitle": "System roles cannot be deleted.", + "roleDetail.statPermissions": "permissions", + "roleDetail.readOnlyTitle": "Built-in role — read only", + "roleDetail.readOnlyBody": " ships with the framework. Its name, description, and permissions are managed centrally so the seed contract and the runtime permission syncer stay in agreement. Create a custom role if you need a different set of grants.", + "roleDetail.detailsTitle": "Role details", + "roleDetail.detailsDescription": "The display label and a one-line summary admins will see at assignment time.", + "roleDetail.descPlaceholder": "Short description for this role", + "roleDetail.permsTitle": "Permissions", + "roleDetail.permsDescription": "Search, filter, and toggle individual permissions — or seed a sensible default from a preset. Some root-level permissions may be filtered server-side.", + "roleDetail.presetBasic": "Basic", + "roleDetail.presetAll": "All", + "roleDetail.presetClear": "Clear", + "roleDetail.unsaved": "Unsaved changes", + "roleDetail.allSaved": "All changes saved", + "roleDetail.discard": "Discard", + "roleDetail.saving": "Saving…", + "roleDetail.saveChanges": "Save changes", + "roleDetail.searchPlaceholder": "Search by resource, action, or description…", + "roleDetail.searchAria": "Search permissions", + "roleDetail.clearSearch": "Clear search", + "roleDetail.filterAll": "All", + "roleDetail.filterEnabled": "Enabled", + "roleDetail.filterModified": "Modified", + "roleDetail.filterBasic": "Basic", + "roleDetail.enabled": "enabled", + "roleDetail.modified": "{{n}} modified", + "roleDetail.showingMatches_one": "showing {{count}} match", + "roleDetail.showingMatches_other": "showing {{count}} matches", + "roleDetail.expandAll": "Expand all", + "roleDetail.collapseAll": "Collapse all", + "roleDetail.noMatchTitle": "No permissions match", + "roleDetail.noMatchBody": "Try a different term or clear the current filter.", + "roleDetail.resetFilters": "Reset filters", + "roleDetail.toggleAllAria": "Toggle all {{resource}}", + "roleDetail.changed": "{{n}} changed", + "roleDetail.changedTitle_one": "{{count}} unsaved change", + "roleDetail.changedTitle_other": "{{count}} unsaved changes", + "roleDetail.groupMatches_one": "· {{count}} match", + "roleDetail.groupMatches_other": "· {{count}} matches", + "roleDetail.groupAll": "All", + "roleDetail.groupNone": "None", + "roleDetail.rootBadge": "root", + "roleDetail.rootTitle": "Root-level permission. May be filtered server-side.", + "roleDetail.basicBadge": "basic", + "roleDetail.modifiedAria": "modified", + "roleDetail.deleteTitle": "Delete role", + "roleDetail.deleteBodyPre": "This permanently removes ", + "roleDetail.deleteBodyPost": ". Members currently assigned will lose its permissions immediately.", + "roleDetail.deleting": "Deleting…", + "roleDetail.deleteConfirm": "Delete role", + "roleDetail.toastUpdateFailed": "Update failed", + "roleDetail.toastPermsFailed": "Permissions update failed", + "roleDetail.toastDeleted": "Role deleted", + "roleDetail.toastDeleteFailed": "Delete failed", + "roleDetail.toastSaved": "Role saved", + + "groupDetail.back": "Back to groups", + "groupDetail.notFound": "Group not found.", + "groupDetail.default": "Default", + "groupDetail.system": "System", + "groupDetail.subtitle": "Group cohort.", + "groupDetail.deleteGroup": "Delete group", + "groupDetail.memberLabel_one": "member", + "groupDetail.memberLabel_other": "members", + "groupDetail.roleLabel_one": "role", + "groupDetail.roleLabel_other": "roles", + "groupDetail.detailsTitle": "Group details", + "groupDetail.detailsDescription": "Name, description, and the roles attached to this group.", + "groupDetail.discard": "Discard", + "groupDetail.saving": "Saving…", + "groupDetail.saveChanges": "Save changes", + "groupDetail.descPlaceholder": "Short summary of who or what this group represents", + "groupDetail.defaultLabel": "Default group", + "groupDetail.defaultHint": "Auto-assign to newly registered users.", + "groupDetail.rolesAttached": "Roles attached", + "groupDetail.noRolesPre": "No roles defined. ", + "groupDetail.noRolesLink": "Create one", + "groupDetail.noRolesPost": " first.", + "groupDetail.attachAria": "Attach {{name}}", + "groupDetail.membersTitle": "Members", + "groupDetail.membersDescription": "Users who belong to this group inherit every role attached above.", + "groupDetail.addMembers": "Add members", + "groupDetail.emptyMembersPre": "No one in this group yet. Click ", + "groupDetail.emptyMembersStrong": "Add members", + "groupDetail.emptyMembersPost": " to attach users.", + "groupDetail.remove": "Remove", + "groupDetail.unknownUser": "Unknown user", + "groupDetail.deleteTitle": "Delete group", + "groupDetail.deleteBodyPost": " will be removed. Members will lose any permissions inherited through this group.", + "groupDetail.deleting": "Deleting…", + "groupDetail.deleteConfirm": "Delete group", + "groupDetail.toastUpdated": "Group updated", + "groupDetail.toastUpdateFailed": "Update failed", + "groupDetail.toastDeleted": "Group deleted", + "groupDetail.toastDeleteFailed": "Delete failed", + "groupDetail.toastMemberRemoved": "Member removed", + "groupDetail.toastRemoveFailed": "Remove failed", + "groupDetail.pickTitle": "Pick members to add", + "groupDetail.pickDescription": "Search active users and select one or more to attach to this group.", + "groupDetail.searchPlaceholder": "Search by name, username, or email…", + "groupDetail.clearSearch": "Clear search", + "groupDetail.noMatch": "No users match \"{{term}}\".", + "groupDetail.noUsers": "No users available.", + "groupDetail.alreadyIn": "already in", + "groupDetail.selected": "{{count}} selected", + "groupDetail.adding": "Adding…", + "groupDetail.addN": "Add {{count}}", + "groupDetail.toastAdded_one": "Added {{count}} member", + "groupDetail.toastAdded_other": "Added {{count}} members", + "groupDetail.toastAddedDupes": " · {{count}} already present", + "groupDetail.toastAddFailed": "Add failed" +} diff --git a/clients/dashboard/src/locales/en-US/notifications.json b/clients/dashboard/src/locales/en-US/notifications.json new file mode 100644 index 0000000000..0b4f3b223f --- /dev/null +++ b/clients/dashboard/src/locales/en-US/notifications.json @@ -0,0 +1,32 @@ +{ + "bell.aria": "Notifications", + "bell.ariaUnread": "Notifications, {{count}} unread", + "bell.title": "Notifications", + "header.title": "Notifications", + "header.allCaughtUp": "All caught up", + "header.summary": "{{unread}} unread · {{loaded}} loaded", + "markAllRead": "Mark all read", + "recent": "Recent", + "loading": "Loading…", + "empty": "Nothing yet. Mentions and channel updates will appear here.", + "settings": "Settings ↗", + "rel.justNow": "just now", + "rel.minutes": "{{n}}m", + "rel.hours": "{{n}}h", + "rel.days": "{{n}}d", + "rel.weeks": "{{n}}w", + "toast.channelFallback": "channel", + "toast.aChannel": "a channel", + "toast.directMessage": "direct message", + "toast.groupChat": "group chat", + "toast.justNow": "just now", + "toast.openConversation": "Open conversation", + "toast.emptyBody": "(attachment or empty body)", + "toast.dismissAria": "Dismiss notification", + "toast.dismiss": "Dismiss", + "badge.chat": "Chat", + "badge.ariaUnread_one": "Chat, {{count}} unread message", + "badge.ariaUnread_other": "Chat, {{count}} unread messages", + "badge.titleUnread_one": "{{count}} unread chat message", + "badge.titleUnread_other": "{{count}} unread chat messages" +} diff --git a/clients/dashboard/src/locales/en-US/overview.json b/clients/dashboard/src/locales/en-US/overview.json new file mode 100644 index 0000000000..9b759ef4af --- /dev/null +++ b/clients/dashboard/src/locales/en-US/overview.json @@ -0,0 +1,108 @@ +{ + "greeting.morning": "Good morning, {{name}}", + "greeting.afternoon": "Good afternoon, {{name}}", + "greeting.evening": "Good evening, {{name}}", + "operator": "operator", + "yourTenant": "your tenant", + "refresh": "Refresh", + "viewActivity": "View activity", + "viewAudits": "View audits", + "open": "Open", + "seeAll": "See all", + + "stat.plan": "Plan", + "stat.validFor": "Valid for", + "stat.resources": "Resources", + "stat.liveEvents": "Live events", + "stat.unavailable": "Unavailable", + "stat.noSubscription": "No subscription", + "stat.status.Active": "Active", + "stat.status.Suspended": "Suspended", + "stat.status.Cancelled": "Cancelled", + + "connState.idle": "idle", + "connState.connecting": "connecting", + "connState.connected": "connected", + "connState.reconnecting": "reconnecting", + "connState.error": "error", + + "validity.expired": "Expired", + "validity.openEnded": "Open-ended", + "validity.days": "days", + "validity.statusUnavailable": "Status unavailable", + "validity.renew": "Contact your operator to renew", + "validity.graceEnds": "grace ends {{date}}", + "validity.inGracePeriod": "in grace period", + "validity.until": "until {{date}}", + "validity.noEndDate": "no end date", + + "resources.avgUtilization": "avg utilization", + "resources.overage": "overage", + + "subscription.loadErrorTitle": "Couldn't load subscription", + "subscription.loadErrorBody": "The subscription endpoint returned an error. Try refreshing.", + "subscription.noneTitle": "No active subscription", + "subscription.noneBody": "Pick a plan to enable billing, quotas, and overage tracking.", + "subscription.viewCta": "View subscription", + "subscription.started": "Started", + "subscription.ends": "Ends", + "subscription.openEnded": "open-ended", + "subscription.currentTerm": "Current term", + "subscription.termProgress": "{{pct}}% · {{days}}d left", + + "system.streamLive": "Stream live", + "system.offline": "offline", + "system.liveDesc": "Backend events are flowing in real time.", + "system.erroredDesc": "Stream disconnected. Events will queue once it recovers.", + "system.waitingDesc": "Waiting for the stream to come online.", + "system.eventsThisSession": "Events this session", + + "audits.emptyTitle": "No recent activity", + "audits.emptyBody": "Audited actions in the last 24 hours will appear here.", + "audits.eventFallback": "Event", + "audits.systemActor": "system", + "audits.ago": "{{value}} ago", + + "quick.inviteUsers.title": "Invite users", + "quick.inviteUsers.description": "Add teammates, assign roles.", + "quick.browseCatalog.title": "Browse catalog", + "quick.browseCatalog.description": "Products, brands, categories.", + "quick.subscription.title": "Subscription", + "quick.subscription.description": "Plan, usage, invoices.", + "quick.liveActivity.title": "Live activity", + "quick.liveActivity.description": "Real-time event stream.", + + "liveFeed.emptyTitle": "Listening for activity", + "liveFeed.emptyBody": "Events will appear here as the backend publishes them.", + + "firstRun.dismissAria": "Dismiss setup checklist", + "firstRun.skipTitle": "Skip for now", + "firstRun.welcome": "Welcome to {{name}}", + "firstRun.subtitle": "Your tenant is provisioned and ready. Here's where most teams start.", + "firstRun.step": "Step {{step}}", + "firstRun.tile.plan.title": "Pick a plan", + "firstRun.tile.plan.description": "Enable billing, quotas, and overage tracking.", + "firstRun.tile.team.title": "Invite your team", + "firstRun.tile.team.description": "Add teammates, assign roles, and group them.", + "firstRun.tile.catalog.title": "Browse catalog", + "firstRun.tile.catalog.description": "Sample products, brands, categories ready to go.", + "firstRun.tile.watch.title": "Watch live", + "firstRun.tile.watch.description": "SSE stream right into the dashboard.", + + "section.subscription": "Subscription", + "section.systemStatus": "System status", + "section.recentAudits": "Recent audits", + "section.recentAuditsDesc": "Last 24 hours, top 5 events.", + "section.usage": "Usage by resource", + "section.usageDesc": "Current-month consumption against plan limits.", + "section.quickActions": "Quick actions", + "section.quickActionsDesc": "Jump into the most-used destinations.", + "section.liveFeed": "Live feed", + "section.liveFeedDesc": "Real-time backend events over SSE.", + + "usage.errorTitle": "Couldn't load usage", + "usage.errorBody": "The usage endpoint returned an error. Try refreshing.", + "usage.emptyTitle": "No usage captured yet", + "usage.emptyBody": "Activity will appear here as the backend records snapshots for this period.", + "usageOverageBadge": "overage" +} diff --git a/clients/dashboard/src/locales/en-US/settings.json b/clients/dashboard/src/locales/en-US/settings.json new file mode 100644 index 0000000000..5b44d4b8ab --- /dev/null +++ b/clients/dashboard/src/locales/en-US/settings.json @@ -0,0 +1,219 @@ +{ + "title": "Settings", + "sections": "Sections", + "sectionsNav": "Settings sections", + "tab.profile.label": "Profile", + "tab.profile.hint": "Your identity across the tenant", + "tab.security.label": "Security", + "tab.security.hint": "Password and active sessions", + "tab.appearance.label": "Appearance", + "tab.appearance.hint": "Theme and visual preferences", + "tab.branding.label": "Branding", + "tab.branding.hint": "Tenant colours and logos", + "tab.notifications.label": "Notifications", + "tab.notifications.hint": "How we reach you", + "tab.apiKeys.label": "API keys", + "tab.apiKeys.hint": "Personal access tokens", + + "profile.toastSaved": "Profile saved", + "profile.saveError": "Failed to save profile", + "profile.toastSaveFail": "Save failed", + "profile.imageUpdated": "Profile image updated", + "profile.imageUpdateError": "Failed to update profile image", + "profile.loadError": "Couldn't load your profile. Showing details from your session; saved changes may not reflect the latest server state.", + "profile.photo.title": "Photo", + "profile.photo.description": "Shown in the topbar and on your activity. Square crops work best — JPG, PNG, or WebP.", + "profile.identity.title": "Identity", + "profile.identity.description": "Your name and contact details, visible across the dashboard.", + "profile.reset": "Reset", + "profile.saving": "Saving…", + "profile.save": "Save changes", + "profile.field.firstName": "First name", + "profile.field.lastName": "Last name", + "profile.field.email": "Email", + "profile.field.phone": "Phone", + "profile.emailHint": "Contact your tenant admin to change your sign-in email.", + "profile.phonePlaceholder": "+1 (555) 123-4567", + "profile.subject.title": "Subject identifier", + "profile.subject.description": "The unique ID this account uses inside the platform. Read-only.", + + "notifications.title": "Notification preferences", + "notifications.description": "Choose which events you want delivered as email or in-app notifications.", + "notifications.emptyTitle": "Per-event preferences aren't tunable yet.", + "notifications.emptyBody": "In-app notifications are already delivered to your bell — open it from the top bar to see them. Granular per-event email opt-ins are on the v1.1 roadmap. In the meantime, your tenant admin can disable email delivery globally.", + "notifications.openBell": "Open notifications bell", + + "apiKeys.title": "API keys", + "apiKeys.description": "Long-lived credentials used by services to call the FSH API. Treat them like passwords.", + "apiKeys.emptyTitle": "API keys aren't available yet.", + "apiKeys.emptyBody": "Personal access tokens and service-to-service API keys are on the v1.1 roadmap. For now, your access flows through the user-bound JWT issued at sign-in. Track progress on the public roadmap or watch the next release notes.", + "apiKeys.viewRoadmap": "View roadmap", + + "appearance.theme.title": "Theme", + "appearance.theme.description": "Pick a colour mode for the dashboard. System follows your OS.", + "appearance.theme.light": "Light", + "appearance.theme.lightBlurb": "Bright canvas, day-shift comfort.", + "appearance.theme.system": "System", + "appearance.theme.systemBlurb": "Follow the OS preference.", + "appearance.theme.dark": "Dark", + "appearance.theme.darkBlurb": "Reduced glare for long sessions.", + "appearance.theme.ariaLabel": "{{label}} theme", + "appearance.active": "Active", + "appearance.accent.title": "Accent", + "appearance.accent.description": "Pick the brand colour used for primary actions, charts, and highlights across the dashboard.", + "appearance.accent.ariaLabel": "{{label}} accent", + "appearance.font.title": "Font", + "appearance.font.description": "The UI typeface. Mono code blocks always use JetBrains Mono.", + "appearance.font.ariaLabel": "{{label}} font", + "appearance.density.title": "Density", + "appearance.density.description": "Compact mode reduces card padding and row height for data-dense screens.", + "appearance.density.toggle": "Use compact spacing across the dashboard.", + "appearance.density.ariaLabel": "Compact density", + "appearance.motion.title": "Motion", + "appearance.motion.descPre": "Override the system", + "appearance.motion.descPost": "setting.", + "appearance.motion.toggle": "Disable transitions and decorative animations.", + "appearance.motion.ariaLabel": "Reduce motion", + "appearance.custom.ariaLabel": "Custom accent", + "appearance.custom.title": "Custom accent — click to edit", + "appearance.custom.label": "Custom", + "appearance.custom.dialogTitle": "Pick your brand colour", + "appearance.custom.dialogDescription": "Drag the hue ribbon to recolour the accent. Saturation scales chroma uniformly across the eleven brand stops.", + "appearance.custom.hue": "Hue", + "appearance.custom.saturation": "Saturation", + "appearance.custom.brandLadder": "Brand ladder", + "appearance.custom.preview": "Preview", + "appearance.custom.previewSubscription": "Subscription", + "appearance.custom.previewActive": "active", + "appearance.custom.primaryAction": "Primary action", + "appearance.custom.cancel": "Cancel", + "appearance.custom.apply": "Apply accent", + + "security.sessions.unknownBrowser": "Unknown browser", + "security.sessions.unknownOs": "Unknown OS", + "security.sessions.unknownIp": "unknown ip", + "security.sessions.toastRevoked": "Session revoked", + "security.sessions.revokeError": "Could not revoke session.", + "security.sessions.toastRevokedCount_one": "Revoked {{count}} session", + "security.sessions.toastRevokedCount_other": "Revoked {{count}} sessions", + "security.sessions.revokeAllError": "Could not revoke sessions.", + "security.sessions.loadError": "Failed to load sessions.", + "security.sessions.title": "Active sessions", + "security.sessions.activeCount_one": "{{count}} active", + "security.sessions.activeCount_other": "{{count}} active", + "security.sessions.description": "Browsers and devices currently signed in to your account.", + "security.sessions.signOutOthers": "Sign out everywhere else", + "security.sessions.emptyTitle": "No sessions tracked", + "security.sessions.emptyBody": "Session activity will appear here once you sign in from any device.", + "security.sessions.thisDevice": "this device", + "security.sessions.revokedBadge": "revoked", + "security.sessions.meta": "{{ip}} · last activity {{lastActivity}} · expires {{expires}}", + "security.sessions.revoking": "Revoking…", + "security.sessions.revoke": "Revoke", + + "security.password.title": "Password", + "security.password.description": "Used to sign in to this tenant. Choose a strong, unique password.", + "security.password.recommendation": "We recommend a passphrase of 16+ characters with no reuse from other services.", + "security.password.change": "Change password", + "security.password.hide": "Hide password", + "security.password.show": "Show password", + "security.password.toastSuccess": "Password changed", + "security.password.toastSuccessDesc": "Other active sessions remain valid until you revoke them.", + "security.password.changeError": "Could not change password.", + "security.password.dialogDescription": "Pick a strong password you don't reuse elsewhere. Your other signed-in sessions stay active — revoke them below if you want them out.", + "security.password.current": "Current password", + "security.password.new": "New password", + "security.password.confirm": "Confirm new password", + "security.password.match": "Passwords match", + "security.password.mismatchYet": "Passwords don't match yet", + "security.password.cancel": "Cancel", + "security.password.updating": "Updating…", + "security.password.update": "Update password", + + "security.validation.min8": "New password must be at least 8 characters.", + "security.validation.mismatch": "Passwords don't match.", + "security.validation.mustDiffer": "New password must differ from the current one.", + + "security.strength.weak": "Weak", + "security.strength.fair": "Fair", + "security.strength.good": "Good", + "security.strength.strong": "Strong", + + "security.twoFa.title": "Two-factor authentication", + "security.twoFa.badgeOn": "enabled", + "security.twoFa.badgeOff": "disabled", + "security.twoFa.description": "Require a one-time code from an authenticator app on every sign-in.", + "security.twoFa.enrollFailed": "Enrollment failed", + "security.twoFa.enrollStartError": "Could not start enrollment.", + "security.twoFa.enabledToast": "Two-factor enabled", + "security.twoFa.enabledToastDesc": "Future logins require a 6-digit code from your authenticator.", + "security.twoFa.verifyFailed": "Verification failed", + "security.twoFa.verifyMismatch": "That code didn't match. Try again.", + "security.twoFa.verifyError": "Could not verify code.", + "security.twoFa.generating": "Generating…", + "security.twoFa.enable": "Enable two-factor", + "security.twoFa.enrollHint": "You'll scan a QR code in your authenticator app (1Password, Google Authenticator, Authy, …).", + "security.twoFa.qrAlt": "Two-factor QR code", + "security.twoFa.rendering": "Rendering…", + "security.twoFa.manualEntry": "can't scan? enter manually", + "security.twoFa.copied": "copied", + "security.twoFa.copy": "copy", + "security.twoFa.codeLabel": "6-digit code from your app", + "security.twoFa.codePlaceholder": "123 456", + "security.twoFa.verifying": "Verifying…", + "security.twoFa.confirmEnable": "Confirm & enable", + "security.twoFa.cancel": "Cancel", + "security.twoFa.disabledToast": "Two-factor disabled", + "security.twoFa.disableFailed": "Disable failed", + "security.twoFa.disableWrongPassword": "Password verification failed.", + "security.twoFa.disableError": "Could not disable two-factor.", + "security.twoFa.disableDescription": "Two-factor is currently enabled. Confirm your password to disable — this rotates the authenticator secret, so a fresh enroll will generate a new QR code.", + "security.twoFa.disabling": "Disabling…", + "security.twoFa.disable": "Disable two-factor", + + "branding.title": "Branding", + "branding.description": "Theme tokens consumed by your tenant's apps on sign-in. Live preview reflects the primary action with the chosen palette.", + "branding.loadingDescription": "Loading branding…", + "branding.loading": "Loading branding", + "branding.toastSaved": "Branding saved", + "branding.saveFailed": "Save failed", + "branding.toastReset": "Branding reset to defaults", + "branding.resetFailed": "Reset failed", + "branding.unknownError": "Unknown error", + "branding.badgeDefault": "default", + "branding.badgeUnsaved": "unsaved", + "branding.resetAriaLabel": "Reset branding to defaults", + "branding.resetting": "Resetting…", + "branding.resetToDefaults": "Reset to defaults", + "branding.saving": "Saving…", + "branding.save": "Save branding", + "branding.lightPreview": "Light preview", + "branding.lightPalette": "Light palette", + "branding.darkPalette": "Dark palette", + "branding.resetPalette": "Reset palette", + "branding.pickColor": "Pick {{label}} color", + "branding.colorAriaLabel": "{{label}} color", + "branding.field.primary": "Primary", + "branding.field.secondary": "Secondary", + "branding.field.tertiary": "Tertiary", + "branding.field.background": "Background", + "branding.field.surface": "Surface", + "branding.field.error": "Error", + "branding.field.warning": "Warning", + "branding.field.success": "Success", + "branding.field.info": "Info", + "branding.assets.title": "Brand assets", + "branding.assets.description": "URLs to your hosted brand assets. Upload via the Files module first, then paste the resulting public URL here.", + "branding.assets.logo": "Logo URL", + "branding.assets.logoDark": "Logo URL (dark mode)", + "branding.assets.favicon": "Favicon URL", + "branding.assets.placeholder": "https://cdn.example.com/logo.svg", + "branding.preview.live": "live", + "branding.preview.samplePage": "Sample tenant page", + "branding.preview.active": "active", + "branding.preview.body": "A short paragraph rendered with the chosen body color over the chosen surface, on the chosen page background. Action buttons use the primary token.", + "branding.preview.primaryAction": "Primary action", + "branding.preview.secondary": "Secondary", + "branding.preview.warn": "warn", + "branding.preview.error": "error" +} diff --git a/clients/dashboard/src/locales/en-US/subscription.json b/clients/dashboard/src/locales/en-US/subscription.json new file mode 100644 index 0000000000..678f89cf82 --- /dev/null +++ b/clients/dashboard/src/locales/en-US/subscription.json @@ -0,0 +1,125 @@ +{ + "title": "Subscription", + "description": "Your tenant's plan, validity, usage, and recent invoices.", + + "plan.title": "Plan", + "plan.noneTitle": "No active subscription", + "plan.noneBody": "Your tenant has no plan assigned. Contact your operator to enable billing, quotas, and overage tracking.", + "plan.started": "Started", + "plan.ends": "Ends", + "plan.openEnded": "open-ended", + "plan.changesNote": "Plan changes are operator-driven. Contact your operator to upgrade, renew, or cancel.", + "plan.status.Active": "Active", + "plan.status.Suspended": "Suspended", + "plan.status.Cancelled": "Cancelled", + + "validity.title": "Validity", + "validity.unavailable": "Tenant status is unavailable right now.", + "validity.inactive": "Inactive", + "validity.validUntil": "Valid until", + "validity.graceEnds": "Grace ends", + "validity.state.inGrace": "In grace", + "validity.state.expired": "Expired", + "validity.state.active": "Active", + "validity.state.unknown": "Unknown", + + "usage.title": "Usage by resource", + "usage.description": "Current-month consumption against your plan limits.", + "usage.loadError": "Couldn't load usage. Try refreshing.", + "usage.emptyTitle": "No usage captured yet", + "usage.emptyBody": "Consumption will appear here as snapshots are recorded for this period.", + + "recent.title": "Recent invoices", + "recent.description": "Your five most recent invoices.", + "recent.seeAll": "See all", + "recent.loadError": "Couldn't load invoices. Try refreshing.", + + "invoices.title": "Invoices", + "invoices.unit": "invoice", + "invoices.description": "Your tenant's billing history, newest first.", + "invoices.searchPlaceholder": "Search by invoice number, status, or period…", + "invoices.loadError": "Failed to load invoices.", + "invoices.clearSearch": "Clear search", + "invoices.empty.foundTitle": "No invoices found", + "invoices.empty.emptyTitle": "No invoices yet", + "invoices.empty.foundBody": "Nothing matches \"{{term}}\". Try a different term or clear the search.", + "invoices.empty.emptyBody": "Once your tenant has been billed for a period, invoices will appear here.", + "invoices.matched_one": "{{count}} invoice matched on this page", + "invoices.matched_other": "{{count}} invoices matched on this page", + "invoices.showing_one": "Showing {{shown}} of {{count}} invoice", + "invoices.showing_other": "Showing {{shown}} of {{count}} invoices", + "invoices.pageSuffix": " · page {{page}} of {{totalPages}}", + "invoices.col.number": "Invoice #", + "invoices.col.customer": "Customer", + "invoices.col.amount": "Amount", + "invoices.col.status": "Status", + "invoices.col.dueDate": "Due date", + "invoices.period": "period {{period}}", + "invoices.due": "due {{date}}", + "invoices.paidOn": "paid {{date}}", + "invoices.status.Draft": "Draft", + "invoices.status.Issued": "Issued", + "invoices.status.Paid": "Paid", + "invoices.status.Void": "Void", + + "wallet.title": "WhatsApp wallet", + "wallet.description": "Your prepaid balance for WhatsApp messaging. Request a top-up and we'll raise an invoice to credit your wallet.", + "wallet.currentBalance": "Current balance", + "wallet.lowEmpty": "Your wallet is empty. Request a top-up to keep sending WhatsApp messages.", + "wallet.lowSoon": "Your balance is running low. Consider requesting a top-up soon.", + "wallet.requestTitle": "Request a top-up", + "wallet.amountLabel": "Amount", + "wallet.amountHint": "How much to add to your wallet, in {{currency}}.", + "wallet.amountPlaceholder": "100.00", + "wallet.noteLabel": "Note", + "wallet.noteHint": "Optional — anything we should know about this top-up.", + "wallet.notePlaceholder": "e.g. budget for the June campaign", + "wallet.submitting": "Submitting…", + "wallet.submit": "Request top-up", + "wallet.toastRequested": "Top-up requested", + "wallet.toastRequestedDesc": "We'll raise an invoice to credit your wallet shortly.", + "wallet.toastFailed": "Top-up request failed", + "wallet.requestsTitle": "My top-up requests", + "wallet.emptyTitle": "No top-up requests yet", + "wallet.emptyBody": "Once you request a top-up it will appear here with its status — pending, invoiced, or completed.", + "wallet.col.requested": "Requested", + "wallet.col.amount": "Amount", + "wallet.col.status": "Status", + "wallet.col.invoice": "Invoice", + "wallet.invoiceLink": "Invoice", + "wallet.viewInvoice": "View invoice", + "wallet.status.Pending": "Pending", + "wallet.status.Invoiced": "Invoiced", + "wallet.status.Completed": "Completed", + "wallet.status.Rejected": "Rejected", + "wallet.status.Cancelled": "Cancelled", + + "invoiceDetail.back": "Back to invoices", + "invoiceDetail.downloadFailed": "Download failed", + "invoiceDetail.periodLabel": "Period {{period}}", + "invoiceDetail.preparing": "Preparing…", + "invoiceDetail.downloadPdf": "Download PDF", + "invoiceDetail.lineItems": "Line items", + "invoiceDetail.details": "Details", + "invoiceDetail.notes": "Notes", + "invoiceDetail.noLineItems": "No line items", + "invoiceDetail.noLineItemsBody": "This invoice has no itemized charges.", + "invoiceDetail.col.description": "Description", + "invoiceDetail.col.qty": "Qty", + "invoiceDetail.col.unitPrice": "Unit price", + "invoiceDetail.col.amount": "Amount", + "invoiceDetail.total": "Total", + "invoiceDetail.row.status": "Status", + "invoiceDetail.row.currency": "Currency", + "invoiceDetail.row.period": "Period", + "invoiceDetail.row.created": "Created", + "invoiceDetail.row.issued": "Issued", + "invoiceDetail.row.due": "Due", + "invoiceDetail.row.paid": "Paid", + "invoiceDetail.row.voided": "Voided", + "invoiceDetail.row.periodStart": "Period start", + "invoiceDetail.row.periodEnd": "Period end", + "invoiceDetail.notFoundTitle": "Invoice not found", + "invoiceDetail.notFoundBody": "It may not belong to your tenant, or the link may be wrong.", + "invoiceDetail.notFoundBack": "Back to invoices" +} diff --git a/clients/dashboard/src/locales/en-US/system.json b/clients/dashboard/src/locales/en-US/system.json new file mode 100644 index 0000000000..94a62ae918 --- /dev/null +++ b/clients/dashboard/src/locales/en-US/system.json @@ -0,0 +1,80 @@ +{ + "sessions.title": "Active sessions", + "sessions.description": "Every browser and device currently signed in to {{tenant}}. Refreshes every 30 seconds.", + "sessions.thisTenant": "this tenant", + "sessions.refresh": "Refresh", + "sessions.searchPlaceholder": "Find by user, email, or IP…", + "sessions.filter.visibility": "Visibility", + "sessions.filter.liveOnly": "Live only", + "sessions.filter.includeInactive": "Include inactive", + "sessions.stats.active": "{{n}} active", + "sessions.stats.users": "{{n}} users", + "sessions.stats.mobile": "{{n}} mobile", + "sessions.empty.foundTitle": "No sessions found", + "sessions.empty.activeTitle": "No active sessions", + "sessions.empty.searchBody": "Nothing matches \"{{term}}\". Try a different term, or toggle Include inactive to see expired sessions.", + "sessions.empty.body": "Sessions appear when users sign in. Toggle Include inactive to see expired and revoked sessions.", + "sessions.empty.clearSearch": "Clear search", + "sessions.empty.refresh": "Refresh", + "sessions.found_one": "{{count}} session found", + "sessions.found_other": "{{count}} sessions found", + "sessions.col.user": "User", + "sessions.col.device": "Device", + "sessions.col.ip": "IP", + "sessions.col.lastActivity": "Last activity", + "sessions.col.actions": "Actions", + "sessions.badge.you": "You", + "sessions.badge.inactive": "Inactive", + "sessions.unknownUser": "Unknown user", + "sessions.unknownBrowser": "Unknown browser", + "sessions.allDevices": "All devices", + "sessions.all": "All", + "sessions.revoke": "Revoke", + "sessions.revoking": "Revoking…", + "sessions.revokeAllTitle": "Sign this user out of all devices", + "sessions.toast.revoked": "Session revoked", + "sessions.toast.revokeError": "Could not revoke session.", + "sessions.toast.revokedAll_one": "Revoked {{count}} session", + "sessions.toast.revokedAll_other": "Revoked {{count}} sessions", + "sessions.toast.revokeAllError": "Could not revoke sessions.", + "trash.title": "Recycle bin", + "trash.description": "Soft-deleted records, kept indefinitely until you restore them. Restoring a row brings it back to its parent list with the same ID and history intact.", + "trash.noAccessTitle": "No recycle bins available", + "trash.noAccessBody": "You don't have permission to restore deleted records in this tenant. Ask an administrator if you think you should.", + "trash.sections": "Trash sections", + "trash.tab.products": "Products", + "trash.tab.brands": "Brands", + "trash.tab.categories": "Categories", + "trash.tab.tickets": "Tickets", + "trash.tab.files": "Files", + "trash.count": "{{n}} {{entity}} in trash", + "trash.emptyTitle": "The {{entity}} trash is empty", + "trash.emptyBody": "Soft-deleted {{entity}} land here for as long as you want — no automatic purge. Anything you remove from the main list can be recovered.", + "trash.backTo": "Back to {{entity}}", + "trash.col.entity": "Entity", + "trash.col.deletedBy": "Deleted by", + "trash.col.deletedAt": "Deleted at", + "trash.col.actions": "Actions", + "trash.action.restore": "Restore", + "trash.action.restoring": "Restoring…", + "trash.by": "by {{id}}…", + "trash.restore.title": "Restore {{entity}}?", + "trash.restore.body": "will be moved back to its {{entity}} list with the same ID and history intact.", + "trash.restore.cancel": "Cancel", + "trash.restore.confirm": "Restore {{entity}}", + "trash.entityPlural.products": "products", + "trash.entityPlural.brands": "brands", + "trash.entityPlural.categories": "categories", + "trash.entityPlural.tickets": "tickets", + "trash.entityPlural.files": "files", + "trash.entitySingular.products": "product", + "trash.entitySingular.brands": "brand", + "trash.entitySingular.categories": "category", + "trash.entitySingular.tickets": "ticket", + "trash.entitySingular.files": "file", + "trash.toast.products": "Product restored", + "trash.toast.brands": "Brand restored", + "trash.toast.categories": "Category restored", + "trash.toast.tickets": "Ticket restored", + "trash.toast.files": "File restored" +} diff --git a/clients/dashboard/src/locales/en-US/tickets.json b/clients/dashboard/src/locales/en-US/tickets.json new file mode 100644 index 0000000000..630066488d --- /dev/null +++ b/clients/dashboard/src/locales/en-US/tickets.json @@ -0,0 +1,119 @@ +{ + "status.Open": "Open", + "status.InProgress": "In progress", + "status.Resolved": "Resolved", + "status.Closed": "Closed", + "priority.Low": "Low", + "priority.Medium": "Medium", + "priority.High": "High", + "priority.Critical": "Critical", + "list.title": "Tickets", + "list.unit": "ticket", + "list.description": "Open work, ranked by priority. The desk where issues land, get owned, and ship.", + "list.new": "New ticket", + "list.searchPlaceholder": "Find by number, title, or description…", + "list.count_one": "{{count}} ticket found", + "list.count_other": "{{count}} tickets found", + "filter.status": "Status filter", + "filter.priority": "Priority filter", + "filter.all": "All", + "filter.any": "Any", + "col.subject": "Subject", + "col.priority": "Priority", + "col.status": "Status", + "col.assignee": "Assignee", + "col.updated": "Updated", + "empty.searchTitle": "No tickets found", + "empty.title": "No tickets yet", + "empty.searchBodyTerm": "Nothing matches \"{{term}}\". Try a different term or clear the filters.", + "empty.searchBody": "No tickets match the current filters.", + "empty.body": "Open the first ticket to start tracking work. Tickets carry a status, priority, an optional assignee, and a comment thread.", + "empty.clearFilters": "Clear filters", + "empty.openTicket": "Open ticket", + "aria.openTicket": "Open ticket {{title}}", + "row.unassigned": "Unassigned", + "create.toastOpened": "Ticket opened", + "create.title": "Open a ticket", + "create.descPrefix": "Tickets land on the desk as ", + "create.descSuffix": ". Assign one to start it; resolve it with a note when work is done.", + "create.fieldTitle": "Title", + "create.fieldDescription": "Description", + "create.fieldPriority": "Priority", + "create.placeholderTitle": "Briefly describe the issue or request", + "create.placeholderDescription": "Steps to reproduce, expected behavior, anything else useful", + "create.cancel": "Cancel", + "create.opening": "Opening…", + "create.submit": "Open ticket", + "detail.back": "Back to tickets", + "detail.hero.openedBy": "opened {{rel}} by {{name}}", + "detail.hero.refresh": "Refresh", + "detail.hero.reassign": "Reassign", + "detail.hero.assign": "Assign", + "detail.hero.resolve": "Resolve", + "detail.hero.reopen": "Reopen", + "detail.hero.toastReopened": "Ticket reopened", + "detail.stat.comment_one": "comment", + "detail.stat.comment_other": "comments", + "detail.stat.updated": "updated", + "detail.stat.resolved": "resolved", + "detail.meta.assignee": "Assignee:", + "detail.meta.reporter": "Reporter:", + "detail.meta.created": "Created:", + "detail.unassigned": "Unassigned", + "detail.description.title": "Description", + "detail.description.empty": "No description on file. Comments below carry the conversation.", + "detail.description.resolution": "Resolution", + "detail.comments.title": "Conversation", + "detail.comments.none": "No comments yet", + "detail.comments.count_one": "{{count}} comment", + "detail.comments.count_other": "{{count}} comments", + "detail.comments.toastPosted": "Comment posted", + "detail.comments.empty": "No comments yet. Add the first note below — visible to anyone with View access.", + "detail.comments.ariaAdd": "Add a comment", + "detail.comments.placeholderDisabled": "Reopen the ticket to add a new comment.", + "detail.comments.placeholder": "Add a comment to the thread…", + "detail.comments.charCount": "{{count}} / 8192", + "detail.comments.posting": "Posting…", + "detail.comments.post": "Post comment", + "detail.comments.self": "You", + "detail.properties.title": "Properties", + "detail.properties.reporter": "Reporter", + "detail.properties.assignee": "Assignee", + "detail.properties.status": "Status", + "detail.properties.priority": "Priority", + "detail.properties.created": "Created", + "detail.properties.updated": "Updated", + "detail.properties.resolved": "Resolved", + "detail.resolve.toast": "Ticket resolved", + "detail.resolve.desc": "Resolution notes are kept on the ticket and shown in the description panel. Leave blank if there's nothing to add.", + "detail.resolve.fieldNote": "Resolution note", + "detail.resolve.hintNote": "Optional — leave blank if there's nothing to add.", + "detail.resolve.placeholder": "What changed? Root cause? Anything reviewers should know.", + "detail.resolve.cancel": "Cancel", + "detail.resolve.resolving": "Resolving…", + "detail.resolve.submit": "Mark resolved", + "detail.assign.toastAssigned": "Ticket assigned", + "detail.assign.toastUnassigned": "Ticket unassigned", + "detail.assign.descPrefix": "Paste a user ID. Picking up the ticket transitions it to ", + "detail.assign.descMid": "; clearing the assignee on an in-progress ticket sends it back to ", + "detail.assign.descSuffix": ".", + "detail.assign.fieldAssignee": "Assignee", + "detail.assign.hintAssignee": "Search by name or email. Clear the selection to unassign.", + "detail.assign.warnPrefix": "Clearing the assignee will ", + "detail.assign.warnEmphasis": "unassign", + "detail.assign.warnSuffix": " the ticket.", + "detail.assign.cancel": "Cancel", + "detail.assign.saving": "Saving…", + "detail.assign.submit": "Save assignment", + "detail.notFound.title": "This ticket no longer exists.", + "detail.notFound.body": "It may have been deleted. Check the trash, or head back to the tickets desk.", + "detail.notFound.back": "Back to tickets", + "detail.notFound.desk": "Tickets desk", + "userPicker.searchPlaceholder": "Search by name or email…", + "userPicker.searchReassign": "Search to reassign…", + "userPicker.clear": "Clear", + "userPicker.clearSelection": "Clear selection", + "userPicker.searching": "Searching for \"{{term}}\"…", + "userPicker.noMatch": "No users match \"{{term}}\".", + "userPicker.results": "Search results" +} diff --git a/clients/dashboard/src/locales/pt-BR/activity.json b/clients/dashboard/src/locales/pt-BR/activity.json new file mode 100644 index 0000000000..d2e426643f --- /dev/null +++ b/clients/dashboard/src/locales/pt-BR/activity.json @@ -0,0 +1,21 @@ +{ + "title": "Atividade ao vivo", + "unit": "evento", + "description": "Log completo de eventos transmitido pela API via Server-Sent Events.", + "ariaLabel": "Eventos de atividade", + "status.streaming": "transmitindo", + "status.offline": "offline", + "status.idle": "ocioso", + "status.connecting": "conectando", + "status.reconnecting": "reconectando", + "shown_one": "{{count}} evento exibido", + "shown_other": "{{count}} eventos exibidos", + "totalCount": "{{total}} no total", + "empty.listeningTitle": "Aguardando atividade", + "empty.noEventsTitle": "Nenhum evento ainda", + "empty.listeningBody": "O stream está aberto. Os eventos aparecerão aqui conforme o backend os publica.", + "empty.offlineBody": "O stream de atividade não está conectado. Os eventos entrarão na fila assim que a conexão ficar online.", + "col.action": "Ação", + "col.entity": "Entidade", + "col.time": "Horário" +} diff --git a/clients/dashboard/src/locales/pt-BR/audits.json b/clients/dashboard/src/locales/pt-BR/audits.json new file mode 100644 index 0000000000..da83a60e88 --- /dev/null +++ b/clients/dashboard/src/locales/pt-BR/audits.json @@ -0,0 +1,93 @@ +{ + "eventType.none": "Desconhecido", + "eventType.entity": "Entidade", + "eventType.security": "Segurança", + "eventType.activity": "Atividade", + "eventType.exception": "Exceção", + "severity.none": "—", + "severity.trace": "Trace", + "severity.debug": "Debug", + "severity.info": "Info", + "severity.warn": "Aviso", + "severity.error": "Erro", + "severity.critical": "Crítico", + "tag.piiMasked": "PII mascarada", + "tag.quotaExceeded": "Cota excedida", + "tag.sampled": "Amostrado", + "tag.retained": "Retido", + "tag.healthCheck": "Health check", + "tag.auth": "Autenticação", + "tag.authz": "Autorização", + "page.title": "Trilha de auditoria", + "page.unit": "evento", + "page.description": "Eventos de atividade, segurança, mudança de entidade e exceções em toda a plataforma. Janela imposta no servidor; máximo de 90 dias.", + "page.refresh": "Atualizar", + "page.searchPlaceholder": "Buscar payload, origem, usuário…", + "page.count_one": "{{count}} evento encontrado", + "page.count_other": "{{count}} eventos encontrados", + "page.loadError": "Falha ao carregar auditorias", + "empty.title": "Nenhuma auditoria nesta janela", + "empty.body": "Tente ampliar o intervalo de tempo ou afrouxar os filtros. Eventos de atividade chegam assim que a plataforma processa uma requisição.", + "empty.reset": "Redefinir filtros", + "col.actor": "Ator", + "col.event": "Evento", + "col.severity": "Severidade", + "col.timestamp": "Horário", + "actor.system": "Sistema", + "summary.window": "Janela {{range}}", + "summary.events": "eventos", + "summary.legend.activity": "Atividade", + "summary.legend.entity": "Entidade", + "summary.legend.security": "Segurança", + "summary.legend.exception": "Exceção", + "summary.severity.warn": "Aviso", + "summary.severity.err": "Erro", + "summary.topSources": "Principais origens", + "filter.advanced": "Avançado", + "filter.hideSystemTitle": "Oculta os eventos de Atividade do sistema por requisição (api.*) para mostrar só auditorias relevantes", + "filter.hideSystem": "Ocultar atividade do sistema", + "filter.clearSearch": "Limpar busca", + "filter.reset": "Redefinir", + "filter.type": "Tipo", + "filter.severity": "Severidade", + "filter.field.source": "Origem", + "filter.field.sourcePlaceholder": "api.identity.RegisterUser", + "filter.field.user": "ID do usuário", + "filter.field.userPlaceholder": "00000000-0000-…", + "filter.field.correlation": "Correlação", + "filter.field.correlationPlaceholder": "0HMxxxx…", + "filter.field.trace": "Trace", + "filter.field.tracePlaceholder": "hex traceparent", + "filter.tags": "Tags", + "drawer.srTitle": "Detalhe da auditoria", + "drawer.srDescription": "Payload completo, identificadores e ações relacionadas ao evento de auditoria selecionado.", + "drawer.close": "Fechar", + "drawer.eventFallback": "Evento de auditoria", + "drawer.utc": "UTC", + "drawer.section.identity": "Identidade", + "drawer.section.trace": "Trace", + "drawer.section.payload": "Payload", + "drawer.section.pipeline": "Pipeline", + "drawer.section.related": "Eventos relacionados", + "drawer.field.tenant": "Organização", + "drawer.field.user": "Usuário", + "drawer.field.userId": "ID do usuário", + "drawer.field.source": "Origem", + "drawer.field.traceId": "Trace ID", + "drawer.field.spanId": "Span ID", + "drawer.field.correlationId": "ID de correlação", + "drawer.field.requestId": "ID da requisição", + "drawer.field.occurred": "Ocorrido", + "drawer.field.received": "Recebido", + "drawer.field.sinkDelay": "Atraso de gravação", + "drawer.field.auditId": "ID da auditoria", + "drawer.allByCorrelation": "Todos por correlação", + "drawer.allByTrace": "Todos por trace", + "drawer.copy": "Copiar", + "drawer.copied": "Copiado", + "drawer.related.count": "{{count}} nesta correlação", + "drawer.related.empty": "Nenhum outro evento compartilha esta correlação. O ciclo de vida completo desta requisição está no payload acima.", + "drawer.related.thisEvent": "este evento", + "drawer.error.title": "Não foi possível carregar a auditoria", + "drawer.error.body": "O servidor retornou um erro ao buscar esta auditoria. O registro pode ter sido removido pela rotina de retenção." +} diff --git a/clients/dashboard/src/locales/pt-BR/auth.json b/clients/dashboard/src/locales/pt-BR/auth.json new file mode 100644 index 0000000000..aa9f8295d1 --- /dev/null +++ b/clients/dashboard/src/locales/pt-BR/auth.json @@ -0,0 +1,107 @@ +{ + "shell.tagline": "Starter Kit .NET 10", + "shell.encrypted": "Criptografado em trânsito · sessão protegida por JWT", + "shell.footer": "Administração fullstackhero", + "login.title": "Bem-vindo de volta", + "login.subtitle": "Entre na sua conta", + "login.inactivityNotice": "Você foi desconectado por inatividade.", + "login.tenant": "Organização", + "login.tenantPlaceholder": "root", + "login.email": "E-mail", + "login.emailPlaceholder": "nome@exemplo.com", + "login.password": "Senha", + "login.passwordPlaceholder": "Digite sua senha", + "login.forgot": "Esqueceu?", + "login.errorFallback": "Falha no login", + "login.showPassword": "Mostrar senha", + "login.hidePassword": "Ocultar senha", + "login.submit": "Entrar", + "login.submitting": "Entrando…", + "login.demoButton": "Entrar com uma conta de demonstração", + "demo.dialogTitle": "Contas de demonstração", + "demo.dialogDescription": "Escolha uma organização e uma conta de demonstração para entrar.", + "demo.liveDemo": "Demonstração ao vivo", + "demo.title": "Assuma qualquer papel.", + "demo.subtitle": "Explore o fullstackhero como qualquer usuário nas organizações de demonstração, entramos por você na hora.", + "demo.tenantsAria": "Organizações de demonstração", + "demo.userCount_one": "{{count}} usuário", + "demo.userCount_other": "{{count}} usuários", + "demo.users": "Usuários", + "demo.tapToSignIn": "toque para entrar", + "demo.demoOnly": "apenas demonstração", + "demo.resets": "Reiniciado a cada nova carga de dados.", + "inactivity.seconds": "segundos", + "inactivity.title": "Ainda por aí?", + "inactivity.description": "Você está inativo há um tempo. Vamos desconectá-lo em breve para manter sua conta segura.", + "inactivity.signOutNow": "Sair agora", + "inactivity.stay": "Estou aqui", + "confirm.malformed": "Este link de confirmação está sem parâmetros obrigatórios. Ele pode ter sido cortado pelo seu cliente de e-mail.", + "confirm.successFallback": "Seu e-mail foi confirmado. Você já pode entrar.", + "confirm.backLink": "← Voltar para o login", + "confirm.verifyingLead": "Verificando seu", + "confirm.verifyingAccent": "e-mail…", + "confirm.verifyingBody": "Um momento — verificando o token de confirmação com o servidor.", + "confirm.successLead": "E-mail", + "confirm.successAccent": "confirmado", + "confirm.continueSignIn": "Continuar para o login", + "confirm.errorLead": "Não foi possível", + "confirm.errorAccent": "confirmar", + "confirm.errorTrail": " seu e-mail", + "confirm.errorHint": "O link pode ter expirado ou já ter sido usado. Se você entrou desde que este e-mail foi enviado, pode ignorá-lo.", + "confirm.backToSignIn": "Voltar para o login", + "confirm.resetInstead": "Redefinir a senha", + "forgot.titleLead": "Redefina sua", + "forgot.titleAccent": "senha", + "forgot.subtitle": "Informe o e-mail que você usa para entrar. Enviaremos um link de uso único.", + "forgot.tenant": "Organização", + "forgot.tenantPlaceholder": "root", + "forgot.email": "E-mail", + "forgot.emailPlaceholder": "voce@exemplo.com", + "forgot.submit": "Enviar link de redefinição", + "forgot.submitting": "Enviando link…", + "forgot.successLead": "Verifique sua", + "forgot.successAccent": "caixa de entrada", + "forgot.successPre": "Se existir uma conta para", + "forgot.successMid": "na organização", + "forgot.successPost": ", um link de redefinição de uso único está a caminho. O link expira em 30 minutos.", + "forgot.tip1": "Não recebeu? Aguarde um minuto e verifique o spam.", + "forgot.tip2": "Ainda nada? Confirme o e-mail + organização e tente novamente.", + "forgot.tryDifferent": "Tentar outro endereço", + "forgot.backToSignIn": "Voltar para o login", + "forgot.remembered": "Lembrou?", + "forgot.signIn": "Entrar", + "reset.incompleteLead": "Este link está", + "reset.incompleteAccent": "incompleto", + "reset.incompletePre": "Falta um destes no link de redefinição:", + "reset.fieldToken": "token", + "reset.fieldEmail": "e-mail", + "reset.fieldTenant": "organização", + "reset.or": "ou", + "reset.incompletePost": "Alguns clientes de e-mail cortam URLs longas — tente copiar e colar o link completo do e-mail original na barra de endereços do navegador.", + "reset.requestNewLink": "Solicitar um novo link", + "reset.backToSignIn": "Voltar para o login", + "reset.titleLead": "Defina uma nova", + "reset.titleAccent": "senha", + "reset.subtitlePre": "Redefinindo a senha de", + "reset.subtitleMid": "em", + "reset.subtitlePost": ".", + "reset.newPassword": "Nova senha", + "reset.newPasswordPlaceholder": "Pelo menos 8 caracteres", + "reset.confirmPassword": "Confirmar senha", + "reset.confirmPasswordPlaceholder": "Digite a senha novamente", + "reset.showPassword": "Mostrar senha", + "reset.hidePassword": "Ocultar senha", + "reset.strength_weak": "Fraca", + "reset.strength_fair": "Razoável", + "reset.strength_strong": "Forte", + "reset.matches": "As senhas coincidem", + "reset.notMatchYet": "Ainda não coincide", + "reset.errMismatch": "As senhas não coincidem.", + "reset.errTooShort": "Use pelo menos 8 caracteres.", + "reset.submit": "Definir nova senha", + "reset.submitting": "Atualizando senha…", + "reset.toastTitle": "Senha atualizada", + "reset.toastDesc": "Entre com sua nova senha para continuar.", + "reset.changedMind": "Mudou de ideia?", + "reset.signIn": "Entrar" +} diff --git a/clients/dashboard/src/locales/pt-BR/catalog.json b/clients/dashboard/src/locales/pt-BR/catalog.json new file mode 100644 index 0000000000..50c598515a --- /dev/null +++ b/clients/dashboard/src/locales/pt-BR/catalog.json @@ -0,0 +1,204 @@ +{ + "products.title": "Produtos", + "products.description": "Navegue e gerencie o catálogo. Cada produto tem SKU, marca, categoria, preço e contagem de estoque em tempo real.", + "products.searchPlaceholder": "Buscar por nome, SKU ou slug…", + "products.count_one": "{{count}} produto encontrado", + "products.count_other": "{{count}} produtos encontrados", + "brands.title": "Marcas", + "brands.unit": "marca", + "brands.description": "Organize as marcas por trás de cada produto. Cada marca tem seu próprio slug, história e logotipo.", + "brands.searchPlaceholder": "Buscar por nome ou slug…", + "brands.count_one": "{{count}} marca encontrada", + "brands.count_other": "{{count}} marcas encontradas", + "categories.title": "Categorias", + "categories.unit": "categoria", + "categories.description": "Agrupe produtos em prateleiras. Categorias se aninham sob outras para formar a taxonomia que os clientes navegam.", + "categories.searchPlaceholder": "Buscar por nome ou slug…", + "categories.count_one": "{{count}} categoria encontrada", + "categories.count_other": "{{count}} categorias encontradas", + "filter.brand": "Marca", + "filter.category": "Categoria", + "filter.activeLabel": "Filtro de ativos", + "filter.all": "Todos", + "filter.active": "Ativo", + "filter.hidden": "Oculto", + "col.product": "Produto", + "col.sku": "SKU", + "col.brand": "Marca", + "col.price": "Preço", + "col.slug": "Slug", + "col.created": "Criado", + "col.category": "Categoria", + "badge.hidden": "Oculto", + "badge.active": "Ativo", + "aria.openProduct": "Abrir produto {{name}}", + "aria.editProduct": "Editar {{name}}", + "aria.deleteProduct": "Excluir {{name}}", + "aria.editBrand": "Editar marca {{name}}", + "aria.editCategory": "Editar categoria {{name}}", + "row.under": "sob {{name}}", + "row.parentFallback": "(pai)", + "row.root": "raiz", + "action.newProduct": "Novo produto", + "action.addProduct": "Adicionar produto", + "action.newBrand": "Nova marca", + "action.addBrand": "Adicionar marca", + "action.newCategory": "Nova categoria", + "action.addCategory": "Adicionar categoria", + "action.clear": "Limpar", + "action.clearFilters": "Limpar filtros", + "action.clearSearch": "Limpar busca", + "action.cancel": "Cancelar", + "action.saving": "Salvando…", + "action.saveChanges": "Salvar alterações", + "action.deleting": "Excluindo…", + "action.changePrice": "Alterar preço", + "action.adjustStock": "Ajustar estoque", + "action.adjusting": "Ajustando…", + "action.refresh": "Atualizar", + "action.edit": "Editar", + "action.delete": "Excluir", + "empty.products.searchTitle": "Nenhum produto encontrado", + "empty.products.title": "Nenhum produto ainda", + "empty.products.searchBodyTerm": "Nada corresponde a \"{{term}}\". Tente outro termo ou limpe os filtros.", + "empty.products.searchBody": "Nenhum produto corresponde aos filtros atuais.", + "empty.products.body": "Adicione seu primeiro produto para começar a vender. Cada um tem seu próprio SKU, preço, estoque e imagem.", + "empty.brands.searchTitle": "Nenhuma marca encontrada", + "empty.brands.title": "Nenhuma marca ainda", + "empty.brands.searchBodyTerm": "Nada corresponde a \"{{term}}\". Tente outro termo ou limpe a busca.", + "empty.brands.searchBody": "Nenhuma marca corresponde aos filtros atuais.", + "empty.brands.body": "Adicione sua primeira marca para começar a construir o catálogo. Cada marca tem seu próprio slug, descrição e logotipo.", + "empty.categories.searchTitle": "Nenhuma categoria encontrada", + "empty.categories.title": "Nenhuma categoria ainda", + "empty.categories.searchBodyTerm": "Nada corresponde a \"{{term}}\". Tente outro termo ou limpe a busca.", + "empty.categories.searchBody": "Nenhuma categoria corresponde aos filtros atuais.", + "empty.categories.body": "As categorias dão ao seu catálogo sua árvore. Crie prateleiras raiz e depois aninhe subprateleiras sob elas.", + "toast.productCreated": "Produto criado", + "toast.productUpdated": "Produto atualizado", + "toast.productDeleted": "Produto excluído", + "toast.brandCreated": "Marca criada", + "toast.brandUpdated": "Marca atualizada", + "toast.brandDeleted": "Marca excluída", + "toast.categoryCreated": "Categoria criada", + "toast.categoryUpdated": "Categoria atualizada", + "toast.categoryDeleted": "Categoria excluída", + "toast.priceUpdated": "Preço atualizado", + "toast.stockAdjusted": "Estoque ajustado", + "toast.createFailed": "Falha ao criar", + "toast.updateFailed": "Falha ao atualizar", + "toast.deleteFailed": "Falha ao excluir", + "toast.priceChangeFailed": "Falha ao alterar preço", + "toast.adjustmentFailed": "Falha no ajuste", + "field.name": "Nome", + "field.sku": "SKU", + "field.brand": "Marca", + "field.category": "Categoria", + "field.price": "Preço", + "field.currency": "Moeda", + "field.stock": "Estoque", + "field.description": "Descrição", + "field.slug": "Slug", + "field.parent": "Pai", + "field.logoUrl": "URL do logotipo", + "field.newAmount": "Novo valor", + "placeholder.productName": "Camiseta de Algodão Clássica", + "placeholder.sku": "ACM-TS-001", + "placeholder.selectBrand": "Selecione uma marca…", + "placeholder.selectCategory": "Selecione uma categoria…", + "placeholder.productDescription": "Algodão 100% orgânico, gola careca.", + "placeholder.brandName": "Acme Goods", + "placeholder.brandDescription": "Itens essenciais de qualidade para o lar moderno.", + "placeholder.logoUrl": "https://…", + "placeholder.categoryName": "Ar livre", + "placeholder.categoryDescription": "Equipamentos para a natureza.", + "placeholder.noParent": "Sem pai (raiz)", + "hint.skuFixed": "O SKU é fixo após a criação.", + "hint.skuNew": "Unidade de manutenção de estoque. Torna-se o identificador canônico.", + "hint.description": "Exibida nas páginas de listagem e detalhe do produto.", + "hint.slug": "Derivado automaticamente do nome. Usado em URLs.", + "hint.brandDescription": "Exibida nas páginas de listagem e detalhe do produto.", + "hint.logoUrl": "Opcional. URL pública da imagem do logotipo da marca.", + "hint.parent": "Opcional. Deixe em branco para tornar esta uma categoria raiz.", + "hint.categoryDescription": "Exibida nas páginas de navegação de categoria.", + "parentOption.label": "Categoria pai", + "visibility.title": "Visibilidade", + "visibility.listed": "Listado para clientes.", + "visibility.hidden": "Oculto das listagens.", + "visibility.active": "Ativo", + "productEditor.editTitle": "Editar produto", + "productEditor.addTitle": "Adicionar um produto", + "productEditor.editDesc": "Atualize os detalhes de {{name}}. Use os chips de preço/estoque na linha para alterá-los — eles emitem eventos de domínio.", + "productEditor.addDesc": "Adicione um produto ao seu catálogo. Preço e estoque podem ser ajustados na linha após a criação.", + "brandEditor.editTitle": "Editar marca", + "brandEditor.addTitle": "Adicionar uma marca", + "brandEditor.editDesc": "Atualize os detalhes de {{name}}. O slug é rederivado a partir do nome.", + "brandEditor.addDesc": "Adicione uma marca ao seu catálogo. O slug é gerado automaticamente a partir do nome.", + "categoryEditor.editTitle": "Editar categoria", + "categoryEditor.addTitle": "Adicionar uma categoria", + "categoryEditor.editDesc": "Atualize os detalhes de {{name}}. O slug é rederivado a partir do nome.", + "categoryEditor.addDesc": "Adicione uma categoria ao seu catálogo. O slug é gerado automaticamente a partir do nome.", + "price.title": "Alterar preço", + "price.emitsPrefix": "Emite o evento de domínio ", + "price.emitsSuffix": " para {{name}}.", + "price.emitsDetailPrefix": "{{name}} — emite o evento de domínio ", + "price.emitsDetailSuffix": ".", + "price.was": "Antes", + "price.becomes": "Depois", + "stock.title": "Ajustar estoque", + "stock.emitsPrefix": "Adicione ou remova unidades de {{name}}. Emite o evento ", + "stock.emitsSuffix": ".", + "stock.emitsDetailPrefix": "{{name}} — adicione ou remova unidades. Emite o evento ", + "stock.emitsDetailSuffix": ".", + "stock.current": "Atual", + "stock.becomes": "Depois", + "stock.deltaLabel": "Delta", + "stock.negativePrefix": "O estoque não pode ficar negativo. O decremento máximo é ", + "stock.negativeSuffix": ".", + "stock.negativeDetailPrefix": "O estoque não pode ficar negativo. O decremento máximo aqui é ", + "delete.productTitle": "Excluir produto", + "delete.productBody": ". O produto não aparecerá mais em nenhuma listagem ou relatório.", + "delete.brandTitle": "Excluir marca", + "delete.brandBody": ". Produtos que referenciam esta marca precisarão ser reatribuídos.", + "delete.categoryTitle": "Excluir categoria", + "delete.categoryBody": ". Categorias com subcategorias não podem ser excluídas — mova ou exclua os filhos primeiro.", + "delete.removesPrefix": "Isto remove permanentemente ", + "delete.createdOn": "(criado em {{date}})", + "delete.dateOnly": "({{date}})", + "detail.back": "Voltar aos produtos", + "detail.section.pricing": "Preços", + "detail.section.inventory": "Estoque", + "detail.section.identifiers": "Identificadores", + "detail.section.description": "Descrição", + "detail.section.descriptionDesc": "Texto voltado ao cliente exibido na página do produto.", + "detail.section.images": "Imagens", + "detail.section.imagesDesc": "Solte mais para adicionar. Marque uma com estrela para torná-la a capa.", + "detail.section.audit": "Auditoria", + "detail.stat.price": "preço", + "detail.stat.outOfStock": "sem estoque", + "detail.stat.low": "baixo (< {{n}})", + "detail.stat.inStock": "em estoque", + "detail.stat.images": "imagens", + "detail.meta.created": "Criado {{rel}}", + "detail.meta.updated": "Atualizado {{rel}}", + "detail.pricing.listed": "Preço listado · {{currency}}", + "detail.inventory.outOfStock": "Sem estoque", + "detail.inventory.below": "Abaixo de {{n}} unidades", + "detail.inventory.unitsOnHand": "Unidades em mãos", + "detail.id.sku": "SKU", + "detail.id.slug": "Slug", + "detail.id.productId": "ID do produto", + "detail.id.brandId": "ID da marca", + "detail.id.categoryId": "ID da categoria", + "detail.audit.created": "Criado", + "detail.audit.revised": "Revisado", + "detail.audit.never": "Nunca", + "detail.audit.noEdits": "sem edições desde a criação", + "detail.audit.status": "Status", + "detail.audit.active": "Ativo", + "detail.audit.hidden": "Oculto", + "detail.description.empty": "Nenhuma descrição registrada. Os clientes verão uma descrição em branco na página do produto até você adicionar uma.", + "detail.notFound.title": "Produto não encontrado", + "detail.notFound.body": "Pode ter sido excluído, ou o link pode estar errado.", + "detail.editor.title": "Editar produto", + "detail.editor.desc": "Atualize os detalhes de {{name}}. Use as ações de preço/estoque na barra lateral para alterá-los — eles emitem eventos de domínio." +} diff --git a/clients/dashboard/src/locales/pt-BR/chat.json b/clients/dashboard/src/locales/pt-BR/chat.json new file mode 100644 index 0000000000..b519f4f179 --- /dev/null +++ b/clients/dashboard/src/locales/pt-BR/chat.json @@ -0,0 +1,206 @@ +{ + "util.unnamedChannel": "(canal sem nome)", + "util.directMessage": "Mensagem direta", + "util.emptyGroup": "Grupo vazio", + "day.today": "Hoje", + "day.yesterday": "Ontem", + "unnamed": "(sem nome)", + "page.emptyTitle": "Escolha uma conversa", + "page.emptyBody": "Escolha um canal à esquerda para participar. Canais são públicos para a sua organização; DMs são privadas para as pessoas nelas. Menções chegam no sino de notificações, no canto superior direito.", + "page.hintSend": "Enviar", + "page.hintNewline": "Nova linha", + "page.hintMention": "Mencionar", + "page.channelUnreachable": "Este canal não está acessível. Ele pode ter sido arquivado ou você não é mais um membro.", + "page.loadingChannel": "Carregando canal…", + "page.backToChannels": "Voltar para os canais", + "page.member_one": "{{count}} membro", + "page.member_other": "{{count}} membros", + "page.searchMessages": "Buscar mensagens", + "page.channelSettings": "Configurações do canal", + "page.olderThanWindow": "Essa mensagem é mais antiga que a janela carregada.", + "rail.brand": "Chat", + "rail.filterPlaceholder": "Filtrar canais…", + "rail.clearFilter": "Limpar filtro", + "rail.sectionChannels": "Canais", + "rail.newChannel": "Novo canal", + "rail.sectionDms": "Mensagens diretas", + "rail.newDm": "Nova mensagem direta", + "rail.loading": "Carregando…", + "rail.noMatches": "Nenhuma correspondência.", + "rail.noChannels": "Nenhum canal ainda.", + "rail.noConversations": "Nenhuma conversa.", + "rail.footerFiltered": "{{shown}} de {{total}}", + "rail.footerCount_one": "{{count}} canal", + "rail.footerCount_other": "{{count}} canais", + "rail.unreadAria": "{{n}} não lidas", + "rail.createTitle": "Criar um canal", + "rail.createDescription": "Canais são onde os times conversam. Qualquer pessoa na sua organização pode entrar em canais públicos.", + "rail.nameLabel": "Nome", + "rail.namePlaceholder": "engenharia, feedback-de-design…", + "rail.descLabel": "Descrição (opcional)", + "rail.descPlaceholder": "Sobre o que é este canal?", + "rail.privateLabel": "Privado", + "rail.privateHint": "Apenas membros convidados podem encontrar ou entrar neste canal.", + "rail.cancel": "Cancelar", + "rail.createSubmit": "Criar canal", + "rail.dmTitle": "Nova mensagem direta", + "rail.dmDescription": "Encontre alguém na sua organização e abriremos uma DM com essa pessoa. DMs existentes são reutilizadas, você não cria duplicatas.", + "rail.dmSearchLabel": "Buscar", + "rail.dmSearchPlaceholder": "Nome, usuário ou e-mail…", + "rail.dmSearching": "Buscando…", + "rail.dmLoadingPeople": "Carregando pessoas…", + "rail.dmNoMatch": "Ninguém corresponde a \"{{term}}\".", + "rail.dmNoTeammates": "Nenhum colega na sua organização ainda.", + "rail.dmSearchResults": "Resultados da busca", + "rail.dmSuggested": "Sugeridos", + "composer.replyPlaceholder": "Digite sua resposta…", + "composer.messageChannel": "Mensagem para #{{name}}", + "composer.messageOther": "Mensagem para {{name}}", + "composer.ariaChannel": "Mensagem para o canal {{name}}", + "composer.ariaOther": "Mensagem para {{name}}", + "composer.ariaReply": "Digite sua resposta", + "composer.attachFile": "Anexar um arquivo", + "composer.sendMessage": "Enviar mensagem", + "composer.hintReply": "Enter para enviar · Shift+Enter para nova linha · Esc para limpar", + "composer.hintDefault": "Enter para enviar · Shift+Enter para nova linha · @ + 2 caracteres para mencionar", + "composer.sendFailedRetry": "Falha ao enviar, tentar de novo?", + "composer.replaceAttachment": "Substitua o anexo existente primeiro.", + "composer.attachError": "Não foi possível anexar esse arquivo.", + "composer.sendFailed": "A mensagem falhou ao enviar, tente novamente.", + "composer.replyingTo": "Respondendo a", + "composer.noTextEmpty": "(sem texto, anexo ou vazio)", + "composer.clearReplyAria": "Limpar contexto da resposta", + "composer.clearReply": "Limpar resposta", + "composer.uploadPreparing": "Preparando…", + "composer.uploadUploading": "Enviando… {{percent}}%", + "composer.uploadFinalizing": "Finalizando…", + "composer.uploadFailed": "Falha no envio", + "composer.uploadDone": "Concluído", + "composer.uploadProgressAria": "Progresso do envio", + "composer.uploadCancelAria": "Cancelar envio", + "composer.uploadCancel": "Cancelar", + "composer.readyToSend": "pronto para enviar", + "composer.removeAttachment": "Remover anexo", + "typing.one": "{{name}} está digitando…", + "typing.two": "{{a}} e {{b}} estão digitando…", + "typing.many": "{{a}} e mais {{count}} estão digitando…", + "pinned.bar_one": "{{count}} mensagem fixada", + "pinned.bar_other": "{{count}} mensagens fixadas", + "pinned.countAria_one": "{{count}} mensagem fixada, clique para revisar", + "pinned.countAria_other": "{{count}} mensagens fixadas, clique para revisar", + "pinned.panelTitle": "Mensagens fixadas", + "pinned.noText": "(sem texto)", + "pinned.attachment": "📎 {{name}}", + "pinned.attachmentMore": "📎 {{name}} (+{{count}})", + "search.close": "Fechar busca", + "search.placeholder": "Buscar mensagens neste canal", + "search.clear": "Limpar busca", + "search.searching": "Buscando…", + "search.noMatches": "Nenhuma correspondência para \"{{term}}\".", + "search.footer_one": "{{count}} correspondência · clique para ir · Esc para fechar", + "search.footer_other": "{{count}} correspondências · clique para ir · Esc para fechar", + "search.footerEsc": "Esc para fechar", + "mention.title": "Menção", + "mention.hint": "↑↓ navegar · Enter selecionar · Esc cancelar", + "mention.searching": "Buscando…", + "mention.listboxAria": "Sugestões de menção", + "message.edited": "· editada", + "message.pinned": "Fixada", + "message.deleted": "[mensagem excluída]", + "message.replyingTo": "Respondendo a", + "message.aMessage": "uma mensagem", + "message.noTextParent": "(sem texto, anexo ou vazio)", + "message.seen": "Vista", + "message.seenEveryone": "Vista por todos", + "message.seenByN": "Vista por {{n}}", + "message.mentionTitle": "Menção a {{username}}", + "message.mentionAria": "Menção a {{username}}, clique para abrir o perfil", + "message.you": "você", + "message.lookingUp": "Buscando…", + "message.userNotFound": "Usuário não encontrado.", + "message.loadUserError": "Não foi possível carregar este usuário.", + "message.copyHandle": "Copiar @{{username}}", + "message.opening": "Abrindo…", + "message.openDm": "Abrir DM", + "message.thatsYou": "Esse é você", + "message.copied": "Copiado {{text}}", + "message.copyFailed": "Não foi possível copiar para a área de transferência", + "message.openDmFailed": "Não foi possível abrir a DM", + "message.reactAria": "Reagir com {{emoji}}", + "message.reactionAddAria": "Adicionar reação {{emoji}}, {{n}} até agora", + "message.reactionRemoveAria": "Remover sua reação {{emoji}}, {{n}} até agora", + "message.react": "Reagir", + "message.reply": "Responder", + "message.unpin": "Desafixar", + "message.pin": "Fixar", + "message.edit": "Editar", + "message.delete": "Excluir", + "message.toastDeleted": "Mensagem excluída", + "message.deleteFailed": "Não foi possível excluir a mensagem", + "message.unpinned": "Desafixada", + "message.pinnedToast": "Fixada", + "message.pinUpdateFailed": "Não foi possível atualizar a fixação", + "message.deleteTitle": "Excluir esta mensagem?", + "message.deleteDescription": "Isto não pode ser desfeito. Todos no canal verão a mensagem desaparecer em tempo real.", + "message.deleteNoPreview": "Sem prévia de texto, anexos ou corpo vazio.", + "message.deleteCancel": "Cancelar", + "message.deleting": "Excluindo…", + "message.deleteConfirm": "Excluir mensagem", + "message.editAria": "Editar mensagem", + "message.editHint": "Enter para salvar · Shift+Enter para nova linha · Esc para cancelar", + "message.editCancel": "Cancelar", + "message.editSaving": "Salvando…", + "message.editSave": "Salvar", + "list.loading": "Carregando mensagens…", + "list.emptyTitle": "Nenhuma mensagem ainda", + "list.emptyBody": "Este é o começo da conversa. Envie a primeira mensagem para quebrar o silêncio.", + "list.loadingOlder": "Carregando mais antigas…", + "list.beginning": "Começo da conversa", + "list.logAria": "Mensagens do canal", + "list.new": "Novas", + "list.jumpAria_one": "Ir para a mais recente, {{count}} mensagem não vista", + "list.jumpAria_other": "Ir para a mais recente, {{count}} mensagens não vistas", + "list.jump_one": "nova mensagem", + "list.jump_other": "novas mensagens", + "settings.title": "Configurações do canal", + "settings.description": "Gerencie o nome, os membros e o ciclo de vida do canal.", + "settings.general": "Geral", + "settings.nameLabel": "Nome", + "settings.descLabel": "Descrição", + "settings.descPlaceholder": "Sobre o que é este canal?", + "settings.privateLabel": "Privado", + "settings.privateHint": "Apenas membros convidados podem encontrar ou entrar neste canal.", + "settings.members": "Membros", + "settings.danger": "Zona de perigo", + "settings.archiveTitle": "Arquivar canal", + "settings.archiveHint": "Oculta o canal para todos. Restaure pelo painel de admin se mudar de ideia.", + "settings.archiveConfirm": "Arquivar este canal para todos?", + "settings.archiving": "Arquivando…", + "settings.archive": "Arquivar", + "settings.leaveTitle": "Sair do canal", + "settings.leaveHint": "Você deixará de receber mensagens. Volte pela descoberta de canais se ele for público.", + "settings.leaveConfirm": "Sair deste canal?", + "settings.leaving": "Saindo…", + "settings.leave": "Sair", + "settings.close": "Fechar", + "settings.saving": "Salvando…", + "settings.saveChanges": "Salvar alterações", + "settings.toastUpdated": "Canal atualizado.", + "settings.toastUpdateFailed": "Não foi possível salvar as alterações do canal.", + "settings.toastArchived": "Canal arquivado.", + "settings.toastArchiveFailed": "Não foi possível arquivar o canal.", + "settings.toastLeft": "Você saiu do canal.", + "settings.toastLeaveFailed": "Não foi possível sair do canal.", + "settings.you": "você", + "settings.admin": "admin", + "settings.removeConfirm": "Remover {{name}} do canal?", + "settings.removeAria": "Remover {{name}}", + "settings.toastMemberRemoved": "Membro removido.", + "settings.toastRemoveFailed": "Não foi possível remover o membro.", + "settings.addMember": "Adicionar membro", + "settings.addPlaceholder": "Nome, usuário ou e-mail…", + "settings.addSearching": "Buscando…", + "settings.noMatchesOutside": "Nenhuma correspondência fora da lista atual de membros.", + "settings.toastMemberAdded": "Membro adicionado.", + "settings.toastAddFailed": "Não foi possível adicionar o membro." +} diff --git a/clients/dashboard/src/locales/pt-BR/commandPalette.json b/clients/dashboard/src/locales/pt-BR/commandPalette.json new file mode 100644 index 0000000000..321edded88 --- /dev/null +++ b/clients/dashboard/src/locales/pt-BR/commandPalette.json @@ -0,0 +1,83 @@ +{ + "group.navigate": "Navegar", + "group.create": "Criar", + "group.account": "Conta", + "group.theme": "Tema", + "group.accent": "Cor de destaque", + "group.session": "Sessão", + "nav.overview.label": "Visão geral", + "nav.overview.hint": "Telemetria e uso da organização", + "nav.activity.label": "Atividade ao vivo", + "nav.activity.hint": "Fluxo de eventos em tempo real", + "nav.chat.label": "Chat", + "nav.chat.hint": "Canais e mensagens diretas", + "nav.files.label": "Arquivos", + "nav.files.hint": "Meus arquivos enviados", + "nav.users.label": "Usuários", + "nav.users.hint": "Diretório de identidade", + "nav.roles.label": "Perfis", + "nav.roles.hint": "Permissões e atribuição de perfis", + "nav.groups.label": "Grupos", + "nav.groups.hint": "Grupos e associação da organização", + "nav.products.label": "Produtos", + "nav.products.hint": "Inventário do catálogo", + "nav.brands.label": "Marcas", + "nav.brands.hint": "Marcas do catálogo", + "nav.categories.label": "Categorias", + "nav.categories.hint": "Categorias do catálogo", + "nav.tickets.label": "Chamados", + "nav.tickets.hint": "Solicitações de suporte", + "nav.invoices.label": "Faturas", + "nav.invoices.hint": "Histórico de faturamento", + "nav.health.label": "Saúde", + "nav.health.hint": "Sonda de prontidão e dependências", + "nav.audits.label": "Trilha de auditoria", + "nav.audits.hint": "Eventos de atividade, segurança e mudança de entidade", + "nav.trash.label": "Lixeira", + "nav.trash.hint": "Registros excluídos temporariamente", + "nav.sessions.label": "Sessões", + "nav.sessions.hint": "Sessões de usuário ativas", + "nav.settings.label": "Configurações", + "create.user.label": "Criar usuário", + "create.user.hint": "Registrar uma nova conta", + "create.role.label": "Criar perfil", + "create.role.hint": "Definir um novo conjunto de permissões", + "create.group.label": "Criar grupo", + "create.group.hint": "Organizar membros", + "create.product.label": "Criar produto", + "create.product.hint": "Adicionar ao catálogo", + "create.brand.label": "Criar marca", + "create.brand.hint": "Adicionar uma marca ao catálogo", + "create.category.label": "Criar categoria", + "create.category.hint": "Adicionar uma categoria ao catálogo", + "create.ticket.label": "Criar chamado", + "create.ticket.hint": "Abrir uma solicitação de suporte", + "create.channel.label": "Criar canal de chat", + "create.channel.hint": "Iniciar um novo espaço de conversa", + "create.file.label": "Enviar arquivo", + "create.file.hint": "Adicionar ao seu armazenamento", + "account.profile.label": "Perfil", + "account.profile.hint": "Nome, e-mail, contato", + "account.security.label": "Segurança", + "account.security.hint": "Senha, 2FA, sessões", + "account.keys.label": "Chaves de API", + "account.keys.hint": "Gerar e rotacionar", + "account.notifications.label": "Notificações", + "account.notifications.hint": "Preferências de e-mail", + "account.appearance.label": "Aparência", + "account.appearance.hint": "Tema, destaque, fonte, densidade", + "theme.light": "Mudar para claro", + "theme.dark": "Mudar para escuro", + "theme.system": "Seguir o tema do sistema", + "accent.set": "Definir destaque: {{name}}", + "session.logout.label": "Sair", + "session.logout.hint": "Encerrar esta sessão", + "ui.searchPlaceholder": "Digite um comando ou busque…", + "ui.searchAria": "Buscar comandos", + "ui.srTitle": "Paleta de comandos", + "ui.srDescription": "Busque em páginas, ações da conta, tema e destaque. Use as setas para navegar; Enter para selecionar.", + "ui.emptyTitle": "Nenhuma correspondência", + "ui.emptyBody": "Tente outra palavra-chave — nome da página, entidade, tema, destaque ou sair.", + "ui.navigate": "navegar", + "ui.select": "selecionar" +} diff --git a/clients/dashboard/src/locales/pt-BR/common.json b/clients/dashboard/src/locales/pt-BR/common.json new file mode 100644 index 0000000000..3894bdc860 --- /dev/null +++ b/clients/dashboard/src/locales/pt-BR/common.json @@ -0,0 +1,136 @@ +{ + "language": "Idioma", + "language.enUS": "English (US)", + "language.ptBR": "Português (BR)", + "search": "Buscar", + "commandPalette.open": "Abrir paleta de comandos", + "profileMenu.open": "Abrir menu do perfil", + "unknownUser": "Desconhecido", + "skipToContent": "Pular para o conteúdo principal", + "brand.dashboard": "Painel", + "presence.connected_one": "Conectado · {{formatted}} evento", + "presence.connected_other": "Conectado · {{formatted}} eventos", + "presence.offline": "Transmissão offline", + "presence.connecting": "Conectando…", + "presence.reconnecting": "Reconectando…", + "presence.idle": "Ocioso", + "theme.title": "Tema", + "theme.light": "Claro", + "theme.dark": "Escuro", + "theme.system": "Sistema", + "account.title": "Conta", + "account.profile": "Perfil", + "account.settings": "Configurações", + "account.apiKeys": "Chaves de API", + "account.signOut": "Sair", + "signOut.title": "Sair do fullstackhero?", + "signOut.description": "Você precisará entrar novamente para acessar esta organização. Qualquer trabalho não salvo nesta sessão será perdido.", + "signOut.cancel": "Cancelar", + "signOut.confirm": "Sair", + "sidebar.collapse": "Recolher barra lateral", + "sidebar.expand": "Expandir barra lateral", + "nav.primary": "Navegação principal", + "nav.primaryDescription": "Seções do site e links da conta.", + "nav.openMenu": "Abrir menu de navegação", + "nav.overview": "Visão geral", + "nav.chat": "Chat", + "nav.myFiles": "Meus arquivos", + "nav.settings": "Configurações", + "nav.section.operations": "Operações", + "nav.section.catalog": "Catálogo", + "nav.section.helpdesk": "Central de ajuda", + "nav.section.identity": "Identidade", + "nav.section.system": "Sistema", + "nav.liveActivity": "Atividade ao vivo", + "nav.subscription": "Assinatura", + "nav.wallet": "Carteira do WhatsApp", + "nav.invoices": "Faturas", + "nav.products": "Produtos", + "nav.brands": "Marcas", + "nav.categories": "Categorias", + "nav.tickets": "Chamados", + "nav.users": "Usuários", + "nav.roles": "Funções", + "nav.groups": "Grupos", + "nav.health": "Saúde", + "nav.audits": "Trilha de auditoria", + "nav.sessions": "Sessões", + "nav.trash": "Lixeira", + "expiryBanner.expired": "Sua assinatura expirou. Entre em contato com seu operador para renovar e restaurar o acesso completo.", + "expiryBanner.grace": "Sua assinatura expirou — {{days}} de carência restantes (até {{date}}). Entre em contato com seu operador para renovar.", + "expiryBanner.nearing": "Sua assinatura expira em {{days}}.", + "expiryBanner.days_one": "{{count}} dia", + "expiryBanner.days_other": "{{count}} dias", + "expiryBanner.graceSoon": "em breve", + "expiryBanner.dismiss": "Dispensar", + "expiryBanner.dismissNotice": "Dispensar aviso de assinatura", + "impersonationBanner.crossTenant": "Personificação entre organizações", + "impersonationBanner.impersonating": "Personificando", + "impersonationBanner.operator": "operador", + "impersonationBanner.end": "Encerrar personificação", + "impersonationBanner.ending": "Encerrando…", + "impersonationBanner.toastEnded": "Personificação encerrada", + "impersonationBanner.toastReturned": "De volta à sua sessão", + "impersonationBanner.toastErrorTitle": "Não foi possível encerrar a personificação corretamente", + "impersonationBanner.toastErrorDesc": "Sessão local restaurada.", + "backToSignIn": "Voltar ao login", + "relative.justNow": "agora mesmo", + "relative.secondsAgo": "há {{n}}s", + "relative.minutesAgo": "há {{n}}min", + "relative.hoursAgo": "há {{n}}h", + "relative.daysAgo": "há {{n}}d", + "relative.monthsAgo": "há {{n}}mês", + "relative.yearsAgo": "há {{n}}a", + "notFound.title": "Página não encontrada", + "notFound.body": "Não encontramos nada neste endereço. Pode ser um link desatualizado, uma rota renomeada ou um caminho que nunca existiu.", + "notFound.requested": "Solicitado", + "notFound.backHome": "Voltar ao início", + "notFound.goBack": "Voltar à página anterior", + "tenantDeactivated.title": "Organização desativada", + "tenantDeactivated.body": "Esta organização foi desativada e não está mais disponível. Se você acha que isto é um engano, entre em contato com o administrador.", + "impersonationEnded.title": "Personificação encerrada", + "impersonationEnded.body": "O acesso do operador a esta sessão foi revogado ou expirou. Entre com a sua própria conta para continuar.", + "close": "Fechar", + "list.clear": "Limpar", + "list.clearSearch": "Limpar busca", + "list.clearFilter": "Limpar filtro", + "list.noMatches": "Nenhuma correspondência.", + "routeError.title": "Algo deu errado", + "routeError.reload": "Recarregar", + "routeError.goHome": "Ir para o início", + "routeError.unexpected": "Erro inesperado", + "list.loading": "Carregando…", + "list.pageOf": "Página {{page}} de {{total}}", + "list.prevPage": "Página anterior", + "list.nextPage": "Próxima página", + "unit.item_one": "{{count}} item", + "unit.item_other": "{{count}} itens", + "unit.event_one": "{{count}} evento", + "unit.event_other": "{{count}} eventos", + "unit.brand_one": "{{count}} marca", + "unit.brand_other": "{{count}} marcas", + "unit.category_one": "{{count}} categoria", + "unit.category_other": "{{count}} categorias", + "unit.file_one": "{{count}} arquivo", + "unit.file_other": "{{count}} arquivos", + "unit.group_one": "{{count}} grupo", + "unit.group_other": "{{count}} grupos", + "unit.invoice_one": "{{count}} fatura", + "unit.invoice_other": "{{count}} faturas", + "unit.role_one": "{{count}} função", + "unit.role_other": "{{count}} funções", + "unit.user_one": "{{count}} usuário", + "unit.user_other": "{{count}} usuários", + "unit.session_one": "{{count}} sessão", + "unit.session_other": "{{count}} sessões", + "unit.ticket_one": "{{count}} chamado", + "unit.ticket_other": "{{count}} chamados", + "realtimeStatus.offline": "Offline", + "realtimeStatus.connecting": "Conectando", + "realtimeStatus.live": "Ao vivo", + "realtimeStatus.reconnecting": "Reconectando", + "realtimeStatus.title": "Tempo real: {{label}}", + "session.restoring": "Restaurando sua sessão…", + "field.required": "obrigatório", + "errorBand.failure": "Falha" +} diff --git a/clients/dashboard/src/locales/pt-BR/files.json b/clients/dashboard/src/locales/pt-BR/files.json new file mode 100644 index 0000000000..9b4c634513 --- /dev/null +++ b/clients/dashboard/src/locales/pt-BR/files.json @@ -0,0 +1,130 @@ +{ + "page.title": "Arquivos", + "page.unit": "arquivo", + "page.description": "Solte imagens, documentos ou arquivos compactados. Os envios são privados por padrão; torne um arquivo público na sua pré-visualização para deixá-lo visível ao resto da organização em Compartilhados.", + "tabs.aria": "Escopos de arquivo", + "tabs.mine": "Meus arquivos", + "tabs.shared": "Compartilhados na organização", + "search.placeholder": "Buscar por nome do arquivo…", + "search.aria": "Buscar arquivos", + "search.clear": "Limpar busca", + "chip.all": "Todos", + "chip.images": "Imagens", + "chip.documents": "Documentos", + "chip.archives": "Compactados", + "chip.other": "Outros", + "error.mine": "Não foi possível carregar seus arquivos.", + "error.shared": "Não foi possível carregar os arquivos compartilhados.", + "empty.mine.title": "Nenhum arquivo ainda", + "empty.mine.body": "Solte um arquivo acima para começar. Seus envios são privados por padrão.", + "empty.shared.title": "Nada compartilhado ainda", + "empty.shared.body": "Quando um colega tornar um de seus arquivos público, ele aparece aqui para todos na organização.", + "empty.refresh": "Atualizar", + "empty.noMatch.title": "Nenhuma correspondência", + "empty.noMatch.body": "Nada corresponde ao filtro atual.", + "empty.noMatch.bodyTerm": "Nada corresponde ao filtro atual \"{{term}}\".", + "empty.noMatch.reset": "Redefinir filtros", + "col.filename": "Nome do arquivo", + "col.uploadedBy": "Enviado por", + "col.visibility": "Visibilidade", + "col.size": "Tamanho", + "col.uploaded": "Enviado", + "count.showing_one": "Mostrando {{shown}} de {{count}} arquivo", + "count.showing_other": "Mostrando {{shown}} de {{count}} arquivos", + "count.total_one": "{{count}} arquivo", + "count.total_other": "{{count}} arquivos", + "visibility.public": "Público", + "visibility.private": "Privado", + "card.by": "por", + "preview.fallbackName": "Arquivo", + "preview.toastDeleted": "Arquivo excluído", + "preview.toastDeleteFailed": "Falha ao excluir", + "preview.toastNowPublic": "O arquivo agora é público para a sua organização", + "preview.toastNowPrivate": "O arquivo agora é privado", + "preview.toastVisibilityFailed": "Falha ao alterar a visibilidade", + "preview.errorMeta": "Não foi possível carregar os metadados do arquivo.", + "preview.confirmDelete": "Excluir este arquivo?", + "preview.cancel": "Cancelar", + "preview.deleting": "Excluindo…", + "preview.confirmDeleteButton": "Confirmar exclusão", + "preview.delete": "Excluir", + "preview.close": "Fechar", + "preview.notFinalized": "Envio ainda não finalizado.", + "preview.quarantined": "Este arquivo está em quarentena e não pode ser visualizado.", + "preview.expired": "O link de pré-visualização expirou ou está inacessível.", + "preview.retry": "Tentar novamente", + "preview.notAvailable": "Pré-visualização indisponível para este tipo de arquivo.", + "preview.openNewTab": "Abrir em nova aba", + "preview.textError": "Não foi possível obter o conteúdo de texto: {{err}}", + "preview.fetchFailed": "Falha ao obter", + "preview.meta.fileId": "ID do arquivo", + "preview.meta.ownerType": "Tipo de dono", + "preview.meta.uploadedBy": "Enviado por", + "preview.meta.contentType": "Tipo de conteúdo", + "preview.meta.size": "Tamanho", + "preview.meta.status": "Status", + "preview.meta.created": "Criado", + "preview.meta.uploaderLoading": "Carregando…", + "preview.meta.visibility": "Visibilidade", + "preview.meta.publicHint": "Todos na sua organização podem encontrar este arquivo em Compartilhados.", + "preview.meta.privateHint": "Somente você pode visualizar ou baixar este arquivo.", + "preview.meta.readOnly": "Somente leitura", + "preview.meta.toggleAria": "Alternar visibilidade pública", + "preview.status.pending": "Envio pendente", + "preview.status.available": "Disponível", + "preview.status.quarantined": "Em quarentena", + "preview.status.unknown": "Desconhecido ({{status}})", + "preview.download": "Baixar", + "dropzone.toastUploaded": "Arquivo enviado", + "dropzone.dismiss": "Dispensar", + "dropzone.cancel": "Cancelar", + "dropzone.progressAria": "Progresso do envio", + "dropzone.caption.preparing": "Preparando envio…", + "dropzone.caption.uploading": "Enviando {{name}}", + "dropzone.caption.uploadingFallback": "Enviando …", + "dropzone.caption.finalizing": "Finalizando…", + "dropzone.caption.done": "Enviado {{name}}", + "dropzone.caption.doneFallback": "Arquivo enviado", + "dropzone.caption.error": "Falha no envio", + "dropzone.caption.idle": "Solte um arquivo ou clique para procurar", + "dropzone.detail.uploading": "{{loaded}} de {{total}}", + "dropzone.detail.allowed": "Permitidos: {{ext}}", + "dropzone.detail.upTo": " · até {{size}}", + "dropzone.detail.dropAFile": "Solte um arquivo", + "imageInput.upload": "Enviar", + "imageInput.pasteUrl": "Colar URL", + "imageInput.replace": "Substituir imagem", + "imageInput.choose": "Escolher imagem", + "imageInput.remove": "Remover", + "imageInput.urlPlaceholder": "https://…", + "imageInput.hintUpload": "JPG/PNG/WebP/GIF · até {{size}}", + "imageInput.hintUrl": "Link direto para uma imagem hospedada em outro lugar.", + "imageInput.toastUploaded": "Imagem enviada", + "imageInput.uploadFailed": "Falha no envio", + "imageInput.noPublicUrl": "O servidor não retornou publicUrl para este arquivo.", + "pim.upload": "Enviar imagens", + "pim.count_one": "{{count}} imagem · JPG / PNG / WebP / GIF · até 10 MB", + "pim.count_other": "{{count}} imagens · JPG / PNG / WebP / GIF · até 10 MB", + "pim.empty": "Nenhuma imagem ainda. Envie uma para definir a capa do produto.", + "pim.toastCoverUpdated": "Imagem de capa atualizada", + "pim.toastImageRemoved": "Imagem removida", + "pim.errAttach": "Falha ao anexar imagem", + "pim.errSetCover": "Falha ao definir a capa", + "pim.errRemove": "Falha ao remover imagem", + "pim.errUploadNamed": "Falha no envio: {{name}}", + "pim.noPublicUrl": "O servidor não retornou publicUrl para a imagem enviada.", + "pim.cover": "Capa", + "pim.setCover": "Definir como capa", + "pim.removeImage": "Remover imagem", + "pim.previewImage": "Visualizar imagem", + "pim.previewTitle": "Pré-visualização da imagem", + "pim.currentCover": "Imagem de capa atual.", + "pim.clickOutside": "Clique fora para fechar.", + "pim.close": "Fechar", + "pim.detachTitle": "Desanexar esta imagem?", + "pim.detachBody": "A imagem é removida deste produto. ", + "pim.detachCoverNote": "Ela é a capa atual — outra imagem será promovida automaticamente.", + "pim.cancel": "Cancelar", + "pim.removing": "Removendo…", + "pim.remove": "Remover" +} diff --git a/clients/dashboard/src/locales/pt-BR/health.json b/clients/dashboard/src/locales/pt-BR/health.json new file mode 100644 index 0000000000..783393cec5 --- /dev/null +++ b/clients/dashboard/src/locales/pt-BR/health.json @@ -0,0 +1,48 @@ +{ + "eyebrow": "Sistema · Saúde", + "title": "Saúde", + "subtitlePre": "Sonda de prontidão ao vivo em cada dependência registrada. Consultado a cada ", + "subtitleSuffix": ".", + "pauseTitle": "Pausar atualização automática", + "resumeTitle": "Retomar atualização automática", + "live": "Ao vivo", + "paused": "Pausado", + "refresh": "Atualizar", + "dependencies": "Dependências", + "checksTotal_one": "{{count}} verificação · total", + "checksTotal_other": "{{count}} verificações · total", + "hero.Healthy.headline": "Todos os sistemas operacionais", + "hero.Healthy.subline": "Todas as dependências estão respondendo dentro da tolerância.", + "hero.Degraded.headline": "Degradação parcial", + "hero.Degraded.subline": "Uma ou mais verificações estão reportando latência elevada ou avisos.", + "hero.Unhealthy.headline": "Interrupção detectada", + "hero.Unhealthy.subline": "Ao menos uma dependência crítica está inacessível. Investigue as verificações com falha abaixo.", + "status.Healthy": "Saudável", + "status.Degraded": "Degradado", + "status.Unhealthy": "Com falha", + "http": "HTTP {{code}}", + "vital.checks": "Verificações", + "vital.checksHint": "aprovadas", + "vital.roundTrip": "Ida e volta", + "vital.roundTripHint": "ponta a ponta", + "vital.slowest": "Mais lenta", + "vital.slowestHint": "verificação única", + "vital.lastPoll": "Última consulta", + "recentPolls": "Consultas recentes", + "sessionCount": "{{n}} / {{limit}} nesta sessão", + "historyAria": "Histórico de consultas recentes", + "historyTick": "{{status}} · {{time}}", + "historyNoData": "sem dados", + "noDescription": "Nenhuma descrição registrada.", + "latencyBudget": "Orçamento de latência", + "latencyBudgetValue": "{{value}} / 500 ms", + "latencyBudgetHint": "Escalonado contra um orçamento de prontidão suave de 500 ms. Barras no terço superior são território de alerta antecipado.", + "detail": "Detalhe", + "noDetails": "Nenhum detalhe adicional reportado.", + "moreDetails": "+{{count}} mais", + "error.title": "Endpoint de saúde inacessível", + "error.default": "O navegador não conseguiu acessar /health/ready. A API pode estar fora do ar, ou uma política de rede está bloqueando a requisição.", + "empty.title": "Nenhuma verificação registrada", + "empty.bodyPre": "Módulos podem registrar verificações de dependência via ", + "empty.bodyPost": ". Nenhuma está reportando ainda." +} diff --git a/clients/dashboard/src/locales/pt-BR/identity.json b/clients/dashboard/src/locales/pt-BR/identity.json new file mode 100644 index 0000000000..c72da0809a --- /dev/null +++ b/clients/dashboard/src/locales/pt-BR/identity.json @@ -0,0 +1,331 @@ +{ + "unnamedUser": "Usuário sem nome", + "backToUsers": "Voltar para usuários", + "notFound": "Usuário não encontrado.", + "member": "Membro", + "cancel": "Cancelar", + "badge.active": "Ativo", + "badge.inactive": "Inativo", + "badge.emailConfirmed": "Email confirmado", + "badge.emailPending": "Email pendente", + "actions.impersonate": "Personificar", + "actions.impersonateDisabledTitle": "Não é possível personificar um usuário inativo", + "actions.sending": "Enviando…", + "actions.resendConfirmation": "Reenviar confirmação", + "actions.confirming": "Confirmando…", + "actions.confirmEmail": "Confirmar email", + "actions.deactivate": "Desativar", + "actions.reactivate": "Reativar", + "actions.delete": "Excluir", + "stat.role_one": "função", + "stat.role_other": "funções", + "stat.session_one": "sessão", + "stat.session_other": "sessões", + "card.title": "Cartão de identidade", + "card.description": "Somente leitura aqui. Os membros atualizam o próprio perfil nas configurações.", + "field.username": "Usuário", + "field.email": "Email", + "field.firstName": "Nome", + "field.lastName": "Sobrenome", + "field.phone": "Telefone", + "field.id": "ID", + "roles.title": "Atribuição de funções", + "roles.description": "Alterne quais funções se aplicam. As alterações ficam pendentes até salvar.", + "roles.pending_one": "{{count}} pendente", + "roles.pending_other": "{{count}} pendentes", + "roles.discard": "Descartar", + "roles.saving": "Salvando…", + "roles.save": "Salvar alterações", + "roles.emptyPre": "Nenhuma função definida.", + "roles.emptyLink": "Crie uma", + "roles.emptyPost": "para começar a atribuir acesso.", + "roles.untitled": "Função sem título", + "roles.modified": "modificada", + "roles.toggleAria": "Alternar {{name}}", + "roles.roleFallback": "função", + "roles.updated": "Funções atualizadas", + "roles.updatedDesc_one": "{{count}} função alterada.", + "roles.updatedDesc_other": "{{count}} funções alteradas.", + "roles.updateFailed": "Falha na atualização", + "status.deactivated": "Usuário desativado", + "status.reactivated": "Usuário reativado", + "status.changeFailed": "Falha ao alterar o status", + "status.deactivateTitle": "Desativar usuário?", + "status.reactivateTitle": "Reativar usuário?", + "status.deactivateDesc": "{{name}} não poderá entrar até ser reativado. As sessões existentes permanecem, a menos que sejam revogadas.", + "status.reactivateDesc": "{{name}} recuperará o acesso de login imediatamente.", + "status.working": "Processando…", + "email.confirmed": "Email confirmado", + "email.confirmFailed": "Falha ao confirmar o email", + "email.resent": "Email de confirmação enviado", + "email.resendFailed": "Não foi possível enviar o email de confirmação", + "delete.deleted": "Usuário excluído", + "delete.failed": "Falha ao excluir", + "delete.title": "Excluir este membro", + "delete.descPre": "Isto remove permanentemente", + "delete.descPost": "Ele perderá o acesso imediatamente. Isto não pode ser desfeito.", + "delete.deleting": "Excluindo…", + "delete.confirm": "Excluir usuário", + "sessions.revoked": "Sessão revogada", + "sessions.revokeFailed": "Falha ao revogar", + "sessions.revokedCount_one": "{{count}} sessão revogada", + "sessions.revokedCount_other": "{{count}} sessões revogadas", + "sessions.revokeAllFailed": "Falha ao revogar todas", + "sessions.unknownBrowser": "Navegador desconhecido", + "sessions.unknownOs": "SO desconhecido", + "sessions.title": "Sessões ativas", + "sessions.loading": "Carregando sessões…", + "sessions.summary": "{{active}} ativas · {{total}} registradas no total", + "sessions.revokeAll": "Revogar todas", + "sessions.empty": "Nenhuma sessão registrada. O usuário não entrou recentemente.", + "sessions.badgeActive": "Ativa", + "sessions.badgeEnded": "Encerrada", + "sessions.lastSeen": "vista por último {{date}}", + "sessions.started": "iniciada {{date}}", + "sessions.revoking": "Revogando…", + "sessions.revoke": "Revogar", + "sessions.revokeAllTitle": "Revogar todas as sessões de {{name}}?", + "sessions.revokeAllDesc": "Toda sessão ativa — desktop, celular, aba do navegador — será encerrada imediatamente. O usuário precisará entrar novamente em cada dispositivo.", + "sessions.revokingAll": "Revogando…", + "impersonate.started": "Personificação iniciada", + "impersonate.startedDesc": "Você agora está agindo como este usuário. Use o banner para encerrar.", + "impersonate.failed": "Falha na personificação", + "impersonate.title": "Personificar {{name}}?", + "impersonate.description": "Você agirá como este usuário em todo o dashboard. Cada ação que você fizer será atribuída a ele nos logs de auditoria (com o seu id de operador preservado como autor). Encerre a personificação pelo banner no topo da página quando terminar.", + "impersonate.reasonLabel": "Motivo (opcional, registrado no log de auditoria)", + "impersonate.reasonPlaceholder": "Investigando um relato de bug deste usuário…", + "impersonate.starting": "Iniciando…", + "impersonate.start": "Iniciar personificação", + "field.name": "Nome", + "field.description": "Descrição", + "field.password": "Senha", + "field.confirm": "Confirmar", + "rolesList.title": "Funções", + "rolesList.description": "Funções agrupam permissões em faixas de autoridade nomeadas. Atribua-as pela página de detalhe do usuário.", + "rolesList.newRole": "Nova função", + "rolesList.searchPlaceholder": "Buscar por nome ou descrição…", + "rolesList.emptyFoundTitle": "Nenhuma função encontrada", + "rolesList.emptyTitle": "Nenhuma função ainda", + "rolesList.emptySearchBody": "Nada corresponde a \"{{term}}\". Tente outro termo ou limpe a busca.", + "rolesList.emptyBody": "Crie a primeira função para começar a agrupar permissões. Sem funções, os membros ficam sem acesso por padrão.", + "rolesList.clearSearch": "Limpar busca", + "rolesList.addRole": "Adicionar função", + "rolesList.found_one": "{{count}} função encontrada", + "rolesList.found_other": "{{count}} funções encontradas", + "rolesList.colName": "Nome", + "rolesList.colDescription": "Descrição", + "rolesList.colPermissions": "Permissões", + "rolesList.system": "Sistema", + "rolesList.noDescription": "Sem descrição registrada.", + "rolesList.openAria": "Abrir função {{name}}", + "rolesList.permNone": "—", + "rolesList.perm_one": "{{count}} permissão", + "rolesList.perm_other": "{{count}} permissões", + "rolesList.createTitle": "Criar uma função", + "rolesList.createDescription": "Funções agrupam permissões. Após criar, você será levado ao editor para atribuir permissões e ajustar o acesso.", + "rolesList.namePlaceholder": "Gerente", + "rolesList.descHint": "Mostrado aos administradores ao atribuir funções. Linguagem simples ajuda.", + "rolesList.descPlaceholder": "Pode gerenciar usuários e atribuir funções", + "rolesList.creating": "Criando…", + "rolesList.createSubmit": "Criar função", + "rolesList.toastCreated": "Função criada", + "rolesList.toastCreatedDesc": "Agora configure as permissões de {{name}}.", + "rolesList.toastCreateFailed": "Falha ao criar", + "groupsList.title": "Grupos", + "groupsList.description": "Grupos agrupam membros e funções em coortes reutilizáveis. Adicione um usuário a um grupo para conceder todas as funções vinculadas a ele.", + "groupsList.newGroup": "Novo grupo", + "groupsList.searchPlaceholder": "Buscar por nome ou descrição…", + "groupsList.emptyFoundTitle": "Nenhum grupo encontrado", + "groupsList.emptyTitle": "Nenhum grupo ainda", + "groupsList.emptySearchBodyTerm": "Nada corresponde a \"{{term}}\". Tente outro termo.", + "groupsList.emptySearchBodyNoTerm": "Nenhum grupo corresponde aos filtros atuais.", + "groupsList.emptyBody": "Crie o primeiro grupo para agrupar membros e funções. Útil para times, departamentos ou coortes de recursos.", + "groupsList.clearSearch": "Limpar busca", + "groupsList.addGroup": "Adicionar grupo", + "groupsList.found_one": "{{count}} grupo encontrado", + "groupsList.found_other": "{{count}} grupos encontrados", + "groupsList.colGroup": "Grupo", + "groupsList.colComposition": "Composição", + "groupsList.colFlags": "Sinalizadores", + "groupsList.default": "Padrão", + "groupsList.system": "Sistema", + "groupsList.noDescription": "Sem descrição registrada.", + "groupsList.openAria": "Abrir grupo {{name}}", + "groupsList.member_one": "{{count}} membro", + "groupsList.member_other": "{{count}} membros", + "groupsList.role_one": "{{count}} função", + "groupsList.role_other": "{{count}} funções", + "groupsList.createTitle": "Criar um grupo", + "groupsList.createDescription": "Grupos agrupam membros e funções. Após criar, você será levado ao editor para anexar funções e adicionar membros.", + "groupsList.namePlaceholder": "Engenharia", + "groupsList.descHint": "Ajuda os administradores a entender o que esta coorte representa.", + "groupsList.descPlaceholder": "Engenheiros de web, mobile e plataforma.", + "groupsList.defaultLabel": "Grupo padrão", + "groupsList.defaultHint": "Usuários recém-registrados entram automaticamente.", + "groupsList.creating": "Criando…", + "groupsList.createSubmit": "Criar grupo", + "groupsList.toastCreated": "Grupo criado", + "groupsList.toastCreatedDesc": "Adicione membros e funções a seguir.", + "groupsList.toastCreateFailed": "Falha ao criar", + "usersList.title": "Usuários", + "usersList.description": "Todo membro com acesso a esta organização. Registre novos usuários, revise o status e gerencie funções.", + "usersList.registerUser": "Registrar usuário", + "usersList.searchPlaceholder": "Buscar por nome, usuário ou e-mail…", + "usersList.filterAccountStatus": "Status da conta", + "usersList.filterAll": "Todos", + "usersList.filterActive": "Ativo", + "usersList.filterInactive": "Inativo", + "usersList.filterEmailStatus": "Status do e-mail", + "usersList.filterAnyEmail": "Qualquer e-mail", + "usersList.filterConfirmed": "Confirmado", + "usersList.filterPending": "Pendente", + "usersList.filterRole": "Função", + "usersList.emptyFoundTitle": "Nenhum usuário encontrado", + "usersList.emptyTitle": "Nenhum usuário ainda", + "usersList.emptySearchBodyTerm": "Nada corresponde a \"{{term}}\". Tente outro termo ou limpe os filtros.", + "usersList.emptySearchBodyNoTerm": "Nenhum usuário corresponde aos filtros atuais.", + "usersList.emptyBody": "Registre o primeiro membro para popular esta organização. Ele receberá um e-mail de confirmação se a confirmação de e-mail estiver habilitada.", + "usersList.clearFilters": "Limpar filtros", + "usersList.found_one": "{{count}} usuário encontrado", + "usersList.found_other": "{{count}} usuários encontrados", + "usersList.colName": "Nome", + "usersList.colUsername": "Usuário", + "usersList.colStatus": "Status", + "usersList.noEmail": "sem e-mail", + "usersList.noEmailFile": "sem e-mail registrado", + "usersList.openAria": "Abrir usuário {{name}}", + "usersList.badgeConfirmed": "Confirmado", + "usersList.badgePending": "Pendente", + "usersList.registerTitle": "Registrar um membro", + "usersList.registerDescription": "Adicione um novo usuário a esta organização. Usuário e e-mail devem ser únicos. Senhas precisam de uma letra maiúscula, uma minúscula e um dígito.", + "usersList.firstPlaceholder": "Ada", + "usersList.lastPlaceholder": "Lovelace", + "usersList.usernameHint": "Usado no login. Minúsculas, sem espaços.", + "usersList.usernamePlaceholder": "ada.lovelace", + "usersList.emailPlaceholder": "ada@exemplo.com", + "usersList.phoneHint": "Número de contato opcional.", + "usersList.phonePlaceholder": "+55 …", + "usersList.confirmMismatch": "As senhas não coincidem.", + "usersList.registering": "Registrando…", + "usersList.registerSubmit": "Registrar usuário", + "usersList.toastRegistered": "Usuário registrado", + "usersList.toastRegisteredDesc": "Uma confirmação de e-mail pode ser necessária antes do login.", + "usersList.toastRegisterFailed": "Falha no registro", + "roleDetail.back": "Voltar para funções", + "roleDetail.notFound": "Função não encontrada.", + "roleDetail.catalogError": "Não foi possível carregar o catálogo de permissões: {{error}}", + "roleDetail.system": "Sistema", + "roleDetail.subtitleSystem": "Função interna gerenciada pelo framework.", + "roleDetail.subtitleCustom": "Função personalizada.", + "roleDetail.deleteRole": "Excluir função", + "roleDetail.deleteSystemTitle": "Funções de sistema não podem ser excluídas.", + "roleDetail.statPermissions": "permissões", + "roleDetail.readOnlyTitle": "Função interna, somente leitura", + "roleDetail.readOnlyBody": " vem com o framework. Seu nome, descrição e permissões são gerenciados centralmente para que o contrato de seed e o sincronizador de permissões em runtime fiquem alinhados. Crie uma função personalizada se precisar de um conjunto de concessões diferente.", + "roleDetail.detailsTitle": "Detalhes da função", + "roleDetail.detailsDescription": "O rótulo de exibição e um resumo de uma linha que os administradores verão no momento da atribuição.", + "roleDetail.descPlaceholder": "Descrição curta para esta função", + "roleDetail.permsTitle": "Permissões", + "roleDetail.permsDescription": "Busque, filtre e alterne permissões individuais, ou aplique um padrão sensato a partir de um preset. Algumas permissões de nível raiz podem ser filtradas no servidor.", + "roleDetail.presetBasic": "Básico", + "roleDetail.presetAll": "Todas", + "roleDetail.presetClear": "Limpar", + "roleDetail.unsaved": "Alterações não salvas", + "roleDetail.allSaved": "Todas as alterações salvas", + "roleDetail.discard": "Descartar", + "roleDetail.saving": "Salvando…", + "roleDetail.saveChanges": "Salvar alterações", + "roleDetail.searchPlaceholder": "Buscar por recurso, ação ou descrição…", + "roleDetail.searchAria": "Buscar permissões", + "roleDetail.clearSearch": "Limpar busca", + "roleDetail.filterAll": "Todas", + "roleDetail.filterEnabled": "Habilitadas", + "roleDetail.filterModified": "Modificadas", + "roleDetail.filterBasic": "Básicas", + "roleDetail.enabled": "habilitadas", + "roleDetail.modified": "{{n}} modificadas", + "roleDetail.showingMatches_one": "mostrando {{count}} correspondência", + "roleDetail.showingMatches_other": "mostrando {{count}} correspondências", + "roleDetail.expandAll": "Expandir tudo", + "roleDetail.collapseAll": "Recolher tudo", + "roleDetail.noMatchTitle": "Nenhuma permissão corresponde", + "roleDetail.noMatchBody": "Tente outro termo ou limpe o filtro atual.", + "roleDetail.resetFilters": "Redefinir filtros", + "roleDetail.toggleAllAria": "Alternar todas {{resource}}", + "roleDetail.changed": "{{n}} alteradas", + "roleDetail.changedTitle_one": "{{count}} alteração não salva", + "roleDetail.changedTitle_other": "{{count}} alterações não salvas", + "roleDetail.groupMatches_one": "· {{count}} correspondência", + "roleDetail.groupMatches_other": "· {{count}} correspondências", + "roleDetail.groupAll": "Todas", + "roleDetail.groupNone": "Nenhuma", + "roleDetail.rootBadge": "raiz", + "roleDetail.rootTitle": "Permissão de nível raiz. Pode ser filtrada no servidor.", + "roleDetail.basicBadge": "básica", + "roleDetail.modifiedAria": "modificada", + "roleDetail.deleteTitle": "Excluir função", + "roleDetail.deleteBodyPre": "Isto remove permanentemente ", + "roleDetail.deleteBodyPost": ". Os membros atualmente atribuídos perderão suas permissões imediatamente.", + "roleDetail.deleting": "Excluindo…", + "roleDetail.deleteConfirm": "Excluir função", + "roleDetail.toastUpdateFailed": "Falha ao atualizar", + "roleDetail.toastPermsFailed": "Falha ao atualizar permissões", + "roleDetail.toastDeleted": "Função excluída", + "roleDetail.toastDeleteFailed": "Falha ao excluir", + "roleDetail.toastSaved": "Função salva", + "groupDetail.back": "Voltar para grupos", + "groupDetail.notFound": "Grupo não encontrado.", + "groupDetail.default": "Padrão", + "groupDetail.system": "Sistema", + "groupDetail.subtitle": "Coorte de grupo.", + "groupDetail.deleteGroup": "Excluir grupo", + "groupDetail.memberLabel_one": "membro", + "groupDetail.memberLabel_other": "membros", + "groupDetail.roleLabel_one": "função", + "groupDetail.roleLabel_other": "funções", + "groupDetail.detailsTitle": "Detalhes do grupo", + "groupDetail.detailsDescription": "Nome, descrição e as funções vinculadas a este grupo.", + "groupDetail.discard": "Descartar", + "groupDetail.saving": "Salvando…", + "groupDetail.saveChanges": "Salvar alterações", + "groupDetail.descPlaceholder": "Resumo curto de quem ou o que este grupo representa", + "groupDetail.defaultLabel": "Grupo padrão", + "groupDetail.defaultHint": "Atribuir automaticamente a usuários recém-registrados.", + "groupDetail.rolesAttached": "Funções vinculadas", + "groupDetail.noRolesPre": "Nenhuma função definida. ", + "groupDetail.noRolesLink": "Crie uma", + "groupDetail.noRolesPost": " primeiro.", + "groupDetail.attachAria": "Vincular {{name}}", + "groupDetail.membersTitle": "Membros", + "groupDetail.membersDescription": "Usuários que pertencem a este grupo herdam todas as funções vinculadas acima.", + "groupDetail.addMembers": "Adicionar membros", + "groupDetail.emptyMembersPre": "Ninguém neste grupo ainda. Clique em ", + "groupDetail.emptyMembersStrong": "Adicionar membros", + "groupDetail.emptyMembersPost": " para vincular usuários.", + "groupDetail.remove": "Remover", + "groupDetail.unknownUser": "Usuário desconhecido", + "groupDetail.deleteTitle": "Excluir grupo", + "groupDetail.deleteBodyPost": " será removido. Os membros perderão qualquer permissão herdada por meio deste grupo.", + "groupDetail.deleting": "Excluindo…", + "groupDetail.deleteConfirm": "Excluir grupo", + "groupDetail.toastUpdated": "Grupo atualizado", + "groupDetail.toastUpdateFailed": "Falha ao atualizar", + "groupDetail.toastDeleted": "Grupo excluído", + "groupDetail.toastDeleteFailed": "Falha ao excluir", + "groupDetail.toastMemberRemoved": "Membro removido", + "groupDetail.toastRemoveFailed": "Falha ao remover", + "groupDetail.pickTitle": "Escolha membros para adicionar", + "groupDetail.pickDescription": "Busque usuários ativos e selecione um ou mais para vincular a este grupo.", + "groupDetail.searchPlaceholder": "Buscar por nome, usuário ou e-mail…", + "groupDetail.clearSearch": "Limpar busca", + "groupDetail.noMatch": "Nenhum usuário corresponde a \"{{term}}\".", + "groupDetail.noUsers": "Nenhum usuário disponível.", + "groupDetail.alreadyIn": "já no grupo", + "groupDetail.selected": "{{count}} selecionados", + "groupDetail.adding": "Adicionando…", + "groupDetail.addN": "Adicionar {{count}}", + "groupDetail.toastAdded_one": "{{count}} membro adicionado", + "groupDetail.toastAdded_other": "{{count}} membros adicionados", + "groupDetail.toastAddedDupes": " · {{count}} já presentes", + "groupDetail.toastAddFailed": "Falha ao adicionar" +} diff --git a/clients/dashboard/src/locales/pt-BR/notifications.json b/clients/dashboard/src/locales/pt-BR/notifications.json new file mode 100644 index 0000000000..b919490d0b --- /dev/null +++ b/clients/dashboard/src/locales/pt-BR/notifications.json @@ -0,0 +1,32 @@ +{ + "bell.aria": "Notificações", + "bell.ariaUnread": "Notificações, {{count}} não lidas", + "bell.title": "Notificações", + "header.title": "Notificações", + "header.allCaughtUp": "Tudo em dia", + "header.summary": "{{unread}} não lidas · {{loaded}} carregadas", + "markAllRead": "Marcar todas como lidas", + "recent": "Recentes", + "loading": "Carregando…", + "empty": "Nada ainda. Menções e atualizações de canais aparecerão aqui.", + "settings": "Configurações ↗", + "rel.justNow": "agora mesmo", + "rel.minutes": "{{n}}min", + "rel.hours": "{{n}}h", + "rel.days": "{{n}}d", + "rel.weeks": "{{n}}sem", + "toast.channelFallback": "canal", + "toast.aChannel": "um canal", + "toast.directMessage": "mensagem direta", + "toast.groupChat": "conversa em grupo", + "toast.justNow": "agora mesmo", + "toast.openConversation": "Abrir conversa", + "toast.emptyBody": "(anexo ou corpo vazio)", + "toast.dismissAria": "Dispensar notificação", + "toast.dismiss": "Dispensar", + "badge.chat": "Chat", + "badge.ariaUnread_one": "Chat, {{count}} mensagem não lida", + "badge.ariaUnread_other": "Chat, {{count}} mensagens não lidas", + "badge.titleUnread_one": "{{count}} mensagem de chat não lida", + "badge.titleUnread_other": "{{count}} mensagens de chat não lidas" +} diff --git a/clients/dashboard/src/locales/pt-BR/overview.json b/clients/dashboard/src/locales/pt-BR/overview.json new file mode 100644 index 0000000000..0891845a8a --- /dev/null +++ b/clients/dashboard/src/locales/pt-BR/overview.json @@ -0,0 +1,96 @@ +{ + "greeting.morning": "Bom dia, {{name}}", + "greeting.afternoon": "Boa tarde, {{name}}", + "greeting.evening": "Boa noite, {{name}}", + "operator": "operador", + "yourTenant": "sua organização", + "refresh": "Atualizar", + "viewActivity": "Ver atividade", + "viewAudits": "Ver auditorias", + "open": "Abrir", + "seeAll": "Ver tudo", + "stat.plan": "Plano", + "stat.validFor": "Válido por", + "stat.resources": "Recursos", + "stat.liveEvents": "Eventos ao vivo", + "stat.unavailable": "Indisponível", + "stat.noSubscription": "Sem assinatura", + "stat.status.Active": "Ativa", + "stat.status.Suspended": "Suspensa", + "stat.status.Cancelled": "Cancelada", + "connState.idle": "ocioso", + "connState.connecting": "conectando", + "connState.connected": "conectado", + "connState.reconnecting": "reconectando", + "connState.error": "erro", + "validity.expired": "Expirada", + "validity.openEnded": "Sem prazo", + "validity.days": "dias", + "validity.statusUnavailable": "Status indisponível", + "validity.renew": "Entre em contato com o operador para renovar", + "validity.graceEnds": "carência termina {{date}}", + "validity.inGracePeriod": "em período de carência", + "validity.until": "até {{date}}", + "validity.noEndDate": "sem data de término", + "resources.avgUtilization": "utilização média", + "resources.overage": "excedente", + "subscription.loadErrorTitle": "Não foi possível carregar a assinatura", + "subscription.loadErrorBody": "O endpoint de assinatura retornou um erro. Tente atualizar.", + "subscription.noneTitle": "Nenhuma assinatura ativa", + "subscription.noneBody": "Escolha um plano para habilitar cobrança, cotas e controle de excedente.", + "subscription.viewCta": "Ver assinatura", + "subscription.started": "Início", + "subscription.ends": "Término", + "subscription.openEnded": "sem prazo", + "subscription.currentTerm": "Período atual", + "subscription.termProgress": "{{pct}}% · {{days}}d restantes", + "system.streamLive": "Stream ao vivo", + "system.offline": "offline", + "system.liveDesc": "Os eventos do backend estão chegando em tempo real.", + "system.erroredDesc": "Stream desconectado. Os eventos entrarão na fila assim que ele se recuperar.", + "system.waitingDesc": "Aguardando o stream ficar online.", + "system.eventsThisSession": "Eventos nesta sessão", + "audits.emptyTitle": "Nenhuma atividade recente", + "audits.emptyBody": "As ações auditadas nas últimas 24 horas aparecerão aqui.", + "audits.eventFallback": "Evento", + "audits.systemActor": "sistema", + "audits.ago": "há {{value}}", + "quick.inviteUsers.title": "Convidar usuários", + "quick.inviteUsers.description": "Adicione colegas, atribua funções.", + "quick.browseCatalog.title": "Explorar catálogo", + "quick.browseCatalog.description": "Produtos, marcas, categorias.", + "quick.subscription.title": "Assinatura", + "quick.subscription.description": "Plano, uso, faturas.", + "quick.liveActivity.title": "Atividade ao vivo", + "quick.liveActivity.description": "Stream de eventos em tempo real.", + "liveFeed.emptyTitle": "Aguardando atividade", + "liveFeed.emptyBody": "Os eventos aparecerão aqui conforme o backend os publica.", + "firstRun.dismissAria": "Dispensar checklist de configuração", + "firstRun.skipTitle": "Pular por enquanto", + "firstRun.welcome": "Bem-vindo ao {{name}}", + "firstRun.subtitle": "Sua organização está provisionada e pronta. É por aqui que a maioria das equipes começa.", + "firstRun.step": "Passo {{step}}", + "firstRun.tile.plan.title": "Escolha um plano", + "firstRun.tile.plan.description": "Habilite cobrança, cotas e controle de excedente.", + "firstRun.tile.team.title": "Convide sua equipe", + "firstRun.tile.team.description": "Adicione colegas, atribua funções e agrupe-os.", + "firstRun.tile.catalog.title": "Explorar catálogo", + "firstRun.tile.catalog.description": "Produtos, marcas e categorias de exemplo prontos para usar.", + "firstRun.tile.watch.title": "Acompanhe ao vivo", + "firstRun.tile.watch.description": "Stream SSE direto no dashboard.", + "section.subscription": "Assinatura", + "section.systemStatus": "Status do sistema", + "section.recentAudits": "Auditorias recentes", + "section.recentAuditsDesc": "Últimas 24 horas, 5 principais eventos.", + "section.usage": "Uso por recurso", + "section.usageDesc": "Consumo do mês atual em relação aos limites do plano.", + "section.quickActions": "Ações rápidas", + "section.quickActionsDesc": "Vá direto para os destinos mais usados.", + "section.liveFeed": "Feed ao vivo", + "section.liveFeedDesc": "Eventos do backend em tempo real via SSE.", + "usage.errorTitle": "Não foi possível carregar o uso", + "usage.errorBody": "O endpoint de uso retornou um erro. Tente atualizar.", + "usage.emptyTitle": "Nenhum uso registrado ainda", + "usage.emptyBody": "A atividade aparecerá aqui conforme o backend registra snapshots deste período.", + "usageOverageBadge": "excedente" +} diff --git a/clients/dashboard/src/locales/pt-BR/settings.json b/clients/dashboard/src/locales/pt-BR/settings.json new file mode 100644 index 0000000000..bc9842511f --- /dev/null +++ b/clients/dashboard/src/locales/pt-BR/settings.json @@ -0,0 +1,209 @@ +{ + "title": "Configurações", + "sections": "Seções", + "sectionsNav": "Seções de configurações", + "tab.profile.label": "Perfil", + "tab.profile.hint": "Sua identidade na organização", + "tab.security.label": "Segurança", + "tab.security.hint": "Senha e sessões ativas", + "tab.appearance.label": "Aparência", + "tab.appearance.hint": "Tema e preferências visuais", + "tab.branding.label": "Marca", + "tab.branding.hint": "Cores e logos da organização", + "tab.notifications.label": "Notificações", + "tab.notifications.hint": "Como falamos com você", + "tab.apiKeys.label": "Chaves de API", + "tab.apiKeys.hint": "Tokens de acesso pessoais", + "profile.toastSaved": "Perfil salvo", + "profile.saveError": "Falha ao salvar o perfil", + "profile.toastSaveFail": "Falha ao salvar", + "profile.imageUpdated": "Imagem de perfil atualizada", + "profile.imageUpdateError": "Falha ao atualizar a imagem de perfil", + "profile.loadError": "Não foi possível carregar seu perfil. Exibindo os detalhes da sua sessão; alterações salvas podem não refletir o estado mais recente do servidor.", + "profile.photo.title": "Foto", + "profile.photo.description": "Exibida na barra superior e na sua atividade. Recortes quadrados funcionam melhor — JPG, PNG ou WebP.", + "profile.identity.title": "Identidade", + "profile.identity.description": "Seu nome e detalhes de contato, visíveis em todo o dashboard.", + "profile.reset": "Redefinir", + "profile.saving": "Salvando…", + "profile.save": "Salvar alterações", + "profile.field.firstName": "Nome", + "profile.field.lastName": "Sobrenome", + "profile.field.email": "Email", + "profile.field.phone": "Telefone", + "profile.emailHint": "Entre em contato com o administrador da organização para alterar o email de acesso.", + "profile.phonePlaceholder": "+55 (11) 91234-5678", + "profile.subject.title": "Identificador do usuário", + "profile.subject.description": "O ID único que esta conta usa dentro da plataforma. Somente leitura.", + "notifications.title": "Preferências de notificação", + "notifications.description": "Escolha quais eventos você quer receber por email ou como notificações no app.", + "notifications.emptyTitle": "As preferências por evento ainda não são ajustáveis.", + "notifications.emptyBody": "As notificações no app já chegam ao seu sino, abra-o na barra superior para vê-las. As opções granulares de email por evento estão no roadmap da v1.1. Enquanto isso, o administrador da organização pode desativar o envio de emails globalmente.", + "notifications.openBell": "Abrir sino de notificações", + "apiKeys.title": "Chaves de API", + "apiKeys.description": "Credenciais de longa duração usadas por serviços para chamar a API do FSH. Trate-as como senhas.", + "apiKeys.emptyTitle": "As chaves de API ainda não estão disponíveis.", + "apiKeys.emptyBody": "Tokens de acesso pessoais e chaves de API entre serviços estão no roadmap da v1.1. Por enquanto, seu acesso passa pelo JWT vinculado ao usuário emitido no login. Acompanhe o progresso no roadmap público ou nas próximas notas de versão.", + "apiKeys.viewRoadmap": "Ver roadmap", + "appearance.theme.title": "Tema", + "appearance.theme.description": "Escolha um modo de cor para o dashboard. Sistema segue o seu SO.", + "appearance.theme.light": "Claro", + "appearance.theme.lightBlurb": "Tela clara, conforto para o dia.", + "appearance.theme.system": "Sistema", + "appearance.theme.systemBlurb": "Segue a preferência do SO.", + "appearance.theme.dark": "Escuro", + "appearance.theme.darkBlurb": "Menos brilho para sessões longas.", + "appearance.theme.ariaLabel": "Tema {{label}}", + "appearance.active": "Ativo", + "appearance.accent.title": "Destaque", + "appearance.accent.description": "Escolha a cor de marca usada em ações principais, gráficos e destaques em todo o dashboard.", + "appearance.accent.ariaLabel": "Destaque {{label}}", + "appearance.font.title": "Fonte", + "appearance.font.description": "A tipografia da interface. Blocos de código mono sempre usam JetBrains Mono.", + "appearance.font.ariaLabel": "Fonte {{label}}", + "appearance.density.title": "Densidade", + "appearance.density.description": "O modo compacto reduz o preenchimento dos cards e a altura das linhas para telas com muitos dados.", + "appearance.density.toggle": "Usar espaçamento compacto em todo o dashboard.", + "appearance.density.ariaLabel": "Densidade compacta", + "appearance.motion.title": "Movimento", + "appearance.motion.descPre": "Sobrescreve a configuração", + "appearance.motion.descPost": "do sistema.", + "appearance.motion.toggle": "Desativar transições e animações decorativas.", + "appearance.motion.ariaLabel": "Reduzir movimento", + "appearance.custom.ariaLabel": "Destaque personalizado", + "appearance.custom.title": "Destaque personalizado — clique para editar", + "appearance.custom.label": "Personalizado", + "appearance.custom.dialogTitle": "Escolha a cor da sua marca", + "appearance.custom.dialogDescription": "Arraste a faixa de matiz para recolorir o destaque. A saturação escala o croma uniformemente pelos onze tons da marca.", + "appearance.custom.hue": "Matiz", + "appearance.custom.saturation": "Saturação", + "appearance.custom.brandLadder": "Escala da marca", + "appearance.custom.preview": "Prévia", + "appearance.custom.previewSubscription": "Assinatura", + "appearance.custom.previewActive": "ativa", + "appearance.custom.primaryAction": "Ação principal", + "appearance.custom.cancel": "Cancelar", + "appearance.custom.apply": "Aplicar destaque", + "security.sessions.unknownBrowser": "Navegador desconhecido", + "security.sessions.unknownOs": "SO desconhecido", + "security.sessions.unknownIp": "ip desconhecido", + "security.sessions.toastRevoked": "Sessão revogada", + "security.sessions.revokeError": "Não foi possível revogar a sessão.", + "security.sessions.toastRevokedCount_one": "{{count}} sessão revogada", + "security.sessions.toastRevokedCount_other": "{{count}} sessões revogadas", + "security.sessions.revokeAllError": "Não foi possível revogar as sessões.", + "security.sessions.loadError": "Falha ao carregar as sessões.", + "security.sessions.title": "Sessões ativas", + "security.sessions.activeCount_one": "{{count}} ativa", + "security.sessions.activeCount_other": "{{count}} ativas", + "security.sessions.description": "Navegadores e dispositivos conectados à sua conta no momento.", + "security.sessions.signOutOthers": "Sair de todos os outros", + "security.sessions.emptyTitle": "Nenhuma sessão registrada", + "security.sessions.emptyBody": "A atividade de sessão aparecerá aqui assim que você entrar de qualquer dispositivo.", + "security.sessions.thisDevice": "este dispositivo", + "security.sessions.revokedBadge": "revogada", + "security.sessions.meta": "{{ip}} · última atividade {{lastActivity}} · expira {{expires}}", + "security.sessions.revoking": "Revogando…", + "security.sessions.revoke": "Revogar", + "security.password.title": "Senha", + "security.password.description": "Usada para entrar nesta organização. Escolha uma senha forte e única.", + "security.password.recommendation": "Recomendamos uma frase secreta de 16+ caracteres sem reuso de outros serviços.", + "security.password.change": "Alterar senha", + "security.password.hide": "Ocultar senha", + "security.password.show": "Mostrar senha", + "security.password.toastSuccess": "Senha alterada", + "security.password.toastSuccessDesc": "As outras sessões ativas continuam válidas até você revogá-las.", + "security.password.changeError": "Não foi possível alterar a senha.", + "security.password.dialogDescription": "Escolha uma senha forte que você não reutilize em outros lugares. Suas outras sessões conectadas continuam ativas — revogue-as abaixo se quiser encerrá-las.", + "security.password.current": "Senha atual", + "security.password.new": "Nova senha", + "security.password.confirm": "Confirmar nova senha", + "security.password.match": "As senhas conferem", + "security.password.mismatchYet": "As senhas ainda não conferem", + "security.password.cancel": "Cancelar", + "security.password.updating": "Atualizando…", + "security.password.update": "Atualizar senha", + "security.validation.min8": "A nova senha deve ter pelo menos 8 caracteres.", + "security.validation.mismatch": "As senhas não conferem.", + "security.validation.mustDiffer": "A nova senha deve ser diferente da atual.", + "security.strength.weak": "Fraca", + "security.strength.fair": "Razoável", + "security.strength.good": "Boa", + "security.strength.strong": "Forte", + "security.twoFa.title": "Autenticação de dois fatores", + "security.twoFa.badgeOn": "ativada", + "security.twoFa.badgeOff": "desativada", + "security.twoFa.description": "Exige um código de uso único de um app autenticador em todo login.", + "security.twoFa.enrollFailed": "Falha na configuração", + "security.twoFa.enrollStartError": "Não foi possível iniciar a configuração.", + "security.twoFa.enabledToast": "Dois fatores ativado", + "security.twoFa.enabledToastDesc": "Os próximos logins exigirão um código de 6 dígitos do seu autenticador.", + "security.twoFa.verifyFailed": "Falha na verificação", + "security.twoFa.verifyMismatch": "Esse código não conferiu. Tente novamente.", + "security.twoFa.verifyError": "Não foi possível verificar o código.", + "security.twoFa.generating": "Gerando…", + "security.twoFa.enable": "Ativar dois fatores", + "security.twoFa.enrollHint": "Você vai escanear um QR code no seu app autenticador (1Password, Google Authenticator, Authy, …).", + "security.twoFa.qrAlt": "QR code de dois fatores", + "security.twoFa.rendering": "Renderizando…", + "security.twoFa.manualEntry": "não consegue escanear? insira manualmente", + "security.twoFa.copied": "copiado", + "security.twoFa.copy": "copiar", + "security.twoFa.codeLabel": "código de 6 dígitos do seu app", + "security.twoFa.codePlaceholder": "123 456", + "security.twoFa.verifying": "Verificando…", + "security.twoFa.confirmEnable": "Confirmar e ativar", + "security.twoFa.cancel": "Cancelar", + "security.twoFa.disabledToast": "Dois fatores desativado", + "security.twoFa.disableFailed": "Falha ao desativar", + "security.twoFa.disableWrongPassword": "A verificação da senha falhou.", + "security.twoFa.disableError": "Não foi possível desativar os dois fatores.", + "security.twoFa.disableDescription": "O dois fatores está ativado no momento. Confirme sua senha para desativar — isso rotaciona o segredo do autenticador, então uma nova configuração vai gerar um novo QR code.", + "security.twoFa.disabling": "Desativando…", + "security.twoFa.disable": "Desativar dois fatores", + "branding.title": "Marca", + "branding.description": "Tokens de tema consumidos pelos apps da sua organização no login. A prévia ao vivo reflete a ação principal com a paleta escolhida.", + "branding.loadingDescription": "Carregando marca…", + "branding.loading": "Carregando marca", + "branding.toastSaved": "Marca salva", + "branding.saveFailed": "Falha ao salvar", + "branding.toastReset": "Marca redefinida para os padrões", + "branding.resetFailed": "Falha ao redefinir", + "branding.unknownError": "Erro desconhecido", + "branding.badgeDefault": "padrão", + "branding.badgeUnsaved": "não salvo", + "branding.resetAriaLabel": "Redefinir marca para os padrões", + "branding.resetting": "Redefinindo…", + "branding.resetToDefaults": "Redefinir para os padrões", + "branding.saving": "Salvando…", + "branding.save": "Salvar marca", + "branding.lightPreview": "Prévia clara", + "branding.lightPalette": "Paleta clara", + "branding.darkPalette": "Paleta escura", + "branding.resetPalette": "Redefinir paleta", + "branding.pickColor": "Escolher cor {{label}}", + "branding.colorAriaLabel": "cor {{label}}", + "branding.field.primary": "Primária", + "branding.field.secondary": "Secundária", + "branding.field.tertiary": "Terciária", + "branding.field.background": "Fundo", + "branding.field.surface": "Superfície", + "branding.field.error": "Erro", + "branding.field.warning": "Aviso", + "branding.field.success": "Sucesso", + "branding.field.info": "Info", + "branding.assets.title": "Ativos de marca", + "branding.assets.description": "URLs dos seus ativos de marca hospedados. Faça o upload pelo módulo de Arquivos primeiro e depois cole aqui a URL pública resultante.", + "branding.assets.logo": "URL do logo", + "branding.assets.logoDark": "URL do logo (modo escuro)", + "branding.assets.favicon": "URL do favicon", + "branding.assets.placeholder": "https://cdn.example.com/logo.svg", + "branding.preview.live": "ao vivo", + "branding.preview.samplePage": "Página de exemplo da organização", + "branding.preview.active": "ativa", + "branding.preview.body": "Um parágrafo curto renderizado com a cor de corpo escolhida sobre a superfície escolhida, no fundo de página escolhido. Os botões de ação usam o token primário.", + "branding.preview.primaryAction": "Ação principal", + "branding.preview.secondary": "Secundária", + "branding.preview.warn": "aviso", + "branding.preview.error": "erro" +} diff --git a/clients/dashboard/src/locales/pt-BR/subscription.json b/clients/dashboard/src/locales/pt-BR/subscription.json new file mode 100644 index 0000000000..6e83266a02 --- /dev/null +++ b/clients/dashboard/src/locales/pt-BR/subscription.json @@ -0,0 +1,118 @@ +{ + "title": "Assinatura", + "description": "O plano, a validade, o uso e as faturas recentes da sua organização.", + "plan.title": "Plano", + "plan.noneTitle": "Nenhuma assinatura ativa", + "plan.noneBody": "Sua organização não tem plano atribuído. Entre em contato com o operador para habilitar cobrança, cotas e controle de excedente.", + "plan.started": "Início", + "plan.ends": "Término", + "plan.openEnded": "sem prazo", + "plan.changesNote": "As mudanças de plano são feitas pelo operador. Entre em contato com o operador para fazer upgrade, renovar ou cancelar.", + "plan.status.Active": "Ativa", + "plan.status.Suspended": "Suspensa", + "plan.status.Cancelled": "Cancelada", + "validity.title": "Validade", + "validity.unavailable": "O status da organização está indisponível no momento.", + "validity.inactive": "Inativo", + "validity.validUntil": "Válido até", + "validity.graceEnds": "Carência termina", + "validity.state.inGrace": "Em carência", + "validity.state.expired": "Expirada", + "validity.state.active": "Ativa", + "validity.state.unknown": "Desconhecida", + "usage.title": "Uso por recurso", + "usage.description": "Consumo do mês atual em relação aos limites do seu plano.", + "usage.loadError": "Não foi possível carregar o uso. Tente atualizar.", + "usage.emptyTitle": "Nenhum uso registrado ainda", + "usage.emptyBody": "O consumo aparecerá aqui conforme os snapshots forem registrados neste período.", + "recent.title": "Faturas recentes", + "recent.description": "Suas cinco faturas mais recentes.", + "recent.seeAll": "Ver todas", + "recent.loadError": "Não foi possível carregar as faturas. Tente atualizar.", + "invoices.title": "Faturas", + "invoices.unit": "fatura", + "invoices.description": "O histórico de cobrança da sua organização, da mais recente para a mais antiga.", + "invoices.searchPlaceholder": "Buscar por número da fatura, status ou período…", + "invoices.loadError": "Falha ao carregar as faturas.", + "invoices.clearSearch": "Limpar busca", + "invoices.empty.foundTitle": "Nenhuma fatura encontrada", + "invoices.empty.emptyTitle": "Nenhuma fatura ainda", + "invoices.empty.foundBody": "Nada corresponde a \"{{term}}\". Tente outro termo ou limpe a busca.", + "invoices.empty.emptyBody": "Assim que sua organização for cobrada por um período, as faturas aparecerão aqui.", + "invoices.matched_one": "{{count}} fatura corresponde nesta página", + "invoices.matched_other": "{{count}} faturas correspondem nesta página", + "invoices.showing_one": "Exibindo {{shown}} de {{count}} fatura", + "invoices.showing_other": "Exibindo {{shown}} de {{count}} faturas", + "invoices.pageSuffix": " · página {{page}} de {{totalPages}}", + "invoices.col.number": "Fatura nº", + "invoices.col.customer": "Cliente", + "invoices.col.amount": "Valor", + "invoices.col.status": "Status", + "invoices.col.dueDate": "Vencimento", + "invoices.period": "período {{period}}", + "invoices.due": "vence {{date}}", + "invoices.paidOn": "pago {{date}}", + "invoices.status.Draft": "Rascunho", + "invoices.status.Issued": "Emitida", + "invoices.status.Paid": "Paga", + "invoices.status.Void": "Anulada", + "wallet.title": "Carteira do WhatsApp", + "wallet.description": "Seu saldo pré-pago para mensagens do WhatsApp. Solicite uma recarga e emitiremos uma fatura para creditar sua carteira.", + "wallet.currentBalance": "Saldo atual", + "wallet.lowEmpty": "Sua carteira está vazia. Solicite uma recarga para continuar enviando mensagens do WhatsApp.", + "wallet.lowSoon": "Seu saldo está acabando. Considere solicitar uma recarga em breve.", + "wallet.requestTitle": "Solicitar recarga", + "wallet.amountLabel": "Valor", + "wallet.amountHint": "Quanto adicionar à sua carteira, em {{currency}}.", + "wallet.amountPlaceholder": "100,00", + "wallet.noteLabel": "Observação", + "wallet.noteHint": "Opcional, algo que devemos saber sobre esta recarga.", + "wallet.notePlaceholder": "ex.: verba para a campanha de junho", + "wallet.submitting": "Enviando…", + "wallet.submit": "Solicitar recarga", + "wallet.toastRequested": "Recarga solicitada", + "wallet.toastRequestedDesc": "Emitiremos uma fatura para creditar sua carteira em breve.", + "wallet.toastFailed": "Falha ao solicitar recarga", + "wallet.requestsTitle": "Minhas solicitações de recarga", + "wallet.emptyTitle": "Nenhuma solicitação de recarga ainda", + "wallet.emptyBody": "Assim que você solicitar uma recarga, ela aparecerá aqui com o status: pendente, faturada ou concluída.", + "wallet.col.requested": "Solicitada", + "wallet.col.amount": "Valor", + "wallet.col.status": "Status", + "wallet.col.invoice": "Fatura", + "wallet.invoiceLink": "Fatura", + "wallet.viewInvoice": "Ver fatura", + "wallet.status.Pending": "Pendente", + "wallet.status.Invoiced": "Faturada", + "wallet.status.Completed": "Concluída", + "wallet.status.Rejected": "Rejeitada", + "wallet.status.Cancelled": "Cancelada", + "invoiceDetail.back": "Voltar para faturas", + "invoiceDetail.downloadFailed": "Falha no download", + "invoiceDetail.periodLabel": "Período {{period}}", + "invoiceDetail.preparing": "Preparando…", + "invoiceDetail.downloadPdf": "Baixar PDF", + "invoiceDetail.lineItems": "Itens", + "invoiceDetail.details": "Detalhes", + "invoiceDetail.notes": "Observações", + "invoiceDetail.noLineItems": "Nenhum item", + "invoiceDetail.noLineItemsBody": "Esta fatura não tem cobranças detalhadas.", + "invoiceDetail.col.description": "Descrição", + "invoiceDetail.col.qty": "Qtd", + "invoiceDetail.col.unitPrice": "Preço unitário", + "invoiceDetail.col.amount": "Valor", + "invoiceDetail.total": "Total", + "invoiceDetail.row.status": "Status", + "invoiceDetail.row.currency": "Moeda", + "invoiceDetail.row.period": "Período", + "invoiceDetail.row.created": "Criada", + "invoiceDetail.row.issued": "Emitida", + "invoiceDetail.row.due": "Vencimento", + "invoiceDetail.row.paid": "Paga", + "invoiceDetail.row.voided": "Anulada", + "invoiceDetail.row.periodStart": "Início do período", + "invoiceDetail.row.periodEnd": "Fim do período", + "invoiceDetail.notFoundTitle": "Fatura não encontrada", + "invoiceDetail.notFoundBody": "Ela pode não pertencer à sua organização, ou o link pode estar errado.", + "invoiceDetail.notFoundBack": "Voltar para faturas" +} diff --git a/clients/dashboard/src/locales/pt-BR/system.json b/clients/dashboard/src/locales/pt-BR/system.json new file mode 100644 index 0000000000..3d78b77ba7 --- /dev/null +++ b/clients/dashboard/src/locales/pt-BR/system.json @@ -0,0 +1,80 @@ +{ + "sessions.title": "Sessões ativas", + "sessions.description": "Todos os navegadores e dispositivos conectados a {{tenant}} agora. Atualiza a cada 30 segundos.", + "sessions.thisTenant": "esta organização", + "sessions.refresh": "Atualizar", + "sessions.searchPlaceholder": "Buscar por usuário, e-mail ou IP…", + "sessions.filter.visibility": "Visibilidade", + "sessions.filter.liveOnly": "Somente ativas", + "sessions.filter.includeInactive": "Incluir inativas", + "sessions.stats.active": "{{n}} ativas", + "sessions.stats.users": "{{n}} usuários", + "sessions.stats.mobile": "{{n}} celular", + "sessions.empty.foundTitle": "Nenhuma sessão encontrada", + "sessions.empty.activeTitle": "Nenhuma sessão ativa", + "sessions.empty.searchBody": "Nada corresponde a \"{{term}}\". Tente outro termo ou ative Incluir inativas para ver sessões expiradas.", + "sessions.empty.body": "As sessões aparecem quando os usuários entram. Ative Incluir inativas para ver sessões expiradas e revogadas.", + "sessions.empty.clearSearch": "Limpar busca", + "sessions.empty.refresh": "Atualizar", + "sessions.found_one": "{{count}} sessão encontrada", + "sessions.found_other": "{{count}} sessões encontradas", + "sessions.col.user": "Usuário", + "sessions.col.device": "Dispositivo", + "sessions.col.ip": "IP", + "sessions.col.lastActivity": "Última atividade", + "sessions.col.actions": "Ações", + "sessions.badge.you": "Você", + "sessions.badge.inactive": "Inativa", + "sessions.unknownUser": "Usuário desconhecido", + "sessions.unknownBrowser": "Navegador desconhecido", + "sessions.allDevices": "Todos os dispositivos", + "sessions.all": "Todos", + "sessions.revoke": "Revogar", + "sessions.revoking": "Revogando…", + "sessions.revokeAllTitle": "Desconectar este usuário de todos os dispositivos", + "sessions.toast.revoked": "Sessão revogada", + "sessions.toast.revokeError": "Não foi possível revogar a sessão.", + "sessions.toast.revokedAll_one": "{{count}} sessão revogada", + "sessions.toast.revokedAll_other": "{{count}} sessões revogadas", + "sessions.toast.revokeAllError": "Não foi possível revogar as sessões.", + "trash.title": "Lixeira", + "trash.description": "Registros excluídos, mantidos indefinidamente até você restaurá-los. Restaurar um registro o traz de volta à lista original com o mesmo ID e histórico intactos.", + "trash.noAccessTitle": "Nenhuma lixeira disponível", + "trash.noAccessBody": "Você não tem permissão para restaurar registros excluídos nesta organização. Fale com um administrador se acha que deveria ter.", + "trash.sections": "Seções da lixeira", + "trash.tab.products": "Produtos", + "trash.tab.brands": "Marcas", + "trash.tab.categories": "Categorias", + "trash.tab.tickets": "Chamados", + "trash.tab.files": "Arquivos", + "trash.count": "{{n}} {{entity}} na lixeira", + "trash.emptyTitle": "A lixeira de {{entity}} está vazia", + "trash.emptyBody": "{{entity}} excluídos ficam aqui pelo tempo que você quiser, sem limpeza automática. Tudo que você remover da lista principal pode ser recuperado.", + "trash.backTo": "Voltar para {{entity}}", + "trash.col.entity": "Entidade", + "trash.col.deletedBy": "Excluído por", + "trash.col.deletedAt": "Excluído em", + "trash.col.actions": "Ações", + "trash.action.restore": "Restaurar", + "trash.action.restoring": "Restaurando…", + "trash.by": "por {{id}}…", + "trash.restore.title": "Restaurar {{entity}}?", + "trash.restore.body": "voltará para a lista de {{entity}} com o mesmo ID e histórico intactos.", + "trash.restore.cancel": "Cancelar", + "trash.restore.confirm": "Restaurar {{entity}}", + "trash.entityPlural.products": "produtos", + "trash.entityPlural.brands": "marcas", + "trash.entityPlural.categories": "categorias", + "trash.entityPlural.tickets": "chamados", + "trash.entityPlural.files": "arquivos", + "trash.entitySingular.products": "produto", + "trash.entitySingular.brands": "marca", + "trash.entitySingular.categories": "categoria", + "trash.entitySingular.tickets": "chamado", + "trash.entitySingular.files": "arquivo", + "trash.toast.products": "Produto restaurado", + "trash.toast.brands": "Marca restaurada", + "trash.toast.categories": "Categoria restaurada", + "trash.toast.tickets": "Chamado restaurado", + "trash.toast.files": "Arquivo restaurado" +} diff --git a/clients/dashboard/src/locales/pt-BR/tickets.json b/clients/dashboard/src/locales/pt-BR/tickets.json new file mode 100644 index 0000000000..a2bdd11b63 --- /dev/null +++ b/clients/dashboard/src/locales/pt-BR/tickets.json @@ -0,0 +1,119 @@ +{ + "status.Open": "Aberto", + "status.InProgress": "Em andamento", + "status.Resolved": "Resolvido", + "status.Closed": "Fechado", + "priority.Low": "Baixa", + "priority.Medium": "Média", + "priority.High": "Alta", + "priority.Critical": "Crítica", + "list.title": "Chamados", + "list.unit": "chamado", + "list.description": "Trabalho em aberto, ordenado por prioridade. A central onde os problemas chegam, ganham dono e são entregues.", + "list.new": "Novo chamado", + "list.searchPlaceholder": "Busque por número, título ou descrição…", + "list.count_one": "{{count}} chamado encontrado", + "list.count_other": "{{count}} chamados encontrados", + "filter.status": "Filtro de status", + "filter.priority": "Filtro de prioridade", + "filter.all": "Todos", + "filter.any": "Qualquer", + "col.subject": "Assunto", + "col.priority": "Prioridade", + "col.status": "Status", + "col.assignee": "Responsável", + "col.updated": "Atualizado", + "empty.searchTitle": "Nenhum chamado encontrado", + "empty.title": "Nenhum chamado ainda", + "empty.searchBodyTerm": "Nada corresponde a \"{{term}}\". Tente outro termo ou limpe os filtros.", + "empty.searchBody": "Nenhum chamado corresponde aos filtros atuais.", + "empty.body": "Abra o primeiro chamado para começar a acompanhar o trabalho. Chamados têm status, prioridade, um responsável opcional e um fluxo de comentários.", + "empty.clearFilters": "Limpar filtros", + "empty.openTicket": "Abrir chamado", + "aria.openTicket": "Abrir chamado {{title}}", + "row.unassigned": "Sem responsável", + "create.toastOpened": "Chamado aberto", + "create.title": "Abrir um chamado", + "create.descPrefix": "Chamados chegam à central como ", + "create.descSuffix": ". Atribua um para iniciá-lo; resolva-o com uma nota quando o trabalho estiver concluído.", + "create.fieldTitle": "Título", + "create.fieldDescription": "Descrição", + "create.fieldPriority": "Prioridade", + "create.placeholderTitle": "Descreva brevemente o problema ou a solicitação", + "create.placeholderDescription": "Passos para reproduzir, comportamento esperado, qualquer outra coisa útil", + "create.cancel": "Cancelar", + "create.opening": "Abrindo…", + "create.submit": "Abrir chamado", + "detail.back": "Voltar aos chamados", + "detail.hero.openedBy": "aberto {{rel}} por {{name}}", + "detail.hero.refresh": "Atualizar", + "detail.hero.reassign": "Reatribuir", + "detail.hero.assign": "Atribuir", + "detail.hero.resolve": "Resolver", + "detail.hero.reopen": "Reabrir", + "detail.hero.toastReopened": "Chamado reaberto", + "detail.stat.comment_one": "comentário", + "detail.stat.comment_other": "comentários", + "detail.stat.updated": "atualizado", + "detail.stat.resolved": "resolvido", + "detail.meta.assignee": "Responsável:", + "detail.meta.reporter": "Solicitante:", + "detail.meta.created": "Criado:", + "detail.unassigned": "Sem responsável", + "detail.description.title": "Descrição", + "detail.description.empty": "Nenhuma descrição registrada. Os comentários abaixo conduzem a conversa.", + "detail.description.resolution": "Resolução", + "detail.comments.title": "Conversa", + "detail.comments.none": "Nenhum comentário ainda", + "detail.comments.count_one": "{{count}} comentário", + "detail.comments.count_other": "{{count}} comentários", + "detail.comments.toastPosted": "Comentário publicado", + "detail.comments.empty": "Nenhum comentário ainda. Adicione a primeira nota abaixo — visível para qualquer pessoa com acesso de Visualização.", + "detail.comments.ariaAdd": "Adicionar um comentário", + "detail.comments.placeholderDisabled": "Reabra o chamado para adicionar um novo comentário.", + "detail.comments.placeholder": "Adicione um comentário ao fluxo…", + "detail.comments.charCount": "{{count}} / 8192", + "detail.comments.posting": "Publicando…", + "detail.comments.post": "Publicar comentário", + "detail.comments.self": "Você", + "detail.properties.title": "Propriedades", + "detail.properties.reporter": "Solicitante", + "detail.properties.assignee": "Responsável", + "detail.properties.status": "Status", + "detail.properties.priority": "Prioridade", + "detail.properties.created": "Criado", + "detail.properties.updated": "Atualizado", + "detail.properties.resolved": "Resolvido", + "detail.resolve.toast": "Chamado resolvido", + "detail.resolve.desc": "As notas de resolução ficam no chamado e são exibidas no painel de descrição. Deixe em branco se não houver nada a acrescentar.", + "detail.resolve.fieldNote": "Nota de resolução", + "detail.resolve.hintNote": "Opcional — deixe em branco se não houver nada a acrescentar.", + "detail.resolve.placeholder": "O que mudou? Causa raiz? Algo que os revisores devam saber.", + "detail.resolve.cancel": "Cancelar", + "detail.resolve.resolving": "Resolvendo…", + "detail.resolve.submit": "Marcar como resolvido", + "detail.assign.toastAssigned": "Chamado atribuído", + "detail.assign.toastUnassigned": "Chamado desatribuído", + "detail.assign.descPrefix": "Cole um ID de usuário. Assumir o chamado o transiciona para ", + "detail.assign.descMid": "; limpar o responsável de um chamado em andamento o envia de volta para ", + "detail.assign.descSuffix": ".", + "detail.assign.fieldAssignee": "Responsável", + "detail.assign.hintAssignee": "Busque por nome ou e-mail. Limpe a seleção para desatribuir.", + "detail.assign.warnPrefix": "Limpar o responsável vai ", + "detail.assign.warnEmphasis": "desatribuir", + "detail.assign.warnSuffix": " o chamado.", + "detail.assign.cancel": "Cancelar", + "detail.assign.saving": "Salvando…", + "detail.assign.submit": "Salvar atribuição", + "detail.notFound.title": "Este chamado não existe mais.", + "detail.notFound.body": "Pode ter sido excluído. Verifique a lixeira ou volte à central de chamados.", + "detail.notFound.back": "Voltar aos chamados", + "detail.notFound.desk": "Central de chamados", + "userPicker.searchPlaceholder": "Busque por nome ou e-mail…", + "userPicker.searchReassign": "Busque para reatribuir…", + "userPicker.clear": "Limpar", + "userPicker.clearSelection": "Limpar seleção", + "userPicker.searching": "Buscando por \"{{term}}\"…", + "userPicker.noMatch": "Nenhum usuário corresponde a \"{{term}}\".", + "userPicker.results": "Resultados da busca" +} diff --git a/clients/dashboard/src/main.tsx b/clients/dashboard/src/main.tsx index a9db7cf51e..f75d2b3ee3 100644 --- a/clients/dashboard/src/main.tsx +++ b/clients/dashboard/src/main.tsx @@ -2,22 +2,27 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { App } from "@/App"; import { installImpersonationFromHash } from "@/auth/impersonation-handoff"; -import { loadRuntimeConfig } from "@/env"; +import { env, loadRuntimeConfig } from "@/env"; +import { initI18n } from "@/i18n"; import "@/styles/globals.css"; // Runtime config must resolve before React mounts so env.apiBase reads // inside components see the right value on first paint. await loadRuntimeConfig(); +// i18n boots AFTER config so fallbackLng can read the per-deployment default; +// the persisted/detected locale still wins over it. +await initI18n(env.defaultLanguage); + const rootElement = document.getElementById("root"); if (!rootElement) { throw new Error("Root element '#root' not found"); } // Cross-app impersonation handoff — must run BEFORE createRoot so the -// installed token is visible to AuthProvider on first paint. See the -// helper docstring for the why. -installImpersonationFromHash(); +// installed token and the operator's language are visible to AuthProvider on +// first paint. See the helper docstring for the why. +await installImpersonationFromHash(); createRoot(rootElement).render( diff --git a/clients/dashboard/src/pages/activity.tsx b/clients/dashboard/src/pages/activity.tsx index 067d2a30a2..bfdccea4a2 100644 --- a/clients/dashboard/src/pages/activity.tsx +++ b/clients/dashboard/src/pages/activity.tsx @@ -1,6 +1,9 @@ import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { Activity, Inbox } from "lucide-react"; +import i18n from "@/i18n"; import { useSseEvents, useSseStatus, type SseEvent } from "@/sse/sse-context"; +import { formatNumber } from "@/lib/list-helpers"; import { Badge } from "@/components/ui/badge"; import { EntityEmpty, @@ -12,15 +15,14 @@ import { type EntityStatusTone, } from "@/components/list"; -const timeFmt = new Intl.DateTimeFormat("en-US", { - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hour12: false, -}); - function formatTime(ts: number) { - return timeFmt.format(new Date(ts)); + // 24h hh:mm:ss in the active locale — no list-helper covers second precision. + return new Intl.DateTimeFormat(i18n.language, { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }).format(new Date(ts)); } function payloadSummary(data: unknown, raw: string): string { @@ -67,6 +69,7 @@ function entityLabel(data: unknown): string { const DESKTOP_GRID = "grid-cols-[1fr_240px_120px]"; export function ActivityPage() { + const { t } = useTranslation("activity"); const { status, eventCount } = useSseStatus(); const { events } = useSseEvents(); @@ -77,37 +80,37 @@ export function ActivityPage() {
    {isLive ? ( - streaming + {t("status.streaming")} ) : status === "error" ? ( - offline + {t("status.offline")} ) : ( - {status} + {t(`status.${status}`)} )} {items.length === 0 ? ( ) : (

    - {items.length} event{items.length === 1 ? "" : "s"} shown + {t("shown", { count: items.length })} - · {new Intl.NumberFormat("en-US").format(eventCount)} total + · {t("totalCount", { total: formatNumber(eventCount) })}

    @@ -118,7 +121,7 @@ export function ActivityPage() { role="log" aria-live="polite" aria-relevant="additions" - aria-label="Activity events" + aria-label={t("ariaLabel")} > {items.map((ev) => ( @@ -131,12 +134,12 @@ export function ActivityPage() { role="log" aria-live="polite" aria-relevant="additions" - aria-label="Activity events" + aria-label={t("ariaLabel")} > - Action - Entity - Time + {t("col.action")} + {t("col.entity")} + {t("col.time")} {items.map((ev, i) => ( = { + None: "eventType.none", + EntityChange: "eventType.entity", + Security: "eventType.security", + Activity: "eventType.activity", + Exception: "eventType.exception", +}; +const SEVERITY_KEY: Record = { + None: "severity.none", + Trace: "severity.trace", + Debug: "severity.debug", + Information: "severity.info", + Warning: "severity.warn", + Error: "severity.error", + Critical: "severity.critical", +}; +const TAG_KEY_BY_NAME: Record = { + "PII masked": "tag.piiMasked", + "Quota exceeded": "tag.quotaExceeded", + Sampled: "tag.sampled", + Retained: "tag.retained", + "Health check": "tag.healthCheck", + Auth: "tag.auth", + Authz: "tag.authz", +}; + // ──────────────────────────────────────────────────────────────────────── // Time-range presets — keep the SQL window predictable from the UI. // ──────────────────────────────────────────────────────────────────────── @@ -137,14 +164,14 @@ function fmtIsoDense(iso: string): { date: string; time: string } { function fmtRelative(iso: string, now: number = Date.now()): string { const delta = Math.max(0, Math.floor((now - Date.parse(iso)) / 1000)); - if (delta < 5) return "just now"; - if (delta < 60) return `${delta}s ago`; + if (delta < 5) return i18n.t("relative.justNow"); + if (delta < 60) return i18n.t("relative.secondsAgo", { n: delta }); const m = Math.floor(delta / 60); - if (m < 60) return `${m}m ago`; + if (m < 60) return i18n.t("relative.minutesAgo", { n: m }); const h = Math.floor(m / 60); - if (h < 24) return `${h}h ago`; + if (h < 24) return i18n.t("relative.hoursAgo", { n: h }); const days = Math.floor(h / 24); - return `${days}d ago`; + return i18n.t("relative.daysAgo", { n: days }); } // ──────────────────────────────────────────────────────────────────────── @@ -185,6 +212,7 @@ const INITIAL_FILTERS: FilterState = { // ──────────────────────────────────────────────────────────────────────── export function AuditsPage() { + const { t } = useTranslation("audits"); const [filters, setFilters] = useState(INITIAL_FILTERS); const [searchInput, setSearchInput] = useState(""); const [drawerId, setDrawerId] = useState(null); @@ -281,10 +309,10 @@ export function AuditsPage() {
    @@ -306,7 +334,7 @@ export function AuditsPage() { {/* Filter bar — preserved verbatim (range presets, chips, advanced) */} @@ -338,17 +366,17 @@ export function AuditsPage() { className="flex items-start gap-2 rounded-lg border border-[oklch(from_var(--color-destructive)_l_c_h_/_0.30)] bg-[oklch(from_var(--color-destructive)_l_c_h_/_0.06)] px-3 py-2 text-sm text-[var(--color-destructive)]" > - {(auditsQuery.error as Error)?.message ?? "Failed to load audits"} + {(auditsQuery.error as Error)?.message ?? t("page.loadError")}
    ) : items.length === 0 ? ( 0 || filters.search ? ( ) : undefined } @@ -357,7 +385,7 @@ export function AuditsPage() {

    - {paged?.totalCount ?? 0} event{(paged?.totalCount ?? 0) !== 1 ? "s" : ""} found + {t("page.count", { count: paged?.totalCount ?? 0 })}

    {auditsQuery.isFetching && ( @@ -378,10 +406,10 @@ export function AuditsPage() { {/* Desktop list */} - Actor - Event - Severity - Timestamp + {t("col.actor")} + {t("col.event")} + {t("col.severity")} + {t("col.timestamp")} {items.map((row, i) => ( void; }) { + const { t } = useTranslation("audits"); const ts = fmtIsoDense(row.occurredAtUtc); const Icon = eventTypeIcon(row.eventType); const tone = severityTone(row.severity); const toneColor = severityColorVar(row.severity); - const actor = row.userName ?? (row.userId ? `${row.userId.slice(0, 8)}…` : "System"); + const actor = row.userName ?? (row.userId ? `${row.userId.slice(0, 8)}…` : t("actor.system")); return (
    - {AUDIT_EVENT_TYPE_LABELS[row.eventType]} + {t(EVENT_TYPE_KEY[row.eventType])}
    @@ -499,11 +528,12 @@ function AuditDesktopRow({ isLast: boolean; onOpen: () => void; }) { + const { t } = useTranslation("audits"); const ts = fmtIsoDense(row.occurredAtUtc); const Icon = eventTypeIcon(row.eventType); const tone = severityTone(row.severity); const toneColor = severityColorVar(row.severity); - const actor = row.userName ?? (row.userId ? `${row.userId.slice(0, 8)}…` : "System"); + const actor = row.userName ?? (row.userId ? `${row.userId.slice(0, 8)}…` : t("actor.system")); const tags = decodeTags(row.tags); return ( @@ -541,7 +571,7 @@ function AuditDesktopRow({ className="inline-flex shrink-0 items-center gap-0.5 rounded-full bg-[var(--color-muted)] px-1.5 py-0 font-mono text-[10px] text-[var(--color-muted-foreground)]" > - {name} + {t(TAG_KEY_BY_NAME[name])} ))} {tags.length > 2 && ( @@ -558,7 +588,7 @@ function AuditDesktopRow({ - {AUDIT_SEVERITY_LABELS[row.severity]} + {t(SEVERITY_KEY[row.severity])}
    @@ -595,6 +625,7 @@ function SummaryStrip({ topSources: Array<[string, number]>; range: RangeKey; }) { + const { t } = useTranslation("audits"); if (loading || !byType || !bySeverity) { return ( @@ -609,7 +640,7 @@ function SummaryStrip({ // Stack-bar segments — fall back to a single muted segment when the // window has zero activity, so the strip still has visual mass. - const t = Math.max(1, total); + const denom = Math.max(1, total); const segments = [ { key: "activity", value: byType.activity, color: "var(--color-primary)" }, { key: "entity", value: byType.entity, color: "var(--color-info)" }, @@ -622,12 +653,12 @@ function SummaryStrip({
    - Window {range} + {t("summary.window", { range })}
    - {new Intl.NumberFormat("en-US").format(total)} + {new Intl.NumberFormat(i18n.language).format(total)}
    -
    events
    +
    {t("summary.events")}
    @@ -637,7 +668,7 @@ function SummaryStrip({ key={s.key} title={`${s.key}: ${s.value}`} style={{ - width: `${(s.value / t) * 100}%`, + width: `${(s.value / denom) * 100}%`, backgroundColor: s.color, transition: "width 600ms var(--ease-out-cubic)", }} @@ -645,19 +676,19 @@ function SummaryStrip({ ))}
    - - - - + + + + - - + +
    - Top sources + {t("summary.topSources")}
    {topSources.length === 0 && ( @@ -719,6 +750,7 @@ function FilterBar({ onSearchInput: (v: string) => void; onReset: () => void; }) { + const { t } = useTranslation("audits"); const [advancedOpen, setAdvancedOpen] = useState(false); return ( @@ -749,14 +781,14 @@ function FilterBar({ onSearchInput(e.target.value)} - placeholder="Search payload, source, user…" + placeholder={t("page.searchPlaceholder")} className="pl-9" /> {searchInput && (
    @@ -805,7 +837,7 @@ function FilterBar({ onClick={onReset} className="font-mono text-[11px] uppercase tracking-[0.08em] text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)]" > - Reset + {t("filter.reset")} )}
    @@ -813,24 +845,24 @@ function FilterBar({ {/* Row 2: event type + severity chips */}
    - Type + {t("filter.type")} - {[AuditEventType.Activity, AuditEventType.Security, AuditEventType.EntityChange, AuditEventType.Exception].map((t) => ( + {[AuditEventType.Activity, AuditEventType.Security, AuditEventType.EntityChange, AuditEventType.Exception].map((et) => ( - onPatch({ eventType: filters.eventType === t ? null : t }) + onPatch({ eventType: filters.eventType === et ? null : et }) } > - {AUDIT_EVENT_TYPE_LABELS[t]} + {t(EVENT_TYPE_KEY[et])} ))} - Severity + {t("filter.severity")} {[AuditSeverity.Information, AuditSeverity.Warning, AuditSeverity.Error, AuditSeverity.Critical].map((s) => ( - {AUDIT_SEVERITY_LABELS[s]} + {t(SEVERITY_KEY[s])} ))}
    @@ -850,49 +882,49 @@ function FilterBar({ {advancedOpen && (
    onPatch({ source: v })} /> onPatch({ user: v })} /> onPatch({ correlation: v })} /> onPatch({ trace: v })} />
    - Tags + {t("filter.tags")}
    - {AUDIT_TAG_LABELS.map((t) => { - const active = (filters.tagsMask & t.flag) !== 0; + {AUDIT_TAG_LABELS.map((tag) => { + const active = (filters.tagsMask & tag.flag) !== 0; return ( onPatch({ tagsMask: active - ? filters.tagsMask & ~t.flag - : filters.tagsMask | t.flag, + ? filters.tagsMask & ~tag.flag + : filters.tagsMask | tag.flag, }) } > - {t.name} + {t(TAG_KEY_BY_NAME[tag.name])} ); })} @@ -988,6 +1020,7 @@ function AuditDetailDrawer({ onJumpCorrelation: (id: string) => void; onJumpTrace: (id: string) => void; }) { + const { t } = useTranslation("audits"); const open = auditId !== null; const detail = useQuery({ @@ -1012,9 +1045,9 @@ function AuditDetailDrawer({ "duration-[var(--duration-default)]", )} > - Audit detail + {t("drawer.srTitle")} - Full payload, identifiers, and related actions for the selected audit event. + {t("drawer.srDescription")}
    @@ -1037,7 +1070,7 @@ function AuditDetailDrawer({
    @@ -1093,31 +1127,31 @@ function DrawerHeader({ detail, loading }: { detail?: AuditDetailDto; loading: b - {AUDIT_SEVERITY_LABELS[detail.severity]} + {t(SEVERITY_KEY[detail.severity])} - {AUDIT_EVENT_TYPE_LABELS[detail.eventType]} + {t(EVENT_TYPE_KEY[detail.eventType])}

    - {detail.source ?? "Audit event"} + {detail.source ?? t("drawer.eventFallback")}

    - {ts.date} {ts.time} UTC + {ts.date} {ts.time} {t("drawer.utc")} · {fmtRelative(detail.occurredAtUtc)}
    {tags.length > 0 && (
    - {tags.map((t) => ( + {tags.map((tag) => ( - {t} + {t(TAG_KEY_BY_NAME[tag])} ))}
    @@ -1138,27 +1172,28 @@ function DrawerBody({ onJumpCorrelation: (id: string) => void; onJumpTrace: (id: string) => void; }) { + const { t } = useTranslation("audits"); return (
    {/* Identity grid */}
    - Identity + {t("drawer.section.identity")}
    - - - - + + + +
    {/* Trace grid + jump links */}
    - Trace + {t("drawer.section.trace")}
    - - - - + + + +
    {detail.correlationId && ( @@ -1167,12 +1202,12 @@ function DrawerBody({ size="sm" onClick={() => onJumpCorrelation(detail.correlationId!)} > - All by correlation + {t("drawer.allByCorrelation")} )} {detail.traceId && ( )}
    @@ -1193,7 +1228,7 @@ function DrawerBody({ {/* Payload */}
    - Payload + {t("drawer.section.payload")}
    @@ -1203,24 +1238,24 @@ function DrawerBody({
     
           {/* Reception window */}
           
    - Pipeline + {t("drawer.section.pipeline")}
    - +
    @@ -1246,12 +1281,13 @@ function DrawerSkeleton() { } function DrawerError({ message }: { message?: string }) { + const { t } = useTranslation("audits"); return (
    -
    Could not load audit
    +
    {t("drawer.error.title")}

    - {message ?? "The server returned an error fetching this audit. The record may have been purged by the retention job."} + {message ?? t("drawer.error.body")}

    ); @@ -1287,6 +1323,7 @@ function RelatedEventsSection({ currentOccurredAtUtc: string; onJumpAudit: (id: string) => void; }) { + const { t } = useTranslation("audits"); const related = useQuery({ queryKey: ["audit", "by-correlation", correlationId], queryFn: ({ signal }) => getAuditsByCorrelation(correlationId, {}, signal), @@ -1309,10 +1346,10 @@ function RelatedEventsSection({ return (
    - Related events + {t("drawer.section.related")} {!related.isLoading && ( - {sorted.length} on this correlation + {t("drawer.related.count", { count: sorted.length })} )}
    @@ -1325,8 +1362,7 @@ function RelatedEventsSection({
    ) : others.length === 0 ? (

    - No other events share this correlation. The full lifecycle of this - request is contained in the payload above. + {t("drawer.related.empty")}

    ) : (
      @@ -1343,7 +1379,7 @@ function RelatedEventsSection({ const deltaSec = Math.round((Date.parse(row.occurredAtUtc) - currentMs) / 1000); const deltaLabel = isCurrent - ? "this event" + ? t("drawer.related.thisEvent") : deltaSec === 0 ? "0s" : deltaSec > 0 @@ -1375,10 +1411,10 @@ function RelatedEventsSection({ - {row.source ?? AUDIT_EVENT_TYPE_LABELS[row.eventType]} + {row.source ?? t(EVENT_TYPE_KEY[row.eventType])} - {AUDIT_SEVERITY_LABELS[row.severity]} + {t(SEVERITY_KEY[row.severity])} @@ -1399,6 +1435,7 @@ function RelatedEventsSection({ } function CopyButton({ value }: { value: string }) { + const { t } = useTranslation("audits"); const [copied, setCopied] = useState(false); return ( ); } diff --git a/clients/dashboard/src/pages/auth/confirm-email.tsx b/clients/dashboard/src/pages/auth/confirm-email.tsx index e2b5b0a616..48ddba6ae1 100644 --- a/clients/dashboard/src/pages/auth/confirm-email.tsx +++ b/clients/dashboard/src/pages/auth/confirm-email.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import { Link, useSearchParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; import { AlertCircle, ArrowRight, CheckCircle2, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { AuthHeadline, AuthShell } from "@/components/auth/auth-shell"; @@ -24,6 +25,7 @@ type Status = | { kind: "error"; message: string }; export function ConfirmEmailPage() { + const { t } = useTranslation("auth"); const [params] = useSearchParams(); const userId = params.get("userId") ?? ""; const code = params.get("code") ?? ""; @@ -40,8 +42,7 @@ export function ConfirmEmailPage() { if (malformed) { setStatus({ kind: "error", - message: - "This confirmation link is missing required parameters. It may have been clipped by your email client.", + message: t("confirm.malformed"), }); return; } @@ -55,7 +56,7 @@ export function ConfirmEmailPage() { message: typeof message === "string" && message.length > 0 ? message - : "Your email is confirmed. You can now sign in.", + : t("confirm.successFallback"), }); }) .catch((err: unknown) => { @@ -70,7 +71,7 @@ export function ConfirmEmailPage() { return () => { cancelled = true; }; - }, [userId, code, tenant, malformed]); + }, [userId, code, tenant, malformed, t]); return ( - ← Back to sign in + {t("confirm.backLink")} } > @@ -94,9 +95,9 @@ export function ConfirmEmailPage() {
    - +

    - One moment — checking the confirmation token with the server. + {t("confirm.verifyingBody")}

    @@ -113,14 +114,14 @@ export function ConfirmEmailPage() {
    - +

    {status.message}

    @@ -138,24 +139,27 @@ export function ConfirmEmailPage() {
    - +

    {status.message}

    - The link may have expired or been used already. If you've signed - in since this email was sent, you can ignore it. + {t("confirm.errorHint")}

    diff --git a/clients/dashboard/src/pages/auth/forgot-password.tsx b/clients/dashboard/src/pages/auth/forgot-password.tsx index 1894adfed7..e5374b6c65 100644 --- a/clients/dashboard/src/pages/auth/forgot-password.tsx +++ b/clients/dashboard/src/pages/auth/forgot-password.tsx @@ -1,5 +1,6 @@ import { useState, type FormEvent } from "react"; import { Link, Navigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; import { useMutation } from "@tanstack/react-query"; import { AlertCircle, @@ -30,6 +31,7 @@ import { env } from "@/env"; * inbox" success state after a 2xx. */ export function ForgotPasswordPage() { + const { t } = useTranslation("auth"); const { isAuthenticated } = useAuth(); const [email, setEmail] = useState(""); const [tenant, setTenant] = useState(env.defaultTenant); @@ -65,12 +67,12 @@ export function ForgotPasswordPage() { - Remembered it?{" "} + {t("forgot.remembered")}{" "} - Sign in + {t("forgot.signIn")} } @@ -86,23 +88,23 @@ export function ForgotPasswordPage() {
    - +

    - If an account exists for{" "} - {email} in - tenant{" "} - {tenant}, - a one-time reset link is on its way. The link expires in 30 minutes. + {t("forgot.successPre")}{" "} + {email}{" "} + {t("forgot.successMid")}{" "} + {tenant} + {t("forgot.successPost")}

    • - Didn't get it? Wait a minute, then check spam. + {t("forgot.tip1")}
    • - Still nothing? Confirm the email + tenant and try again. + {t("forgot.tip2")}
    @@ -114,11 +116,11 @@ export function ForgotPasswordPage() { setError(null); }} > - Try a different address + {t("forgot.tryDifferent")}
    @@ -126,9 +128,9 @@ export function ForgotPasswordPage() { ) : ( <>
    - +

    - Enter the email you sign in with. We'll send a one-time link. + {t("forgot.subtitle")}

    @@ -138,7 +140,7 @@ export function ForgotPasswordPage() { htmlFor="reset-tenant" className="block text-[11.5px] font-semibold uppercase tracking-wider text-[var(--color-muted-foreground)]" > - Tenant + {t("forgot.tenant")}
    @@ -146,7 +148,7 @@ export function ForgotPasswordPage() { id="reset-tenant" value={tenant} onChange={(e) => setTenant(e.target.value)} - placeholder="root" + placeholder={t("forgot.tenantPlaceholder")} autoComplete="organization" required aria-invalid={error ? true : undefined} @@ -161,7 +163,7 @@ export function ForgotPasswordPage() { htmlFor="reset-email" className="block text-[11.5px] font-semibold uppercase tracking-wider text-[var(--color-muted-foreground)]" > - Email + {t("forgot.email")}
    @@ -170,7 +172,7 @@ export function ForgotPasswordPage() { type="email" value={email} onChange={(e) => setEmail(e.target.value)} - placeholder="you@example.com" + placeholder={t("forgot.emailPlaceholder")} autoComplete="email" required autoFocus @@ -206,11 +208,11 @@ export function ForgotPasswordPage() { {mutation.isPending ? ( <> - Sending link… + {t("forgot.submitting")} ) : ( <> - Send reset link + {t("forgot.submit")} )} diff --git a/clients/dashboard/src/pages/auth/reset-password.tsx b/clients/dashboard/src/pages/auth/reset-password.tsx index 153f263fc9..93aaa4156e 100644 --- a/clients/dashboard/src/pages/auth/reset-password.tsx +++ b/clients/dashboard/src/pages/auth/reset-password.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState, type FormEvent } from "react"; import { Link, Navigate, useNavigate, useSearchParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; import { useMutation } from "@tanstack/react-query"; import { AlertCircle, @@ -50,25 +51,23 @@ function scorePassword(value: string): Strength | null { return "strong"; } -const STRENGTH_META: Record = { +const STRENGTH_META: Record = { weak: { - label: "Weak", fill: "bg-[var(--color-destructive)]", bar: "w-1/3", }, fair: { - label: "Fair", fill: "bg-[var(--color-warning)]", bar: "w-2/3", }, strong: { - label: "Strong", fill: "bg-[var(--color-success)]", bar: "w-full", }, }; export function ResetPasswordPage() { + const { t } = useTranslation("auth"); const { isAuthenticated } = useAuth(); const navigate = useNavigate(); const [params] = useSearchParams(); @@ -92,8 +91,8 @@ export function ResetPasswordPage() { const mutation = useMutation({ mutationFn: () => resetPassword({ email, password, token, tenant }), onSuccess: () => { - toast.success("Password updated", { - description: "Sign in with your new password to continue.", + toast.success(t("reset.toastTitle"), { + description: t("reset.toastDesc"), }); navigate("/login", { replace: true }); }, @@ -121,11 +120,11 @@ export function ResetPasswordPage() { const onSubmit = (e: FormEvent) => { e.preventDefault(); if (!matches) { - setError("Passwords don't match."); + setError(t("reset.errMismatch")); return; } if (password.length < 8) { - setError("Use at least 8 characters."); + setError(t("reset.errTooShort")); return; } mutation.mutate(); @@ -135,12 +134,12 @@ export function ResetPasswordPage() { - Changed your mind?{" "} + {t("reset.changedMind")}{" "} - Sign in + {t("reset.signIn")} } @@ -148,25 +147,25 @@ export function ResetPasswordPage() { {malformed ? (
    - +

    - The reset link is missing one of{" "} - token,{" "} - email, or{" "} - tenant. - Some email clients clip long URLs — try copy-pasting the full - link from the original email into your browser's address bar. + {t("reset.incompletePre")}{" "} + {t("reset.fieldToken")},{" "} + {t("reset.fieldEmail")},{" "} + {t("reset.or")}{" "} + {t("reset.fieldTenant")}.{" "} + {t("reset.incompletePost")}

    @@ -174,11 +173,11 @@ export function ResetPasswordPage() { ) : ( <>
    - +

    - Resetting password for{" "} - {email} on{" "} - {tenant}. + {t("reset.subtitlePre")}{" "} + {email} {t("reset.subtitleMid")}{" "} + {tenant}{t("reset.subtitlePost")}

    @@ -188,7 +187,7 @@ export function ResetPasswordPage() { htmlFor="new-password" className="block text-[11.5px] font-semibold uppercase tracking-wider text-[var(--color-muted-foreground)]" > - New password + {t("reset.newPassword")}
    setPassword(e.target.value)} - placeholder="At least 8 characters" + placeholder={t("reset.newPasswordPlaceholder")} required autoComplete="new-password" autoFocus @@ -208,7 +207,7 @@ export function ResetPasswordPage() {
    - {STRENGTH_META[strength].label} + {t(`reset.strength_${strength}`)}
    )} @@ -238,7 +237,7 @@ export function ResetPasswordPage() { htmlFor="confirm-password" className="block text-[11.5px] font-semibold uppercase tracking-wider text-[var(--color-muted-foreground)]" > - Confirm password + {t("reset.confirmPassword")}
    setConfirm(e.target.value)} - placeholder="Re-enter password" + placeholder={t("reset.confirmPasswordPlaceholder")} required autoComplete="new-password" minLength={8} @@ -257,7 +256,7 @@ export function ResetPasswordPage() {
    )}
    @@ -309,12 +308,12 @@ export function ResetPasswordPage() { {mutation.isPending ? ( <> - Updating password… + {t("reset.submitting")} ) : ( <> - Set new password + {t("reset.submit")} )} diff --git a/clients/dashboard/src/pages/catalog/brands.tsx b/clients/dashboard/src/pages/catalog/brands.tsx index d7b8b9616e..86b77df5aa 100644 --- a/clients/dashboard/src/pages/catalog/brands.tsx +++ b/clients/dashboard/src/pages/catalog/brands.tsx @@ -19,6 +19,7 @@ import { Trash2, } from "lucide-react"; import { toast } from "sonner"; +import { useTranslation } from "react-i18next"; import { createBrand, deleteBrand, @@ -74,6 +75,7 @@ type EditorState = // ─────────────────────────────────────────────────────────────────────── export function BrandsPage() { + const { t } = useTranslation("catalog"); const [search, setSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState(""); const [pageNumber, setPageNumber] = useState(1); @@ -113,24 +115,24 @@ export function BrandsPage() {
    {query.isLoading && items.length === 0 ? ( @@ -138,13 +140,13 @@ export function BrandsPage() { ) : items.length === 0 ? ( setSearch("")} className="h-9 rounded-lg px-4 text-[13px]" > - Clear search + {t("action.clearSearch")} ) : ( ) } @@ -170,8 +172,7 @@ export function BrandsPage() {

    - {data?.totalCount ?? 0} brand - {(data?.totalCount ?? 0) !== 1 ? "s" : ""} found + {t("brands.count", { count: data?.totalCount ?? 0 })}

    @@ -189,9 +190,9 @@ export function BrandsPage() { {/* Desktop: list card */} - Brand - Slug - Created + {t("col.brand")} + {t("col.slug")} + {t("col.created")} @@ -249,6 +250,7 @@ function MobileCard({ brand: BrandDto; onEdit: () => void; }) { + const { t } = useTranslation("catalog"); return (
    @@ -298,6 +300,7 @@ function DesktopRow({ onEdit: () => void; onDelete: () => void; }) { + const { t } = useTranslation("catalog"); return (