diff --git a/CLAUDE.md b/CLAUDE.md index 4317111a..8ea2e338 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -276,10 +276,72 @@ Menu items carry an explicit `textValue` of the **English** name. Radix runs its **Arabic and Hebrew need a text direction, and the panels are laid out left-to-right.** Every block `SafeMarkdown` emits carries `dir="auto"`, as do the transcript lines and both panels' question lines. The defect without it is not that RTL text renders left-to-right - it does not - it is that the neutrals go the wrong way: sentence-final punctuation takes the *paragraph's* direction, so the question mark lands at the wrong end, and a technical answer reorders at every switch of script, which is every answer since the prompts keep product names and code in Latin. Per block rather than once on a wrapper, because `auto` resolves from the first strong character it contains. Block code is pinned to `dir="ltr"` instead: code is left-to-right in every language and one RTL comment in a fence flips the whole block. `test/rtl-rendering.test.mjs` pins it, and it is a no-op in every language that shipped before the picker. -The app's own chrome is **not** localised, deliberately: an English button on a Spanish interview is an inconvenience, an English transcript of Spanish speech is a wrong answer read out loud. +**The app's own chrome follows a second setting, and is never derived from this one.** The old rule here was that the chrome is not localised at all, on the grounds that an English button on a Spanish interview is an inconvenience while an English transcript of Spanish speech is a wrong answer read out loud. The second half of that is still why nothing infers one language from the other: a Russian speaker interviewing in English wants an English transcript and a Russian app, and guessing either way gets that user wrong. The first half only ever argued for not guessing. So `uiLanguage` is its own config key, asked outright - the first step of the first-run wizard and the first row on the configuration page - and the interview language is left alone by it. See **Interface language** below. **The exported report is the one exception**, because it is the one artifact that leaves the machine and is handed to someone who was not there. The summarize prompt translates the headings *it* writes; the five words the client wraps around them - Transcripts, Suggestions, Suggestion, Interviewer, Date/Time - live in [export-labels.ts](src/main/utils/export-labels.ts) and follow the same setting, or the export is the half-translated document that prompt exists to avoid. The candidate is named rather than labelled, and timestamps stay on the machine's locale. `test/tools-export.test.mjs` pins that every enum member has a full set and that an unknown code falls back to English rather than throwing. +### Interface language + +`uiLanguage` ([src/main/types/ui-language.ts](src/main/types/ui-language.ts), mirrored in +[src/renderer/types/ui-language.ts](src/renderer/types/ui-language.ts) the way `Language` is) is +the language the app's buttons, headings, dialogs and toasts are written in. English and Russian +today. It is **not** `language`: see the note at the end of **Interview language** for why neither +is ever derived from the other. + +**A typed dictionary, not i18next.** `src/renderer/i18n/locales/en.ts` is the source of truth and +exports `Translation = typeof en`; every other locale is an object literal assigned to that type, +so a key Russian is missing - or spells differently - fails the build. i18next's answer for a +missing key is to render the key, which for two locales with no lazy loading and no namespacing is +the only thing its runtime would have bought. A language belongs in the enum once it has a file in +`locales`, not before: offering one without that file is offering a UI that falls back to English +everywhere it matters. + +**Strings that take a value are functions, not templates with placeholders.** That is what makes +Russian's three plural forms expressible at all - `1 кредит`, `2 кредита`, `5 кредитов`, and `11 +кредитов` again despite ending in 1 - and it is why `CreditsDisplay` hands the locale two integers +rather than a formatted `2 hours 15 mins`. A `{{count}}` scheme would need a plural-rule engine to +say what a one-line function says. The compiler covers the other half: `noUnusedParameters` is on, +so a locale that declares `(email: string)` and writes a sentence without it does not compile. + +`useT()` reads the config store directly, so there is no provider. The chosen code is also cached +in `localStorage` and read synchronously at module load, because the store is loaded from an effect +in `MainFrame` - without that cache a Russian install opens in English for the first frames of every +launch, which is the one moment a user is deciding whether the app is translated at all. The cache +is never authoritative: it is read only while the store has not answered. + +**`currentTranslation()` is for callbacks that must stay referentially stable.** Several setting +hooks say in their own comments that the global hotkey listeners subscribe to them once rather than +resubscribing on every config change; `useT()` inside such a callback would freeze the dictionary at +creation, and adding `t` to the dependency array would defeat that stability. It is also what the +callers that cannot call a hook at all use - `showExportSuccessToast`, and the zustand store in +`use-assistant-service`. + +**Main has its own table**, [ui-strings.ts](src/main/utils/ui-strings.ts), for the strings it writes +itself: the placeholder panel copy it seeds, and the push notifications it raises as toasts from +paths the renderer cannot see - a global hotkey pressed while the window is hidden, a session that +expires, stealth refused at the IPC boundary. Same shape as `export-labels.ts` and deliberately not +that table: the report follows the *interview* language because it is handed to someone who was not +there, this follows the *chrome* language because it is read by the person using the app. +`appStateService.refreshPlaceholderLanguage()` is what keeps the two in step - the placeholder is +written on launch and after a Clear, so a language changed between those two would otherwise leave +English sample copy in the panels of a Russian app. It is a no-op once a real interview has written +to the history, which is the half that matters. + +**Backend error text is passed through untranslated.** The client cannot translate a string it did +not write, and replacing a specific server message with a generic local one loses the only useful +half of it. The locale's `errors` blocks are fallbacks, for a failure that arrived without a +message. + +`test/ui-language.test.mjs` covers what the types cannot: an unknown stored code (which would reach +the lookup and come back `undefined` - an app with no text in it), the two enums drifting, and a +Russian string that is still its English original. That last one type-checks perfectly, ships, and +is otherwise only ever caught by a Russian speaker reading the screen; the check is Latin letters +with no Cyrillic in the same string, with an allowlist whose every entry is a written-out claim +that one string is correct in Latin script (`Markdown`, `Pro`, `Junior`, a domain). + +Russian copy uses hyphens where Russian typography would use `—`, because this repository's writing +rules forbid generating em-dashes and the English source uses hyphens in the same positions. + ### Headphones `ch_0` is a loopback of the system's render endpoint, which is the same sound the speakers are diff --git a/SPEC.md b/SPEC.md index 02067d21..8208a830 100644 --- a/SPEC.md +++ b/SPEC.md @@ -79,9 +79,19 @@ A length the balance cannot cover is disabled in the question-count picker with The default. Restructures both live and triggered suggestions into a bold one-line core answer plus one bullet per point, however many the answer needs - the same answer full-sentence mode would give, reorganised so the eye finds each point in one pass and stripped of its padding. Bullets stay full speakable sentences rather than keywords, so the candidate can read one out loud as it stands. Switched from the control panel, the configuration page, or with `Ctrl+Shift+F7`, which keeps it reachable in stealth mode. Persisted locally as `hintOnlyMode`; sent to the backend as `mode` on the suggestion request, whose wire values are still `normal` / `professional`. +### Interface Language + +The app's own chrome - buttons, headings, dialogs, toasts - follows `uiLanguage`, which is English or Russian. A **separate setting** from the interview language, asked outright on the first step of the first-run wizard and settable on the configuration page, and never inferred from the other: a Russian speaker interviewing in English wants an English transcript and a Russian app. + +A typed dictionary rather than i18next, so a missing or misspelled translation is a build error rather than a key rendered on screen ([src/renderer/i18n](src/renderer/i18n)). Strings that take a value are functions, which is what makes Russian's three plural forms expressible. Main keeps its own table for the copy it writes itself - the panel placeholders and the push notifications it raises as toasts ([src/main/utils/ui-strings.ts](src/main/utils/ui-strings.ts)). + +Two things deliberately do not follow it. The exported report follows the **interview** language, because it is handed to someone who was not there. And backend error text is passed through untranslated, because the client cannot translate a string it did not write. + ### First-Run Setup -A user who has not been through setup is sent to `/onboarding` before they can reach anything else, and asked once for the seven things a first interview needs: profile, job context, language, microphone (with a live level test), suggestion style, interface size, and whether the transcript panel is docked. Each step renders the same component the account and configuration pages use. +A user who has not been through setup is sent to `/onboarding` before they can reach anything else, and asked once for the eight things a first interview needs: **app language**, profile, job context, interview language, microphone (with a live level test), suggestion style, interface size, and whether the transcript panel is docked. Each step renders the same component the account and configuration pages use. + +App language is first, ahead of even the profile. Every other step asks about an interview; that one asks whether the user can read the questions, and picking there re-renders the step itself in the language chosen. Gated on the account's `onboarding_completed`, written through `PATCH /api/users/me/onboarding` - on the account rather than on the machine, so it follows the user to a new device and a second account on a shared one gets its own run of it. The gate waits for `interviewConfigLoaded` as well as the flag, since before the account has been read the flag is a default rather than an answer. @@ -89,7 +99,7 @@ Nothing in it is a trap: Skip is on every step, every setting has a working defa ### Navigation -`/` is a launch hub naming the five things a user comes to the app to do: start a mock interview, start the live assistant, open Account (`/account` - sign-in identity, profile, context, password), open Configuration (`/configuration` - microphone, language, suggestion style, interface size, transcript panel), or buy credits. +`/` is a launch hub naming the five things a user comes to the app to do: start a mock interview, start the live assistant, open Account (`/account` - sign-in identity, profile, context, password), open Configuration (`/configuration` - app language, microphone, interview language, suggestion style, interface size, transcript panel), or buy credits. **It is the only place a session begins.** Both launch buttons start one; neither implements starting one. Live hands off to `/main` through router state, because `/main`'s control panel owns the whole start sequence; mock hands off to `/mock-interview` with the setup its dialog collected. `/main` itself carries only Stop - it is the live assistant, not a place to choose one - and shows a way back to `/` on the rare idle visit (a start cancelled at the headphone notice, or the route opened directly). Stopping asks whether to save the interview, clears it, and returns to `/`. See [docs/ux-conventions.md](docs/ux-conventions.md) for where a new capability belongs. diff --git a/src/main/api/client.ts b/src/main/api/client.ts index 16b1b7d1..feb61f08 100644 --- a/src/main/api/client.ts +++ b/src/main/api/client.ts @@ -3,6 +3,7 @@ import os from 'os'; import { BACKEND_BASE_URL } from '../consts.js'; import { configStore } from '../store/config.store.js'; +import { uiStrings } from '../utils/ui-strings.js'; function buildUserAgent(): string { return `PowerInterviewAI/${app.getVersion()} (${process.platform}; ${process.arch}; ${os.release()})`; @@ -101,7 +102,8 @@ export class ApiClient { status: 0, error: { code: 'NETWORK_ERROR', - message: error instanceof Error ? error.message : 'Network request failed', + message: + error instanceof Error ? error.message : uiStrings().transportErrors.networkFailed, }, }; } @@ -166,10 +168,10 @@ export class ApiClient { const timedOut = error instanceof Error && error.name === 'TimeoutError'; throw new ApiRequestError( timedOut - ? 'The request timed out' + ? uiStrings().transportErrors.timedOut : error instanceof Error ? error.message - : 'Network request failed', + : uiStrings().transportErrors.networkFailed, 0, null ); @@ -234,10 +236,10 @@ export class ApiClient { error: { code: timedOut ? 'TIMEOUT' : 'NETWORK_ERROR', message: timedOut - ? 'The request timed out' + ? uiStrings().transportErrors.timedOut : error instanceof Error ? error.message - : 'Network request failed', + : uiStrings().transportErrors.networkFailed, }, }; } @@ -304,7 +306,7 @@ export class ApiClient { console.error('[ApiClient] Streaming request error:', { method, url, error }); throw new ApiRequestError( - error instanceof Error ? error.message : 'Network request failed', + error instanceof Error ? error.message : uiStrings().transportErrors.networkFailed, 0, null ); diff --git a/src/main/ipc/config.ts b/src/main/ipc/config.ts index 029d5d94..c1cd906d 100644 --- a/src/main/ipc/config.ts +++ b/src/main/ipc/config.ts @@ -1,5 +1,6 @@ import { ipcMain } from 'electron'; +import { appStateService } from '../services/app-state.service.js'; import { configStore, RuntimeConfig } from '../store/config.store.js'; export function registerConfigHandlers(): void { @@ -16,7 +17,15 @@ export function registerConfigHandlers(): void { // Handle config updates ipcMain.handle('config:update', async (_event, updates: Partial) => { try { - return configStore.updateConfig(updates); + const config = configStore.updateConfig(updates); + + // The placeholder panel copy is written by main and only rewritten on launch and after a + // Clear, so a chrome language changed between those two would leave English sample text in + // the panels of an otherwise translated app. Keyed on the update rather than on the value + // changing, which is enough: the re-seed is a no-op once a real interview has started. + if (updates.uiLanguage !== undefined) appStateService.refreshPlaceholderLanguage(); + + return config; } catch (error) { console.error('Failed to update config:', error); throw error; diff --git a/src/main/ipc/tools.ts b/src/main/ipc/tools.ts index 0412a881..c64d92ab 100644 --- a/src/main/ipc/tools.ts +++ b/src/main/ipc/tools.ts @@ -3,6 +3,7 @@ import fs from 'fs/promises'; import { toolsService } from '../services/tools.service.js'; import { ExportFormat } from '../types/export.js'; +import { uiStrings } from '../utils/ui-strings.js'; export function registerToolsHandlers(): void { ipcMain.handle('tools:export-transcript', async (_event, format: ExportFormat = 'docx') => { @@ -20,10 +21,11 @@ export function registerToolsHandlers(): void { ipcMain.handle( 'tools:save-image', async (_event, { filename, data }: { filename: string; data: number[] }) => { + const strings = uiStrings(); const { canceled, filePath } = await dialog.showSaveDialog({ - title: 'Save Image', + title: strings.saveImageTitle, defaultPath: filename, - filters: [{ name: 'PNG Image', extensions: ['png'] }], + filters: [{ name: strings.pngFilter, extensions: ['png'] }], }); if (canceled || !filePath) return { filePath: null }; await fs.writeFile(filePath, Buffer.from(data)); diff --git a/src/main/services/account.service.ts b/src/main/services/account.service.ts index 7265d8b9..f5fef4c5 100644 --- a/src/main/services/account.service.ts +++ b/src/main/services/account.service.ts @@ -7,6 +7,7 @@ import { } from '../store/config.store.js'; import { UserAccount } from '../types/account.js'; import { InterviewConfig } from '../types/app-state.js'; +import { uiStrings } from '../utils/ui-strings.js'; import { appStateService } from './app-state.service.js'; /** @@ -86,7 +87,7 @@ export class AccountService { const migration = await this.migrateLegacyConfig(account._id, generation); if (migration === 'migrated') return { success: true }; if (migration === 'failed') { - return { success: false, error: 'Failed to migrate local configuration' }; + return { success: false, error: uiStrings().accountErrors.migrateFailed }; } } @@ -104,7 +105,7 @@ export class AccountService { if (interviewConfig) this.discardLegacyConfigIfOwned(account._id); return { success: true }; } catch { - return { success: false, error: 'Failed to fetch account' }; + return { success: false, error: uiStrings().accountErrors.fetchFailed }; } } @@ -190,7 +191,10 @@ export class AccountService { context, }); if (response.error) { - return { success: false, error: response.error.message || 'Failed to update account' }; + return { + success: false, + error: response.error.message || uiStrings().accountErrors.updateFailed, + }; } // Mirror what the backend stored, not what was sent: it truncates oversized fields, @@ -207,7 +211,7 @@ export class AccountService { }); return { success: true }; } catch { - return { success: false, error: 'Failed to update account' }; + return { success: false, error: uiStrings().accountErrors.updateFailed }; } } @@ -234,7 +238,7 @@ export class AccountService { return { success: fresh, data: state.interviewConfig, - error: fresh ? undefined : pull.error || 'Failed to load configuration', + error: fresh ? undefined : pull.error || uiStrings().accountErrors.loadConfigFailed, }; } @@ -269,13 +273,14 @@ export class AccountService { * user in the wizard on a failed write, so the one screen the deployment cannot support was * also the one screen they could not leave except by skipping it. */ - async setOnboardingCompleted( - completed: boolean - ): Promise<{ success: boolean; error?: string }> { + async setOnboardingCompleted(completed: boolean): Promise<{ success: boolean; error?: string }> { try { const response = await this.client.updateOnboarding({ completed }); if (response.error && response.status !== 404) { - return { success: false, error: response.error.message || 'Failed to save your setup' }; + return { + success: false, + error: response.error.message || uiStrings().accountErrors.onboardingFailed, + }; } // Bumped for the same reason `updateConfig` bumps it: a pull that started before this @@ -287,7 +292,7 @@ export class AccountService { }); return { success: true }; } catch { - return { success: false, error: 'Failed to save your setup' }; + return { success: false, error: uiStrings().accountErrors.onboardingFailed }; } } } diff --git a/src/main/services/action-lock.service.ts b/src/main/services/action-lock.service.ts index a5b189e4..24e2a82e 100644 --- a/src/main/services/action-lock.service.ts +++ b/src/main/services/action-lock.service.ts @@ -4,6 +4,7 @@ */ import { ACTION_LOCK_MAX_HOLD_MS } from '../consts.js'; +import { uiStrings } from '../utils/ui-strings.js'; import { pushNotificationService } from './push-notification.service.js'; export enum ActionType { @@ -76,9 +77,10 @@ class ActionLockService { * Notify user that action is blocked */ private notifyBlocked(requestedAction: ActionType, runningAction: ActionType): void { + const strings = uiStrings(); const actionNames: Record = { - [ActionType.ScreenshotCapture]: 'Screenshot capture', - [ActionType.CaptureSuggestion]: 'Action suggestion generation', + [ActionType.ScreenshotCapture]: strings.actionNames.screenshotCapture, + [ActionType.CaptureSuggestion]: strings.actionNames.captureSuggestion, }; const requested = actionNames[requestedAction]; @@ -88,7 +90,7 @@ class ActionLockService { pushNotificationService.pushNotification({ type: 'warning', - message: `${running} is in progress. Try again a bit later.`, + message: strings.actionBlocked(running), }); } } diff --git a/src/main/services/app-state.service.ts b/src/main/services/app-state.service.ts index a0adcb4b..fabd11ca 100644 --- a/src/main/services/app-state.service.ts +++ b/src/main/services/app-state.service.ts @@ -14,6 +14,7 @@ import { } from '../types/app-state.js'; import { DEFAULT_LANGUAGE } from '../types/language.js'; import { SuggestionMode } from '../types/llm.js'; +import { uiStrings } from '../utils/ui-strings.js'; import { getWindowReference, refreshWindowSurfaces } from './window-control.service.js'; const DEFAULT_STATE: AppState = { @@ -81,6 +82,7 @@ export class AppStateService { setPlaceholderState() { this.placeholderActive = true; + const strings = uiStrings(); const tstampNow = Date.now(); this.state = { ...this.state, @@ -88,7 +90,7 @@ export class AppStateService { transcripts: [ { timestamp: tstampNow, - text: 'Transcripts will be here', + text: strings.placeholderTranscript, speaker: Speaker.Other, isFinal: false, endTimestamp: tstampNow + 5000, @@ -98,8 +100,8 @@ export class AppStateService { liveSuggestions: [ { timestamp: tstampNow, - last_question: 'Interviewer questions will be here', - answer: 'Suggested answers will be here in real-time', + last_question: strings.placeholderQuestion, + answer: strings.placeholderLiveAnswer, state: SuggestionState.Success, error: '', mode: SuggestionMode.FullSentence, @@ -108,9 +110,8 @@ export class AppStateService { actionSuggestions: [ { timestamp: tstampNow, - last_question: 'Interviewer questions will be here', - answer: - 'Triggered suggestions will be here. For example, reply suggestion, coding test solution, diagram descriptions, etc.', + last_question: strings.placeholderQuestion, + answer: strings.placeholderActionAnswer, image_urls: [null, null, null, null], state: SuggestionState.Success, error: '', @@ -126,6 +127,20 @@ export class AppStateService { this.notifyRenderer(); } + /** + * Re-seed the placeholder copy in whatever language the chrome is now in. + * + * A no-op unless the placeholder is actually on screen: once a real interview has written to + * the history, re-seeding would throw it away. Called from the config IPC handler when + * `uiLanguage` moves, because nothing else would - the placeholder is written on launch and + * after a Clear, and a language changed between those two left English copy in the panels of + * an otherwise Russian app. + */ + refreshPlaceholderLanguage(): void { + if (!this.placeholderActive) return; + this.setPlaceholderState(); + } + getState(): AppState { return { ...this.state }; } diff --git a/src/main/services/auth.service.ts b/src/main/services/auth.service.ts index 2fe8e4ca..e5f806a6 100644 --- a/src/main/services/auth.service.ts +++ b/src/main/services/auth.service.ts @@ -1,5 +1,6 @@ import { AuthApi } from '../api/auth.js'; import { configStore } from '../store/config.store.js'; +import { uiStrings } from '../utils/ui-strings.js'; import { accountService } from './account.service.js'; import { appStateService } from './app-state.service.js'; import { toolsService } from './tools.service.js'; @@ -32,12 +33,12 @@ export class AuthService { if (response.error) { return { success: false, - error: response.error.message || 'Failed to send verification code', + error: response.error.message || uiStrings().authErrors.sendCodeFailed, }; } return { success: true }; } catch { - return { success: false, error: 'Failed to send verification code' }; + return { success: false, error: uiStrings().authErrors.sendCodeFailed }; } } @@ -53,12 +54,12 @@ export class AuthService { if (response.error) { return { success: false, - error: response.error.message || 'Invalid or expired verification code', + error: response.error.message || uiStrings().authErrors.invalidCode, }; } return { success: true }; } catch { - return { success: false, error: 'Invalid or expired verification code' }; + return { success: false, error: uiStrings().authErrors.invalidCode }; } } @@ -80,14 +81,17 @@ export class AuthService { verification_code: verificationCode, }); if (response.error) { - return { success: false, error: response.error.message || 'Signup failed' }; + return { + success: false, + error: response.error.message || uiStrings().authErrors.signupFailed, + }; } return { success: true }; } catch { - return { success: false, error: 'Signup failed' }; + return { success: false, error: uiStrings().authErrors.signupFailed }; } } else { - return { success: false, error: 'Invalid email or password' }; + return { success: false, error: uiStrings().authErrors.invalidCredentials }; } } @@ -101,7 +105,10 @@ export class AuthService { try { const response = await this.client.login({ email, password }); if (response.error) { - return { success: false, error: response.error.message || 'Login failed' }; + return { + success: false, + error: response.error.message || uiStrings().authErrors.loginFailed, + }; } // persist credentials in the config store only if rememberMe is enabled @@ -130,10 +137,10 @@ export class AuthService { return { success: true }; } catch { - return { success: false, error: 'Login failed' }; + return { success: false, error: uiStrings().authErrors.loginFailed }; } } else { - return { success: false, error: 'Invalid email or password' }; + return { success: false, error: uiStrings().authErrors.invalidCredentials }; } } @@ -144,11 +151,14 @@ export class AuthService { try { const response = await this.client.logout(); if (response.error) { - return { success: false, error: response.error.message || 'Logout failed' }; + return { + success: false, + error: response.error.message || uiStrings().authErrors.logoutFailed, + }; } return { success: true }; } catch { - return { success: false, error: 'Logout failed' }; + return { success: false, error: uiStrings().authErrors.logoutFailed }; } finally { disableStealth(); // clear session token and update app state @@ -191,7 +201,10 @@ export class AuthService { new_password: newPassword, }); if (response.error) { - return { success: false, error: response.error.message || 'Change password failed' }; + return { + success: false, + error: response.error.message || uiStrings().authErrors.changePasswordFailed, + }; } // Only persist when the user opted in - login/logout leave the store empty otherwise, @@ -212,7 +225,7 @@ export class AuthService { return { success: true }; } catch { - return { success: false, error: 'Change password failed' }; + return { success: false, error: uiStrings().authErrors.changePasswordFailed }; } } @@ -230,12 +243,12 @@ export class AuthService { if (response.error) { return { success: false, - error: response.error.message || 'Failed to send password reset code', + error: response.error.message || uiStrings().authErrors.sendResetCodeFailed, }; } return { success: true }; } catch { - return { success: false, error: 'Failed to send password reset code' }; + return { success: false, error: uiStrings().authErrors.sendResetCodeFailed }; } } @@ -251,12 +264,12 @@ export class AuthService { if (response.error) { return { success: false, - error: response.error.message || 'Invalid or expired reset code', + error: response.error.message || uiStrings().authErrors.invalidResetCode, }; } return { success: true }; } catch { - return { success: false, error: 'Invalid or expired reset code' }; + return { success: false, error: uiStrings().authErrors.invalidResetCode }; } } @@ -275,7 +288,10 @@ export class AuthService { new_password: newPassword, }); if (response.error) { - return { success: false, error: response.error.message || 'Password reset failed' }; + return { + success: false, + error: response.error.message || uiStrings().authErrors.resetFailed, + }; } // The login form pre-fills from the store when rememberMe is on, and if what it holds @@ -305,7 +321,7 @@ export class AuthService { return { success: true }; } catch { - return { success: false, error: 'Password reset failed' }; + return { success: false, error: uiStrings().authErrors.resetFailed }; } } } diff --git a/src/main/services/health-check.service.ts b/src/main/services/health-check.service.ts index 5d7e7c24..52be0666 100644 --- a/src/main/services/health-check.service.ts +++ b/src/main/services/health-check.service.ts @@ -6,6 +6,7 @@ import { HealthCheckApi } from '../api/health-check.js'; import { MockInterviewApi } from '../api/mock-interview.js'; import { safeSleep } from '../utils/sleep.js'; +import { uiStrings } from '../utils/ui-strings.js'; import { accountService } from './account.service.js'; import { appStateService } from './app-state.service.js'; import { authService } from './auth.service.js'; @@ -189,7 +190,7 @@ export class HealthCheckService { console.warn('[HealthCheckService] Session expired (401) - logging out'); pushNotificationService.pushNotification({ type: 'warning', - message: 'Your session expired, please log in again.', + message: uiStrings().sessionExpired, }); await authService.logout(); } else if (res.data?.credits !== undefined) { diff --git a/src/main/services/mock-interview.service.ts b/src/main/services/mock-interview.service.ts index b2d2d002..02a8b19a 100644 --- a/src/main/services/mock-interview.service.ts +++ b/src/main/services/mock-interview.service.ts @@ -41,6 +41,7 @@ import { type SuggestionStallStage, } from '../utils/suggestion-error.js'; import { transcriptSeparator } from '../utils/transcript-join.js'; +import { uiStrings } from '../utils/ui-strings.js'; import { appStateService } from './app-state.service.js'; /** @@ -224,7 +225,7 @@ class MockInterviewService { // direction - there is no start hotkey into a mock session for it to intercept the way it // intercepts every route into the live assistant, so this side needed its own guard. if (appStateService.getState().runningState !== RunningState.Idle) { - throw new Error('Stop the live interview before starting a mock interview.'); + throw new Error(uiStrings().mockErrors.liveRunning); } const seq = ++this.sessionSeq; @@ -258,15 +259,15 @@ class MockInterviewService { if (this.lastQuestionUnaffordable) throw new Error(this.lastQuestionError); throw new Error( this.lastQuestionError - ? `Could not generate the first question: ${this.lastQuestionError}` - : 'Failed to generate the first question. Please try again.' + ? uiStrings().mockErrors.firstQuestionFailedWith(this.lastQuestionError) + : uiStrings().mockErrors.firstQuestionFailed ); } } catch (error) { if (seq !== this.sessionSeq) return; this.session = { ...initialSession(), - error: error instanceof Error ? error.message : 'Failed to start the mock interview', + error: error instanceof Error ? error.message : uiStrings().mockErrors.startFailed, }; this.broadcast(); throw error; @@ -314,7 +315,8 @@ class MockInterviewService { // naming the price and their balance, so it is passed through rather than prefixed with // "could not generate the question", which describes a fault they do not have. if (response.status === HTTP_PAYMENT_REQUIRED) { - this.lastQuestionError = response.error?.message || 'Not enough credits'; + this.lastQuestionError = + response.error?.message || uiStrings().mockErrors.notEnoughCredits; this.lastQuestionUnaffordable = true; console.warn( `[MockInterviewService] question refused for credits: ${this.lastQuestionError}` @@ -754,9 +756,7 @@ class MockInterviewService { const answered = this.session.answers.length > 0; this.session = { ...initialSession(), - error: answered - ? 'The interview ended with nothing recorded. Check that the right microphone is selected and that it is not muted, then try again.' - : null, + error: answered ? uiStrings().mockErrors.nothingRecorded : null, }; this.broadcast(); return; @@ -899,7 +899,7 @@ class MockInterviewService { this.session = { ...this.session, report: null, - reportError: 'The interview was ended before scoring finished.', + reportError: uiStrings().mockErrors.endedBeforeScoring, state: MockInterviewState.Finished, }; this.broadcast(); @@ -1098,7 +1098,7 @@ class MockInterviewService { } if (hint.answer.length === 0) { hint.state = SuggestionState.Error; - hint.error = 'The model returned an empty response.'; + hint.error = uiStrings().suggestionErrors.emptyResponse; } else { hint.state = SuggestionState.Success; } diff --git a/src/main/services/payment.service.ts b/src/main/services/payment.service.ts index 30fa068c..c42cecb4 100644 --- a/src/main/services/payment.service.ts +++ b/src/main/services/payment.service.ts @@ -12,6 +12,7 @@ import { PaymentHistory, PaymentStatusResponse, } from '../types/payment.js'; +import { uiStrings } from '../utils/ui-strings.js'; import { appStateService } from './app-state.service.js'; export class PaymentService { @@ -33,7 +34,10 @@ export class PaymentService { if (response.error) { console.error('[PaymentService] Failed to get plans:', response.error); - return { success: false, error: response.error.message || 'Failed to get plans' }; + return { + success: false, + error: response.error.message || uiStrings().paymentErrors.getPlans, + }; } // Map backend plans to frontend format @@ -48,7 +52,7 @@ export class PaymentService { return { success: true, data: plans }; } catch (error) { console.error('[PaymentService] Failed to get plans:', error); - return { success: false, error: 'Failed to get plans' }; + return { success: false, error: uiStrings().paymentErrors.getPlans }; } } @@ -65,14 +69,14 @@ export class PaymentService { if (response.error) { return { success: false, - error: response.error.message || 'Failed to get available currencies', + error: response.error.message || uiStrings().paymentErrors.getCurrencies, }; } return { success: true, data: response.data || [] }; } catch (error) { console.error('[PaymentService] Failed to get available currencies:', error); - return { success: false, error: 'Failed to get available currencies' }; + return { success: false, error: uiStrings().paymentErrors.getCurrencies }; } } @@ -86,13 +90,16 @@ export class PaymentService { const response = await this.api.createPayment(data); if (response.error) { - return { success: false, error: response.error.message || 'Failed to create payment' }; + return { + success: false, + error: response.error.message || uiStrings().paymentErrors.createPayment, + }; } return { success: true, data: response.data }; } catch (error) { console.error('[PaymentService] Failed to create payment:', error); - return { success: false, error: 'Failed to create payment' }; + return { success: false, error: uiStrings().paymentErrors.createPayment }; } } @@ -106,13 +113,16 @@ export class PaymentService { const response = await this.api.getPaymentStatus(paymentId); if (response.error) { - return { success: false, error: response.error.message || 'Failed to get payment status' }; + return { + success: false, + error: response.error.message || uiStrings().paymentErrors.getStatus, + }; } return { success: true, data: response.data }; } catch (error) { console.error('[PaymentService] Failed to get payment status:', error); - return { success: false, error: 'Failed to get payment status' }; + return { success: false, error: uiStrings().paymentErrors.getStatus }; } } @@ -128,13 +138,16 @@ export class PaymentService { const response = await this.api.getPaymentHistory(); if (response.error) { - return { success: false, error: response.error.message || 'Failed to get payment history' }; + return { + success: false, + error: response.error.message || uiStrings().paymentErrors.getHistory, + }; } return { success: true, data: response.data || [] }; } catch (error) { console.error('[PaymentService] Failed to get payment history:', error); - return { success: false, error: 'Failed to get payment history' }; + return { success: false, error: uiStrings().paymentErrors.getHistory }; } } @@ -146,7 +159,10 @@ export class PaymentService { const response = await this.api.getCredits(); if (response.error) { - return { success: false, error: response.error.message || 'Failed to get credits' }; + return { + success: false, + error: response.error.message || uiStrings().paymentErrors.getCredits, + }; } // Update app state with latest credits @@ -157,7 +173,7 @@ export class PaymentService { return { success: true, credits: response.data?.credits || 0 }; } catch (error) { console.error('[PaymentService] Failed to get credits:', error); - return { success: false, error: 'Failed to get credits' }; + return { success: false, error: uiStrings().paymentErrors.getCredits }; } } @@ -178,7 +194,7 @@ export class PaymentService { error?: string; }> => { if (attempts >= maxAttempts) { - return { success: false, error: 'Payment polling timeout' }; + return { success: false, error: uiStrings().paymentErrors.pollingTimeout }; } attempts++; diff --git a/src/main/services/suggestion-action.service.ts b/src/main/services/suggestion-action.service.ts index 2d01c6c5..d4999a31 100644 --- a/src/main/services/suggestion-action.service.ts +++ b/src/main/services/suggestion-action.service.ts @@ -24,6 +24,7 @@ import { GenerateActionSuggestionRequest, SuggestionMode } from '../types/llm.js import { isMockInterviewSessionActive } from '../types/mock-interview.js'; import { DateTimeUtil } from '../utils/datetime.js'; import { getSuggestionErrorMessage } from '../utils/suggestion-error.js'; +import { uiStrings } from '../utils/ui-strings.js'; import { UuidUtil } from '../utils/uuid.js'; import { actionLockService, ActionType } from './action-lock.service.js'; import { appStateService } from './app-state.service.js'; @@ -91,15 +92,13 @@ export class ActionSuggestionService { } async clearImages(): Promise { - if ( - this.refuseDuringMockInterview('Action suggestions are unavailable during a mock interview.') - ) { + if (this.refuseDuringMockInterview(uiStrings().actionDuringMock)) { return; } if (appStateService.getState().runningState !== RunningState.Running) { pushNotificationService.pushNotification({ type: 'warning', - message: 'Cannot clear images when assistant is not running', + message: uiStrings().cannotClearImages, }); return; } @@ -109,15 +108,13 @@ export class ActionSuggestionService { } async captureScreenshot(): Promise { - if ( - this.refuseDuringMockInterview('Action suggestions are unavailable during a mock interview.') - ) { + if (this.refuseDuringMockInterview(uiStrings().actionDuringMock)) { return; } if (appStateService.getState().runningState !== RunningState.Running) { pushNotificationService.pushNotification({ type: 'warning', - message: 'Cannot capture screenshot when assistant is not running', + message: uiStrings().cannotCaptureScreenshot, }); return; } @@ -125,7 +122,7 @@ export class ActionSuggestionService { if (this.uploadedImageNames.length >= ACTION_SUGGESTION_MAX_CAPTURES) { pushNotificationService.pushNotification({ type: 'warning', - message: `Maximum of ${ACTION_SUGGESTION_MAX_CAPTURES} screenshots reached. Please clear images and try again.`, + message: uiStrings().maxCaptures(ACTION_SUGGESTION_MAX_CAPTURES), }); return; } @@ -157,7 +154,7 @@ export class ActionSuggestionService { appStateService.updateState({ actionSuggestions: this.getSuggestions() }); pushNotificationService.pushNotification({ type: 'error', - message: 'Screenshot capture failed. Please try again.', + message: uiStrings().captureFailed, }); } finally { actionLockService.release(ActionType.ScreenshotCapture); @@ -165,9 +162,7 @@ export class ActionSuggestionService { } async startGenerateSuggestion(): Promise { - if ( - this.refuseDuringMockInterview('Action suggestions are unavailable during a mock interview.') - ) { + if (this.refuseDuringMockInterview(uiStrings().actionDuringMock)) { return; } @@ -176,7 +171,7 @@ export class ActionSuggestionService { if (appState.runningState !== RunningState.Running) { pushNotificationService.pushNotification({ type: 'warning', - message: 'Cannot generate suggestion when assistant is not running', + message: uiStrings().cannotGenerateSuggestion, }); return; } @@ -318,7 +313,7 @@ export class ActionSuggestionService { // This path already promoted to Loading before the loop, so an empty stream lands // here as a blank Success card rather than a stuck one. Still wrong: report it. suggestion.state = SuggestionState.Error; - suggestion.error = 'The model returned an empty response.'; + suggestion.error = uiStrings().suggestionErrors.emptyResponse; } else { suggestion.state = SuggestionState.Success; } @@ -347,7 +342,7 @@ export class ActionSuggestionService { } suggestion.state = SuggestionState.Error; suggestion.error = stalled - ? 'The response timed out. Please try again.' + ? uiStrings().suggestionErrors.responseTimedOut : getSuggestionErrorMessage(error); } this.setSuggestion(timestamp, suggestion); diff --git a/src/main/services/suggestion-live.service.ts b/src/main/services/suggestion-live.service.ts index ea258ba1..590e8cbf 100644 --- a/src/main/services/suggestion-live.service.ts +++ b/src/main/services/suggestion-live.service.ts @@ -16,6 +16,7 @@ import { type SuggestionStallStage, } from '../utils/suggestion-error.js'; import { isNoSuggestionSentinel } from '../utils/suggestion-sentinel.js'; +import { uiStrings } from '../utils/ui-strings.js'; import { UuidUtil } from '../utils/uuid.js'; import { appStateService } from './app-state.service.js'; @@ -180,7 +181,7 @@ class LiveSuggestionService { // Indistinguishable from a provider failure, and a stated error beats a card // that never resolves. suggestion.state = SuggestionState.Error; - suggestion.error = 'The model returned an empty response.'; + suggestion.error = uiStrings().suggestionErrors.emptyResponse; } else { suggestion.state = SuggestionState.Success; } diff --git a/src/main/services/tools.service.ts b/src/main/services/tools.service.ts index 54017361..1796bcd8 100644 --- a/src/main/services/tools.service.ts +++ b/src/main/services/tools.service.ts @@ -8,6 +8,7 @@ import { ExportFormat } from '../types/export.js'; import { GenerateSummarizeRequest } from '../types/llm.js'; import { buildExportMarkdown, generateExportFilename } from '../utils/export-markdown.js'; import { buildMockExportMarkdown } from '../utils/export-mock-markdown.js'; +import { uiStrings } from '../utils/ui-strings.js'; import { appStateService } from './app-state.service.js'; import { mockInterviewService } from './mock-interview.service.js'; import { actionSuggestionService } from './suggestion-action.service.js'; @@ -77,12 +78,15 @@ class ToolsService { const isMarkdown = format === 'md'; + // The document inside follows the interview language; this window is being read by the + // person at the keyboard, so it follows the chrome language. + const strings = uiStrings(); const { canceled, filePath } = await dialog.showSaveDialog({ - title: 'Save Transcript', + title: strings.saveTranscriptTitle, defaultPath: generateExportFilename(format), filters: isMarkdown - ? [{ name: 'Markdown', extensions: ['md'] }] - : [{ name: 'Word Document', extensions: ['docx'] }], + ? [{ name: strings.markdownFilter, extensions: ['md'] }] + : [{ name: strings.wordFilter, extensions: ['docx'] }], }); if (canceled || !filePath) return null; @@ -120,12 +124,13 @@ class ToolsService { const isMarkdown = format === 'md'; + const strings = uiStrings(); const { canceled, filePath } = await dialog.showSaveDialog({ - title: 'Save Mock Interview Report', + title: strings.saveMockReportTitle, defaultPath: generateExportFilename(format, 'mock-interview'), filters: isMarkdown - ? [{ name: 'Markdown', extensions: ['md'] }] - : [{ name: 'Word Document', extensions: ['docx'] }], + ? [{ name: strings.markdownFilter, extensions: ['md'] }] + : [{ name: strings.wordFilter, extensions: ['docx'] }], }); if (canceled || !filePath) return null; diff --git a/src/main/services/window-control.service.ts b/src/main/services/window-control.service.ts index c04e0615..07902e73 100644 --- a/src/main/services/window-control.service.ts +++ b/src/main/services/window-control.service.ts @@ -4,6 +4,7 @@ import { MIN_HEIGHT, MIN_WIDTH, OPACITY_LEVELS } from '../consts.js'; import { configStore } from '../store/config.store.js'; import { RunningState } from '../types/app-state.js'; import { isMockInterviewSessionActive } from '../types/mock-interview.js'; +import { uiStrings } from '../utils/ui-strings.js'; import { appStateService } from './app-state.service.js'; import { pushNotificationService } from './push-notification.service.js'; @@ -583,7 +584,7 @@ export function disableStealth(): void { function stealthUnavailableReason(): string | null { const state = appStateService.getState(); - if (!state.isLoggedIn) return 'You must be logged in to use stealth mode.'; + if (!state.isLoggedIn) return uiStrings().stealthSignedOut; // Mock interview is deliberate practice, not a live call - it needs no always-on-top and no // screen-share hiding, and a click-through, non-focusable window would strand the session @@ -591,14 +592,14 @@ function stealthUnavailableReason(): string | null { // session deliberately leaves `runningState` on Idle, so the answer "start a live interview // first" would be the wrong thing to say to someone who is mid-interview already. if (isMockInterviewSessionActive(state.mockInterview)) { - return 'Stealth mode is off during a mock interview. This is practice, not a live call.'; + return uiStrings().stealthDuringMock; } // The same predicate `shouldHideSurfaces()` reads, so the two cannot disagree about when a // session is on air. `Starting` is deliberately not enough: nothing is being captured yet, and // a start that fails leaves stealth on over a console that never opened. if (!isAssistantRunning()) { - return 'Stealth mode is only available during a live interview.'; + return uiStrings().stealthNotRunning; } return null; @@ -641,7 +642,7 @@ export function toggleOpacity(): void { if (!_stealth) { pushNotificationService.pushNotification({ - message: 'Opacity toggle is only available in stealth mode.', + message: uiStrings().opacityStealthOnly, type: 'warning', }); console.log('⚠️ Opacity toggle is only available in stealth mode'); diff --git a/src/main/store/config.store.ts b/src/main/store/config.store.ts index 7a8df018..c5741139 100644 --- a/src/main/store/config.store.ts +++ b/src/main/store/config.store.ts @@ -7,11 +7,20 @@ import ElectronStore from 'electron-store'; import { OPACITY_DEFAULT } from '../consts.js'; import { DEFAULT_LANGUAGE, Language, resolveLanguage } from '../types/language.js'; +import { DEFAULT_UI_LANGUAGE, resolveUiLanguage, UiLanguage } from '../types/ui-language.js'; // Runtime configuration (matches Config type in frontend) export interface RuntimeConfig { /** Interview language: what the ASR transcribes and what suggestions come back in. */ language: Language; + + /** + * The language the app's own chrome is written in. + * + * A separate key from `language` above, not a view of it. See `types/ui-language.ts` for why + * the two are never derived from each other. + */ + uiLanguage: UiLanguage; sessionToken: string; rememberMe: boolean; email: string; @@ -41,6 +50,14 @@ export interface RuntimeConfig { // Default runtime configuration const DEFAULT_RUNTIME_CONFIG: RuntimeConfig = { language: DEFAULT_LANGUAGE, + + // English, rather than a guess at the chrome language from `app.getLocale()`. An install that + // is being upgraded has never been asked this question, and answering it from the OS would + // move a working English UI under a user who did not ask for that - while a new install is + // asked outright on the first step of the first-run wizard, where the options are listed by + // endonym and picking one translates the step itself on the spot. + uiLanguage: DEFAULT_UI_LANGUAGE, + sessionToken: '', rememberMe: true, email: '', @@ -124,6 +141,11 @@ class ConfigStore { // ASR URL and the request bodies. config.language = resolveLanguage(config.language); + // Same treatment, one step further: an unresolved UI language reaches a lookup in the + // locale table rather than a request body, and comes back undefined - which renders an app + // with no text in it at all. + config.uiLanguage = resolveUiLanguage(config.uiLanguage); + return config; } @@ -271,6 +293,12 @@ export const configStore = new ConfigStore(); const legacy = (raw as (StoredRuntime & Record) | undefined)?.professionalMode; migration.hintOnlyMode = typeof legacy === 'boolean' ? legacy : true; } + if (raw?.uiLanguage === undefined) { + // Written explicitly rather than left to the default backfill in `getConfig`, so that an + // install which has been running in English keeps English on disk even if a later release + // changes what a fresh install defaults to. + migration.uiLanguage = DEFAULT_UI_LANGUAGE; + } if (raw?.mockLiveHintsEnabled === undefined) { // `mockLiveSuggestionsEnabled` is the pre-rename name, and it is read forward for the same // reason `professionalMode` is: the setting means what it meant and now defaults the way it diff --git a/src/main/types/ui-language.ts b/src/main/types/ui-language.ts new file mode 100644 index 00000000..34cbe410 --- /dev/null +++ b/src/main/types/ui-language.ts @@ -0,0 +1,38 @@ +/** + * The language the app's own chrome is written in: buttons, headings, dialogs, toasts. + * + * Deliberately not the same setting as `Language` in ./language.ts, which is the language the + * *interview* runs in. The two answer different questions and routinely disagree: a Russian + * speaker interviewing in English wants an English transcript and a Russian app, and inferring + * either from the other gets that user wrong in one direction or the other. So this is asked + * outright - the first step of the first-run wizard - and never derived. + * + * Narrow on purpose. A language belongs here once the chrome is actually translated into it, + * which is a file in `src/renderer/i18n/locales` rather than a code in a list; offering one + * without that file is offering a UI that falls back to English everywhere it matters. + * + * `src/renderer/types/ui-language.ts` carries the same enum plus the display metadata the picker + * needs, the way `Language` and `SuggestionMode` are mirrored across the two processes. + */ +export enum UiLanguage { + English = 'en', + Russian = 'ru', +} + +export const DEFAULT_UI_LANGUAGE = UiLanguage.English; + +const UI_LANGUAGE_CODES = new Set(Object.values(UiLanguage)); + +/** + * Map a stored or incoming value onto the enum, falling back to English. + * + * Same job `resolveLanguage` does for the interview language and for the same reason: the disk + * holds whatever some build wrote, and a locale this build has no translation file for must + * resolve to one it does rather than reaching a lookup that would come back undefined. + */ +export function resolveUiLanguage(raw: string | null | undefined): UiLanguage { + if (!raw) return DEFAULT_UI_LANGUAGE; + + const normalized = raw.trim().toLowerCase(); + return UI_LANGUAGE_CODES.has(normalized) ? (normalized as UiLanguage) : DEFAULT_UI_LANGUAGE; +} diff --git a/src/main/utils/suggestion-error.ts b/src/main/utils/suggestion-error.ts index 40b3cbd7..4936bc11 100644 --- a/src/main/utils/suggestion-error.ts +++ b/src/main/utils/suggestion-error.ts @@ -1,11 +1,21 @@ import { ApiRequestError } from '../api/client.js'; +import { uiStrings } from './ui-strings.js'; +/** + * What to put on a suggestion card that failed. + * + * The two messages this writes itself follow the chrome language - they are rendered verbatim on + * the card the candidate is reading. Anything else is the error's own message, passed through: + * for an `ApiRequestError` that is the backend's text, and for everything else it is a fault + * whose wording is more useful to whoever is debugging it than to the candidate. + */ export function getSuggestionErrorMessage(error: unknown): string { if (error instanceof ApiRequestError) { + const strings = uiStrings(); if (error.status === 429) { - return 'Too many requests. Please try again later.'; + return strings.suggestionErrors.tooManyRequests; } - return 'Failed to generate response.'; + return strings.suggestionErrors.generateFailed; } return error instanceof Error ? error.message : String(error); } @@ -22,7 +32,8 @@ export type SuggestionStallStage = 'connect' | 'first-token' | 'stream'; /** What to tell the candidate about a request abandoned at `stage`. */ export function getStallMessage(stage: SuggestionStallStage): string { + const strings = uiStrings(); return stage === 'connect' - ? 'Could not reach the server. Check your connection and try again.' - : 'The response timed out. Please try again.'; + ? strings.suggestionErrors.cannotReachServer + : strings.suggestionErrors.responseTimedOut; } diff --git a/src/main/utils/ui-strings.ts b/src/main/utils/ui-strings.ts new file mode 100644 index 00000000..c9d10974 --- /dev/null +++ b/src/main/utils/ui-strings.ts @@ -0,0 +1,350 @@ +import { configStore } from '../store/config.store.js'; +import { UiLanguage } from '../types/ui-language.js'; + +/** + * The main process's share of the app's chrome. + * + * Most of what a user reads is rendered in the renderer and translated there, through + * `src/renderer/i18n`. These are the strings main writes itself, and there are two kinds: + * + * - the placeholder copy the panels show on launch and after every Clear, which main seeds + * because it owns the state the panels render; + * - the push notifications main raises, which arrive in the renderer as toasts with nothing but + * a message and a severity. Those come from paths the renderer cannot see - a global hotkey + * pressed while the window is hidden, a session that expires, a Dock-level refusal. + * + * Table keyed by language, read off the config store, the same shape `export-labels.ts` uses. + * Deliberately *not* that table: the exported report follows the **interview** language, because + * it is handed to someone who was not there. This follows the **chrome** language, because it is + * read by the person using the app. + * + * Backend error text is not here and is not translated. The client cannot translate a string it + * did not write, and replacing a specific server message with a generic local one would lose the + * only useful half of it. + */ +export interface UiStrings { + /** Panel placeholder copy, so an app that has never run an interview has something to show. */ + placeholderTranscript: string; + placeholderQuestion: string; + placeholderLiveAnswer: string; + placeholderActionAnswer: string; + + /** The session token expired under the user, from the health-check poll. */ + sessionExpired: string; + + /** + * Auth and account failures **this process authored**, which is the distinction that matters. + * + * The renderer has a parallel set in `t.auth.errors`, and they are not redundant: those are + * for a rejection that arrived with no message at all, while these are the message. Every + * `{ success: false, error }` below sets `error`, so the renderer's `result?.error || t...` + * never reaches its fallback on these paths - which left the most frequently read error in + * the app ("Invalid email or password") in English on a Russian install. + * + * Anything the backend sent is still preferred over both and passed through untouched. + */ + authErrors: Record< + | 'sendCodeFailed' + | 'invalidCode' + | 'signupFailed' + | 'invalidCredentials' + | 'loginFailed' + | 'logoutFailed' + | 'changePasswordFailed' + | 'sendResetCodeFailed' + | 'invalidResetCode' + | 'resetFailed', + string + >; + + accountErrors: Record< + 'fetchFailed' | 'updateFailed' | 'onboardingFailed' | 'migrateFailed' | 'loadConfigFailed', + string + >; + + /** + * The two transport failures `api/client.ts` names itself. + * + * These matter more than their size suggests. Every caller's fallback is + * `response.error.message || uiStrings()...`, and on a timeout `message` *is* one of these - + * so the English string wins over every translated fallback behind it. A dead or slow backend + * is the most common failure the app has. + * + * A message thrown by the runtime (`fetch failed`) is still passed through: we did not write + * it, which is the same line drawn for backend text. + */ + transportErrors: Record<'timedOut' | 'networkFailed', string>; + + /** Same shape again for the payment service, whose every path sets its own message. */ + paymentErrors: Record< + | 'getPlans' + | 'getCurrencies' + | 'createPayment' + | 'getStatus' + | 'getHistory' + | 'getCredits' + | 'pollingTimeout', + string + >; + + /** + * Written onto a suggestion's `error` field, which the panel renders verbatim on the card the + * candidate is reading mid-interview. + */ + suggestionErrors: Record< + | 'tooManyRequests' + | 'generateFailed' + | 'cannotReachServer' + | 'responseTimedOut' + | 'emptyResponse', + string + >; + + /** + * Written onto the mock session's `error`, which `/mock-interview` deliberately surfaces - the + * microphone one in particular is the explanation a candidate is owed for a session that + * skipped its way through questions they were already billed for. + */ + mockErrors: Record< + | 'liveRunning' + | 'firstQuestionFailed' + | 'startFailed' + | 'nothingRecorded' + | 'endedBeforeScoring' + | 'notEnoughCredits', + string + > & { firstQuestionFailedWith: (reason: string) => string }; + + /** One action-suggestion step refused because another is still running. */ + actionNames: Record<'screenshotCapture' | 'captureSuggestion', string>; + actionBlocked: (runningAction: string) => string; + + /** Why entering stealth mode was refused; see `stealthUnavailableReason`. */ + stealthSignedOut: string; + stealthDuringMock: string; + stealthNotRunning: string; + opacityStealthOnly: string; + + /** + * The native save dialog's own title and file-type labels. + * + * Chrome rather than part of the document: the report inside the file follows the interview + * language, but the window asking where to put it is being read by the person at the keyboard. + * `.docx` and `.md` stay as they are - a file extension is not a word. + */ + saveTranscriptTitle: string; + saveMockReportTitle: string; + saveImageTitle: string; + markdownFilter: string; + wordFilter: string; + pngFilter: string; + + /** Action suggestions, refused for a reason the renderer's own gating cannot always see. */ + actionDuringMock: string; + cannotClearImages: string; + cannotCaptureScreenshot: string; + maxCaptures: (max: number) => string; + captureFailed: string; + cannotGenerateSuggestion: string; +} + +const STRINGS: Record = { + [UiLanguage.English]: { + placeholderTranscript: 'Transcripts will be here', + placeholderQuestion: 'Interviewer questions will be here', + placeholderLiveAnswer: 'Suggested answers will be here in real-time', + placeholderActionAnswer: + 'Triggered suggestions will be here. For example, reply suggestion, coding test solution, diagram descriptions, etc.', + + sessionExpired: 'Your session expired, please log in again.', + + authErrors: { + sendCodeFailed: 'Failed to send verification code', + invalidCode: 'Invalid or expired verification code', + signupFailed: 'Signup failed', + invalidCredentials: 'Invalid email or password', + loginFailed: 'Login failed', + logoutFailed: 'Logout failed', + changePasswordFailed: 'Change password failed', + sendResetCodeFailed: 'Failed to send password reset code', + invalidResetCode: 'Invalid or expired reset code', + resetFailed: 'Password reset failed', + }, + + accountErrors: { + fetchFailed: 'Failed to fetch account', + updateFailed: 'Failed to update account', + onboardingFailed: 'Failed to save your setup', + migrateFailed: 'Failed to migrate local configuration', + loadConfigFailed: 'Failed to load configuration', + }, + + transportErrors: { + timedOut: 'The request timed out', + networkFailed: 'Network request failed', + }, + + paymentErrors: { + getPlans: 'Failed to get plans', + getCurrencies: 'Failed to get available currencies', + createPayment: 'Failed to create payment', + getStatus: 'Failed to get payment status', + getHistory: 'Failed to get payment history', + getCredits: 'Failed to get credits', + pollingTimeout: 'Payment polling timeout', + }, + + suggestionErrors: { + tooManyRequests: 'Too many requests. Please try again later.', + generateFailed: 'Failed to generate response.', + cannotReachServer: 'Could not reach the server. Check your connection and try again.', + responseTimedOut: 'The response timed out. Please try again.', + emptyResponse: 'The model returned an empty response.', + }, + + mockErrors: { + liveRunning: 'Stop the live interview before starting a mock interview.', + firstQuestionFailed: 'Failed to generate the first question. Please try again.', + firstQuestionFailedWith: (reason: string) => + `Could not generate the first question: ${reason}`, + startFailed: 'Failed to start the mock interview', + nothingRecorded: + 'The interview ended with nothing recorded. Check that the right microphone is selected and that it is not muted, then try again.', + endedBeforeScoring: 'The interview was ended before scoring finished.', + notEnoughCredits: 'Not enough credits', + }, + + actionNames: { + screenshotCapture: 'Screenshot capture', + captureSuggestion: 'Action suggestion generation', + }, + actionBlocked: (runningAction: string) => + `${runningAction} is in progress. Try again a bit later.`, + + stealthSignedOut: 'You must be logged in to use stealth mode.', + stealthDuringMock: + 'Stealth mode is off during a mock interview. This is practice, not a live call.', + stealthNotRunning: 'Stealth mode is only available during a live interview.', + opacityStealthOnly: 'Opacity toggle is only available in stealth mode.', + + saveTranscriptTitle: 'Save Transcript', + saveMockReportTitle: 'Save Mock Interview Report', + saveImageTitle: 'Save Image', + markdownFilter: 'Markdown', + wordFilter: 'Word Document', + pngFilter: 'PNG Image', + + actionDuringMock: 'Action suggestions are unavailable during a mock interview.', + cannotClearImages: 'Cannot clear images when assistant is not running', + cannotCaptureScreenshot: 'Cannot capture screenshot when assistant is not running', + maxCaptures: (max: number) => + `Maximum of ${max} screenshots reached. Please clear images and try again.`, + captureFailed: 'Screenshot capture failed. Please try again.', + cannotGenerateSuggestion: 'Cannot generate suggestion when assistant is not running', + }, + + [UiLanguage.Russian]: { + placeholderTranscript: 'Здесь будет расшифровка', + placeholderQuestion: 'Здесь будут вопросы интервьюера', + placeholderLiveAnswer: 'Здесь в реальном времени будут появляться подсказки с ответами', + placeholderActionAnswer: + 'Здесь будут подсказки по запросу: например, вариант ответа, решение задачи по программированию, описание схемы и так далее.', + + sessionExpired: 'Сессия истекла, войдите заново.', + + authErrors: { + sendCodeFailed: 'Не удалось отправить код подтверждения', + invalidCode: 'Код подтверждения неверен или истёк', + signupFailed: 'Не удалось создать учётную запись', + invalidCredentials: 'Неверная почта или пароль', + loginFailed: 'Не удалось войти', + logoutFailed: 'Не удалось выйти', + changePasswordFailed: 'Не удалось изменить пароль', + sendResetCodeFailed: 'Не удалось отправить код сброса пароля', + invalidResetCode: 'Код сброса неверен или истёк', + resetFailed: 'Не удалось изменить пароль', + }, + + accountErrors: { + fetchFailed: 'Не удалось получить данные учётной записи', + updateFailed: 'Не удалось обновить учётную запись', + onboardingFailed: 'Не удалось сохранить настройки', + migrateFailed: 'Не удалось перенести локальные настройки', + loadConfigFailed: 'Не удалось загрузить настройки', + }, + + transportErrors: { + timedOut: 'Время ожидания запроса истекло', + networkFailed: 'Сетевой запрос не удался', + }, + + paymentErrors: { + getPlans: 'Не удалось получить тарифы', + getCurrencies: 'Не удалось получить список доступных валют', + createPayment: 'Не удалось создать платёж', + getStatus: 'Не удалось получить статус платежа', + getHistory: 'Не удалось получить историю платежей', + getCredits: 'Не удалось получить баланс кредитов', + pollingTimeout: 'Платёж не подтвердился вовремя', + }, + + suggestionErrors: { + tooManyRequests: 'Слишком много запросов. Попробуйте позже.', + generateFailed: 'Не удалось создать ответ.', + cannotReachServer: + 'Не удалось связаться с сервером. Проверьте подключение и попробуйте снова.', + responseTimedOut: 'Ответ не пришёл вовремя. Попробуйте снова.', + emptyResponse: 'Модель вернула пустой ответ.', + }, + + mockErrors: { + liveRunning: 'Остановите живое собеседование, прежде чем начинать пробное.', + firstQuestionFailed: 'Не удалось создать первый вопрос. Попробуйте снова.', + firstQuestionFailedWith: (reason: string) => `Не удалось создать первый вопрос: ${reason}`, + startFailed: 'Не удалось начать пробное собеседование', + nothingRecorded: + 'Собеседование закончилось, и ничего не записано. Проверьте, что выбран нужный микрофон и он не выключен, затем попробуйте снова.', + endedBeforeScoring: 'Собеседование завершили до того, как закончилась оценка.', + notEnoughCredits: 'Недостаточно кредитов', + }, + + actionNames: { + screenshotCapture: 'Создание снимка экрана', + captureSuggestion: 'Создание подсказки по запросу', + }, + actionBlocked: (runningAction: string) => + `${runningAction} ещё выполняется. Попробуйте чуть позже.`, + + stealthSignedOut: 'Скрытый режим доступен только после входа.', + stealthDuringMock: + 'В пробном собеседовании скрытый режим выключен. Это тренировка, а не настоящий звонок.', + stealthNotRunning: 'Скрытый режим доступен только во время живого собеседования.', + opacityStealthOnly: 'Прозрачность переключается только в скрытом режиме.', + + saveTranscriptTitle: 'Сохранить расшифровку', + saveMockReportTitle: 'Сохранить отчёт о пробном собеседовании', + saveImageTitle: 'Сохранить изображение', + markdownFilter: 'Markdown', + wordFilter: 'Документ Word', + pngFilter: 'Изображение PNG', + + actionDuringMock: 'Подсказки по запросу недоступны во время пробного собеседования.', + cannotClearImages: 'Нельзя очистить снимки, пока ассистент не запущен', + cannotCaptureScreenshot: 'Нельзя сделать снимок экрана, пока ассистент не запущен', + maxCaptures: (max: number) => + `Достигнут предел снимков экрана (${max}). Очистите их и попробуйте снова.`, + captureFailed: 'Не удалось сделать снимок экрана. Попробуйте ещё раз.', + cannotGenerateSuggestion: 'Нельзя создать подсказку, пока ассистент не запущен', + }, +}; + +/** + * The strings for the chrome language currently on disk. + * + * Read per call rather than captured once at module load: these are used from paths that run for + * the whole life of the process - a health-check poll, a global hotkey handler - and the setting + * can change under any of them. + */ +export function uiStrings(): UiStrings { + return STRINGS[configStore.getConfig().uiLanguage]; +} diff --git a/src/renderer/components/custom/change-password-dialog.tsx b/src/renderer/components/custom/change-password-dialog.tsx index 854fa57b..99460f6a 100644 --- a/src/renderer/components/custom/change-password-dialog.tsx +++ b/src/renderer/components/custom/change-password-dialog.tsx @@ -11,6 +11,7 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { useT } from '@/i18n'; interface ChangePasswordDialogProps { open: boolean; @@ -27,6 +28,7 @@ export function ChangePasswordDialog({ loading, error, }: ChangePasswordDialogProps) { + const t = useT(); const [currentPassword, setCurrentPassword] = useState(''); const [newPassword, setNewPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); @@ -34,9 +36,9 @@ export function ChangePasswordDialog({ const handleSubmit = async () => { try { if (await onChangePassword(currentPassword, newPassword)) { - toast.success('Password changed successfully'); + toast.success(t.changePassword.succeeded); } else { - toast.error('Failed to change password'); + toast.error(t.changePassword.failed); } } catch (err) { // Error is handled by parent component @@ -60,13 +62,13 @@ export function ChangePasswordDialog({ - Change Password - Enter your current password and choose a new one. + {t.changePassword.title} + {t.changePassword.description}
setCurrentPassword(e.target.value)} - placeholder="Enter current password" + placeholder={t.changePassword.currentPlaceholder} maxLength={128} />
setNewPassword(e.target.value)} - placeholder="Enter new password" + placeholder={t.changePassword.nextPlaceholder} maxLength={128} />
setConfirmPassword(e.target.value)} - placeholder="Confirm new password" + placeholder={t.changePassword.confirmPlaceholder} maxLength={128} />
@@ -120,7 +122,7 @@ export function ChangePasswordDialog({ onClick={() => handleOpenChange(false)} disabled={loading} > - Cancel + {t.common.cancel} {/* Says why the button is dead. A mismatch is the one condition above that the user @@ -142,7 +144,7 @@ export function ChangePasswordDialog({ field has something in it, so it is not an error for a half-typed entry. */} {passwordsMismatch && (
- The new passwords do not match. + {t.changePassword.mismatch}
)} {error && ( diff --git a/src/renderer/components/custom/command-palette.tsx b/src/renderer/components/custom/command-palette.tsx index 5c4ade99..dbdb3991 100644 --- a/src/renderer/components/custom/command-palette.tsx +++ b/src/renderer/components/custom/command-palette.tsx @@ -37,6 +37,7 @@ import { useSaveHistoryGuard } from '@/hooks/use-save-history-guard'; import { useSuggestionMode } from '@/hooks/use-suggestion-mode'; import { useThemeStore } from '@/hooks/use-theme-store'; import { useTranscriptPanel } from '@/hooks/use-transcript-panel'; +import { useT } from '@/i18n'; import { isMac } from '@/lib/consts'; /** @@ -68,6 +69,7 @@ function useCommandPaletteHotkey(inert: boolean) { } export function CommandPalette() { + const t = useT(); const isStealth = useIsStealthMode(); // Inert during an interview for the same reason it is inert in stealth mode, and stated the // same way: every entry it carries is a navigation, a session start, or a sign-out, and all @@ -129,7 +131,7 @@ export function CommandPalette() { // Off the caught error, not the hook's `error` state - `logout` sets it and throws in the // same tick, so this closure's copy is still last render's value. console.error('Sign out failed:', err); - toast.error(err instanceof Error ? err.message : 'Failed to sign out'); + toast.error(err instanceof Error ? err.message : t.home.signOutFailed); } }; @@ -138,40 +140,40 @@ export function CommandPalette() { - + - No matching action. + {t.commandPalette.empty} {/* No entry for `/main`. The live console is not a destination you visit - it is where a running session is, and the only two ways onto it are starting one (below) and coming back to one already running (the home screen's own card). Sending an idle user there landed them on a bar whose every control is disabled, with the sole working one being the way back. */} - + run(() => navigate('/'))}> - Home + {t.commandPalette.home} run(() => navigate('/account'))}> - Account + {t.commandPalette.account} run(() => navigate('/configuration'))}> - Configuration + {t.commandPalette.configuration} run(() => navigate('/payment'))}> - Buy Credits + {t.commandPalette.buyCredits} - + {/* Both starts hand off to `/main` through router state rather than starting anything here: the control panel there owns the whole start sequence, and the palette is reachable from every route, including ones where none of it is mounted. Named the @@ -186,39 +188,41 @@ export function CommandPalette() { onSelect={() => run(() => navigate('/', { state: { openMockSetup: true } }))} > - Start mock interview + {t.commandPalette.startMock} )} run(() => navigate('/main', { state: { autoStartLive: true } }))} > - Start live assistant + {t.commandPalette.startLive} run(toggleSuggestionMode)}> {hintOnly ? : } - {hintOnly ? 'Switch to full-sentence mode' : 'Switch to hint-only mode'} + {hintOnly ? t.commandPalette.switchToFullSentence : t.commandPalette.switchToHintOnly} run(toggleTranscript)}> - {transcriptVisible ? 'Hide Transcript' : 'Show Transcript'} + {transcriptVisible + ? t.commandPalette.hideTranscript + : t.commandPalette.showTranscript} - + run(() => navigate('/documentation'))}> - Documentation + {t.commandPalette.documentation} run(() => setIsHotkeysOpen(true))}> - Keyboard Shortcuts + {t.commandPalette.hotkeys} run(toggleTheme)}> {isDark ? : } - {isDark ? 'Switch to Light Mode' : 'Switch to Dark Mode'} + {isDark ? t.commandPalette.switchToLight : t.commandPalette.switchToDark} {/* Stealth mode is not offered here. It is a live-interview control - it hides the app from a screen share that is only happening during a real call - and this @@ -227,7 +231,7 @@ export function CommandPalette() { {isLoggedIn && ( run(() => void handleSignOut())}> - Sign Out + {t.commandPalette.signOut} )} diff --git a/src/renderer/components/custom/connecting-notice.tsx b/src/renderer/components/custom/connecting-notice.tsx index 7727f3ed..e1b204bf 100644 --- a/src/renderer/components/custom/connecting-notice.tsx +++ b/src/renderer/components/custom/connecting-notice.tsx @@ -1,10 +1,14 @@ import { Loader } from 'lucide-react'; +import { useT } from '@/i18n'; + export default function ConnectingNotice() { + const t = useT(); + return (
- Connecting to server… + {t.notices.connecting}
); } diff --git a/src/renderer/components/custom/control-panel/audio-group.tsx b/src/renderer/components/custom/control-panel/audio-group.tsx index 36fd6b07..ab22d35d 100644 --- a/src/renderer/components/custom/control-panel/audio-group.tsx +++ b/src/renderer/components/custom/control-panel/audio-group.tsx @@ -16,6 +16,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { useAppState } from '@/hooks/use-app-state'; import { useAudioInputDevice } from '@/hooks/use-audio-input-device'; import { useConfigStore } from '@/hooks/use-config-store'; +import { useT } from '@/i18n'; import { cn } from '@/lib/utils'; import { RunningState } from '@/types/app-state'; import { type AudioDevice } from '@/types/audio-device'; @@ -45,6 +46,7 @@ export function AudioGroup({ audioInputDeviceNotFound, getDisabled, }: AudioGroupProps) { + const t = useT(); const [open, setOpen] = useState(false); const { runningState } = useAppState(); const { config, updateConfig } = useConfigStore(); @@ -110,10 +112,10 @@ export function AudioGroup({ // makes the conditions this control reports reachable without seeing it. aria-label={ swapFailed - ? 'Audio options - could not switch microphone, still using the previous one' + ? t.audioGroup.optionsSwapFailed : audioInputDeviceNotFound - ? 'Audio options - the selected microphone was not found' - : 'Audio options' + ? t.audioGroup.optionsDeviceNotFound + : t.audioGroup.options } aria-busy={switching} > @@ -125,7 +127,7 @@ export function AudioGroup({ -

Audio options

+

{t.audioGroup.options}

{/* A failed swap raises the same badge as a missing device. Nobody opens a dialog @@ -144,15 +146,15 @@ export function AudioGroup({ - Audio Options + {t.audioGroup.dialogTitle} - Select physical microphone that you use. + {t.audioGroup.dialogDescription} {/* Microphone Select */}
{/* `|| undefined` keeps the pre-existing binding exactly as it was. Radix reserves the empty string for clearing a selection, and the hook reports an unset device as @@ -164,7 +166,7 @@ export function AudioGroup({ onValueChange={(v) => void setDevice(v)} > - + {usableAudioInputDevices.map((device) => ( @@ -185,23 +187,20 @@ export function AudioGroup({ the toast, which does not. */} {running && (switching ? ( -

Switching microphone...

+

{t.audioGroup.switching}

) : failedDeviceName ? (

) : ( // Says what will not happen, which is the question a candidate mid-interview // actually has before touching a control on a live session.

- Takes effect immediately. Transcription keeps running. + {t.audioGroup.takesEffectImmediately}

))}
diff --git a/src/renderer/components/custom/control-panel/index.tsx b/src/renderer/components/custom/control-panel/index.tsx index 97adeb5e..530cc627 100644 --- a/src/renderer/components/custom/control-panel/index.tsx +++ b/src/renderer/components/custom/control-panel/index.tsx @@ -9,6 +9,7 @@ import { useConfigStore } from '@/hooks/use-config-store'; import { useEndLiveSession } from '@/hooks/use-end-live-session'; import useIsStealthMode from '@/hooks/use-is-stealth-mode'; import { useSaveHistoryGuard } from '@/hooks/use-save-history-guard'; +import { useT } from '@/i18n'; import { isMac } from '@/lib/consts'; import { getElectron } from '@/lib/utils'; import { RunningState } from '@/types/app-state'; @@ -23,6 +24,7 @@ import { SuggestionModeGroup } from './suggestion-mode-group'; import { ToolsGroup } from './tools-group'; export default function ControlPanel() { + const t = useT(); const isStealth = useIsStealthMode(); const navigate = useNavigate(); const location = useLocation(); @@ -76,28 +78,28 @@ export default function ControlPanel() { // setting a name they did set sends them into a dialog that cannot save either. { ok: appState?.interviewConfigLoaded ?? false, - message: 'Could not load your saved configuration. Reconnecting - try again in a moment.', + message: t.controlPanel.checks.configUnavailable, // Nothing else re-pulls after a failed startup fetch, so "try again" has to actually // retry: without this the same toast repeats forever however often Start is pressed. onFail: () => void getElectron()?.account?.refresh(), }, { ok: !!appState?.interviewConfig?.fullName, - message: 'Full name is not set', + message: t.controlPanel.checks.nameMissing, onFail: () => navigate('/account'), }, { ok: appState?.interviewConfig?.hasProfileData ?? false, - message: 'Profile data is not set', + message: t.controlPanel.checks.profileMissing, onFail: () => navigate('/account'), }, { ok: !noAudioInputDevices, - message: 'No microphone was detected. Connect one and try again.', + message: t.controlPanel.checks.noMicrophone, }, { ok: !audioInputDeviceNotFound, - message: `Audio input device "${selectedAudioInputDeviceName}" is not found`, + message: t.controlPanel.checks.deviceNotFound(selectedAudioInputDeviceName), }, ]; @@ -120,7 +122,7 @@ export default function ControlPanel() { // three-second "Stopping" for a session that never started, and its own failure would // land here as an unhandled rejection. console.error('Failed to start assistant:', error); - toast.error(error instanceof Error ? error.message : 'Failed to start assistant'); + toast.error(error instanceof Error ? error.message : t.controlPanel.startFailed); } }; diff --git a/src/renderer/components/custom/control-panel/language-group.tsx b/src/renderer/components/custom/control-panel/language-group.tsx index e61ac348..4dbd8a63 100644 --- a/src/renderer/components/custom/control-panel/language-group.tsx +++ b/src/renderer/components/custom/control-panel/language-group.tsx @@ -13,6 +13,7 @@ import { import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useAppState } from '@/hooks/use-app-state'; import { useInterviewLanguage } from '@/hooks/use-interview-language'; +import { useT } from '@/i18n'; import { cn } from '@/lib/utils'; import { RunningState } from '@/types/app-state'; import { LANGUAGES } from '@/types/language'; @@ -40,6 +41,7 @@ interface LanguageGroupProps { * exactly that - a candidate whose interview has just switched language has seconds. */ export function LanguageGroup({ getDisabled }: LanguageGroupProps) { + const t = useT(); const { runningState } = useAppState(); const { language, option, switching, reconnectFailed, setLanguage, clearReconnectFailed } = useInterviewLanguage(); @@ -73,8 +75,8 @@ export function LanguageGroup({ getDisabled }: LanguageGroupProps) { disabled={disabled} aria-label={ halfApplied - ? `Interview language: ${option.name} - transcription did not switch` - : `Interview language: ${option.name}` + ? t.languageGroup.currentHalfApplied(option.name) + : t.languageGroup.current(option.name) } aria-busy={switching} > @@ -96,13 +98,13 @@ export function LanguageGroup({ getDisabled }: LanguageGroupProps) { -

Interview Language: {option.nativeName}

+

{t.languageGroup.tooltip(option.nativeName)}

{switching - ? 'Reconnecting transcription...' + ? t.languageGroup.reconnecting : halfApplied - ? 'Suggestions only - transcription is still reconnecting' - : 'Speech recognition and suggestions'} + ? t.languageGroup.suggestionsOnly + : t.languageGroup.speechAndSuggestions}

@@ -118,7 +120,7 @@ export function LanguageGroup({ getDisabled }: LanguageGroupProps) { className="flex max-h-[min(60vh,20rem)] w-56 flex-col" > - Interview language + {t.languageGroup.menuLabel}
@@ -166,16 +168,13 @@ export function LanguageGroup({ getDisabled }: LanguageGroupProps) { className="flex items-start gap-1.5 px-2 py-1.5 text-xs text-destructive" >
) : ( /* Says what will happen before it happens: a two-second hole in the transcript is alarming if it arrives unannounced mid-question. */
- Transcription reconnects; the current sentence may be cut short. + {t.languageGroup.willReconnect}
)} diff --git a/src/renderer/components/custom/control-panel/main-group.tsx b/src/renderer/components/custom/control-panel/main-group.tsx index e2ce6f97..fadcf447 100644 --- a/src/renderer/components/custom/control-panel/main-group.tsx +++ b/src/renderer/components/custom/control-panel/main-group.tsx @@ -3,6 +3,7 @@ import { Square } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useAppState } from '@/hooks/use-app-state'; +import { useT } from '@/i18n'; import { cn } from '@/lib/utils'; import { RunningState } from '@/types/app-state'; @@ -37,6 +38,7 @@ interface MainGroupProps { * read as one. */ export function MainGroup({ onStop }: MainGroupProps) { + const t = useT(); const { runningState } = useAppState(); const isRunning = runningState === RunningState.Running; @@ -71,21 +73,19 @@ export function MainGroup({ onStop }: MainGroupProps) { disabled={!isRunning} > - Stop + {t.controlPanel.stop} -

Stop the assistant

+

{t.controlPanel.stopTooltip}

{isRunning ? ( -

- Ends the session, offers to save it, and returns home -

+

{t.controlPanel.stopRunningHint}

) : (

{runningState === RunningState.Idle - ? 'Nothing is running - start an interview from the home screen' - : 'Available once the session is running'} + ? t.controlPanel.stopIdleHint + : t.controlPanel.stopTransientHint}

)}
diff --git a/src/renderer/components/custom/control-panel/suggestion-mode-group.tsx b/src/renderer/components/custom/control-panel/suggestion-mode-group.tsx index d32c865a..18e3c417 100644 --- a/src/renderer/components/custom/control-panel/suggestion-mode-group.tsx +++ b/src/renderer/components/custom/control-panel/suggestion-mode-group.tsx @@ -3,6 +3,7 @@ import { ListChecks, Route } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useSuggestionMode } from '@/hooks/use-suggestion-mode'; +import { useT } from '@/i18n'; import { Hotkey, HOTKEYS } from '@/lib/hotkeys'; import { cn } from '@/lib/utils'; @@ -22,6 +23,7 @@ import { BAR_ACTIVE, BAR_GHOST, BAR_ICON_BUTTON } from './bar'; * survives a colour-blind reader. */ export function SuggestionModeGroup() { + const t = useT(); const { hintOnly, toggle } = useSuggestionMode(); return ( @@ -34,9 +36,7 @@ export function SuggestionModeGroup() { className={cn(BAR_ICON_BUTTON, hintOnly ? BAR_ACTIVE : BAR_GHOST)} aria-pressed={hintOnly} aria-label={ - hintOnly - ? 'Suggestion mode: hint-only. Switch to full sentences' - : 'Suggestion mode: full sentences. Switch to hint-only' + hintOnly ? t.suggestionMode.ariaHintOnly : t.suggestionMode.ariaFullSentence } onClick={toggle} > @@ -49,11 +49,13 @@ export function SuggestionModeGroup() {

- {hintOnly ? 'Hint-only mode' : 'Full-sentence mode'} ( - {HOTKEYS[Hotkey.ToggleSuggestionMode].combo}) + {t.common.withCombo( + hintOnly ? t.suggestionMode.hintOnlyMode : t.suggestionMode.fullSentenceMode, + HOTKEYS[Hotkey.ToggleSuggestionMode].combo + )}

- {hintOnly ? 'Headline + keyword bullets' : 'Answers written out in full'} + {hintOnly ? t.suggestionMode.hintOnlySummary : t.suggestionMode.fullSentenceSummary}

diff --git a/src/renderer/components/custom/control-panel/tools-group.tsx b/src/renderer/components/custom/control-panel/tools-group.tsx index ed426842..24488526 100644 --- a/src/renderer/components/custom/control-panel/tools-group.tsx +++ b/src/renderer/components/custom/control-panel/tools-group.tsx @@ -27,6 +27,7 @@ import { useAppState } from '@/hooks/use-app-state'; import { useSaveHistoryGuard } from '@/hooks/use-save-history-guard'; import useTools from '@/hooks/use-tools'; import { useTranscriptPanel } from '@/hooks/use-transcript-panel'; +import { useT } from '@/i18n'; import { Hotkey, HOTKEYS } from '@/lib/hotkeys'; import { cn, getElectron } from '@/lib/utils'; import { RunningState } from '@/types/app-state'; @@ -39,6 +40,7 @@ interface ToolsGroupProps { } export function ToolsGroup({ getDisabled }: ToolsGroupProps) { + const t = useT(); const { runningState, appState } = useAppState(); const { exporting, exportTranscript, exportMockReport, clearAll, setPlaceholderData } = useTools(); @@ -89,7 +91,7 @@ export function ToolsGroup({ getDisabled }: ToolsGroupProps) { await setPlaceholderData(); } catch (error) { console.error(error); - toast.error('Failed to clear'); + toast.error(t.toolsGroup.clearFailed); } finally { setClearing(false); } @@ -114,8 +116,8 @@ export function ToolsGroup({ getDisabled }: ToolsGroupProps) { const onExportTranscript = async (format: ExportFormat) => { if (nothingToExport) { - toast.error('There is nothing to export yet', { - description: 'Run an interview first, then export the transcript and suggestions.', + toast.error(t.toolsGroup.nothingToExport, { + description: t.toolsGroup.nothingToExportDescription, }); return; } @@ -130,7 +132,7 @@ export function ToolsGroup({ getDisabled }: ToolsGroupProps) { console.error(error); // The message when there is nothing to export names the reason, and a generic "failed" // over it would send the user looking for a fault that is not there. - toast.error(error instanceof Error ? error.message : 'Failed to export interview'); + toast.error(error instanceof Error ? error.message : t.toolsGroup.exportFailed); } }; @@ -145,7 +147,9 @@ export function ToolsGroup({ getDisabled }: ToolsGroupProps) { size="sm" className={cn(BAR_ICON_BUTTON, transcriptVisible ? BAR_ACTIVE : BAR_GHOST)} aria-pressed={transcriptVisible} - aria-label={transcriptVisible ? 'Hide transcription' : 'Show transcription'} + aria-label={ + transcriptVisible ? t.toolsGroup.hideTranscription : t.toolsGroup.showTranscription + } > {transcriptVisible ? ( @@ -156,8 +160,10 @@ export function ToolsGroup({ getDisabled }: ToolsGroupProps) {

- {transcriptVisible ? 'Hide Transcription' : 'Show Transcription'} ( - {HOTKEYS[Hotkey.ToggleTranscript].combo}) + {t.common.withCombo( + transcriptVisible ? t.toolsGroup.hideTranscription : t.toolsGroup.showTranscription, + HOTKEYS[Hotkey.ToggleTranscript].combo + )}

@@ -191,18 +197,16 @@ export function ToolsGroup({ getDisabled }: ToolsGroupProps) { size="sm" className={cn(BAR_ICON_BUTTON, BAR_GHOST)} disabled={stealthDisabled} - aria-label="Enter stealth mode" + aria-label={t.toolsGroup.enterStealth} > -

Stealth Mode ({HOTKEYS[Hotkey.ToggleStealth].combo})

+

{t.common.withCombo(t.toolsGroup.stealthMode, HOTKEYS[Hotkey.ToggleStealth].combo)}

- {stealthDisabled - ? 'Available once the live interview is running - there is nothing to hide from yet.' - : 'Hides the app from screen capture. The same shortcut brings it back.'} + {stealthDisabled ? t.toolsGroup.stealthUnavailable : t.toolsGroup.stealthAvailable}

@@ -218,19 +222,24 @@ export function ToolsGroup({ getDisabled }: ToolsGroupProps) { onClick={() => void runActionSuggestion( () => getElectron()?.actionSuggestion.capture(), - 'Failed to capture screenshot' + t.toolsGroup.captureFailed ) } size="sm" className={cn(BAR_ICON_BUTTON, BAR_GHOST)} disabled={actionDisabled} - aria-label="Capture screenshot" + aria-label={t.toolsGroup.captureScreenshot} > -

Capture Screenshot ({HOTKEYS[Hotkey.Capture].combo})

+

+ {t.common.withCombo( + t.toolsGroup.captureScreenshotTooltip, + HOTKEYS[Hotkey.Capture].combo + )} +

@@ -240,19 +249,24 @@ export function ToolsGroup({ getDisabled }: ToolsGroupProps) { onClick={() => void runActionSuggestion( () => getElectron()?.actionSuggestion.clearImages(), - 'Failed to clear captures' + t.toolsGroup.clearCapturesFailed ) } size="sm" className={cn(BAR_ICON_BUTTON, BAR_GHOST)} disabled={actionDisabled} - aria-label="Clear captured screenshots" + aria-label={t.toolsGroup.clearCaptures} > -

Clear Captures ({HOTKEYS[Hotkey.ClearCaptures].combo})

+

+ {t.common.withCombo( + t.toolsGroup.clearCapturesTooltip, + HOTKEYS[Hotkey.ClearCaptures].combo + )} +

@@ -262,13 +276,13 @@ export function ToolsGroup({ getDisabled }: ToolsGroupProps) { onClick={() => void runActionSuggestion( () => getElectron()?.actionSuggestion.trigger(), - 'Failed to generate suggestion' + t.toolsGroup.generateSuggestionFailed ) } size="sm" className={cn(BAR_ICON_BUTTON, BAR_GHOST)} disabled={actionDisabled} - aria-label="Generate triggered suggestion" + aria-label={t.toolsGroup.generateSuggestion} > {actionBusy ? ( @@ -278,7 +292,12 @@ export function ToolsGroup({ getDisabled }: ToolsGroupProps) { -

Generate Suggestion ({HOTKEYS[Hotkey.TriggerWithoutCaptures].combo})

+

+ {t.common.withCombo( + t.toolsGroup.generateSuggestionTooltip, + HOTKEYS[Hotkey.TriggerWithoutCaptures].combo + )} +

@@ -292,7 +311,7 @@ export function ToolsGroup({ getDisabled }: ToolsGroupProps) { size="sm" className={cn(BAR_ICON_BUTTON, BAR_GHOST)} disabled={getDisabled(runningState) || exporting || clearing} - aria-label="Clear the interview" + aria-label={t.toolsGroup.clearInterview} aria-busy={clearing} > {clearing ? ( @@ -303,7 +322,7 @@ export function ToolsGroup({ getDisabled }: ToolsGroupProps) { -

Clear

+

{t.toolsGroup.clear}

{/* Non-modal for the same reason as the titlebar menu: a modal menu locks body pointer @@ -317,7 +336,7 @@ export function ToolsGroup({ getDisabled }: ToolsGroupProps) { size="sm" className={cn(BAR_ICON_BUTTON, BAR_GHOST)} disabled={getDisabled(runningState) || exporting} - aria-label="Export the interview" + aria-label={t.toolsGroup.exportTheInterview} aria-busy={exporting} > {exporting ? ( @@ -329,7 +348,7 @@ export function ToolsGroup({ getDisabled }: ToolsGroupProps) { -

Export Interview

+

{t.toolsGroup.exportInterview}

{/* Opens upward, and not just for looks: the menu is portalled into the overflow-hidden @@ -338,11 +357,11 @@ export function ToolsGroup({ getDisabled }: ToolsGroupProps) { void onExportTranscript('docx')}> - Word Document (.docx) + {t.toolsGroup.exportDocx} void onExportTranscript('md')}> - Markdown (.md) + {t.toolsGroup.exportMarkdown} diff --git a/src/renderer/components/custom/credits-display.tsx b/src/renderer/components/custom/credits-display.tsx index 7d704d03..05f00023 100644 --- a/src/renderer/components/custom/credits-display.tsx +++ b/src/renderer/components/custom/credits-display.tsx @@ -1,5 +1,6 @@ import React from 'react'; +import { useT } from '@/i18n'; import { CREDITS_PER_MINUTE } from '@/lib/consts'; import { cn } from '@/lib/utils'; import { UserRole } from '@/types/app-state'; @@ -22,25 +23,25 @@ export default function CreditsDisplay({ className, style, }: CreditsDisplayProps) { + const t = useT(); + // Not a real plan name - "Pro" is an actual purchasable SKU (see CreditPlan), so labeling // every non-trial user that way shows a starter or enterprise buyer a plan they never bought. - const planLabel = userRole === UserRole.TrialUser ? 'Trial Plan' : 'Paid Plan'; + const planLabel = + userRole === UserRole.TrialUser ? t.creditsDisplay.trialPlan : t.creditsDisplay.paidPlan; const availableMinutes = Math.floor(credits / (creditsPerMinute ?? CREDITS_PER_MINUTE)); - const formatDuration = (mins: number) => { - const hours = Math.floor(mins / 60); - const remainder = mins % 60; - const parts: string[] = []; - if (hours) parts.push(`${hours} hour${hours > 1 ? 's' : ''}`); - if (remainder) parts.push(`${remainder} min${remainder > 1 ? 's' : ''}`); - return parts.join(' ') || '0 mins'; - }; + // Split into hours and minutes here and assembled in the locale, because which plural form + // each number takes is a property of the language: `2 hours 15 mins` against + // `2 часа 15 минут`, where 2 and 22 agree and 5 and 25 do not. + const formatDuration = (mins: number) => + t.creditsDisplay.duration(Math.floor(mins / 60), mins % 60); const availableTime = availableMinutes <= 0 ? credits > 0 - ? 'Less than 1 min' - : 'No credits left' + ? t.creditsDisplay.lessThanAMinute + : t.creditsDisplay.noCreditsLeft : formatDuration(availableMinutes); return ( @@ -60,7 +61,7 @@ export default function CreditsDisplay({ : 'text-destructive animate-pulse' )} > - {credits.toLocaleString()} credits - {availableTime} + {t.creditsDisplay.summary(credits, availableTime)} {llmModel && ( <> diff --git a/src/renderer/components/custom/export-success-toast.tsx b/src/renderer/components/custom/export-success-toast.tsx index 15f99f86..a632ddc1 100644 --- a/src/renderer/components/custom/export-success-toast.tsx +++ b/src/renderer/components/custom/export-success-toast.tsx @@ -3,6 +3,7 @@ import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { currentTranslation } from '@/i18n'; import { getElectron } from '@/lib/utils'; import type { ExportFormat } from '@/types/export'; @@ -16,6 +17,9 @@ import type { ExportFormat } from '@/types/export'; export function showExportSuccessToast(filePath: string, format: ExportFormat): void { const electron = getElectron(); const toastId = `export-${Date.now()}`; + // Not a component, so there is no `useT` to call: this is a plain function invoked from a + // click handler. + const t = currentTranslation(); toast.custom( () => ( @@ -29,7 +33,7 @@ export function showExportSuccessToast(filePath: string, format: ExportFormat): > - Interview exported as {format === 'md' ? 'Markdown' : 'Word'} + {t.exportToast.exported(format === 'md' ? t.exportToast.markdown : t.exportToast.word)}
@@ -38,13 +42,13 @@ export function showExportSuccessToast(filePath: string, format: ExportFormat): size="sm" variant="outline" className="h-6 w-6 p-0" - aria-label="Open the exported file" + aria-label={t.exportToast.openFileLabel} onClick={() => electron?.openFile(filePath)} > - Open file + {t.exportToast.openFile} @@ -52,13 +56,13 @@ export function showExportSuccessToast(filePath: string, format: ExportFormat): size="sm" variant="outline" className="h-6 w-6 p-0" - aria-label="Show the exported file in its folder" + aria-label={t.exportToast.showInFolderLabel} onClick={() => electron?.showInFolder(filePath)} > - Show in folder + {t.exportToast.showInFolder} @@ -66,13 +70,13 @@ export function showExportSuccessToast(filePath: string, format: ExportFormat): size="sm" variant="ghost" className="h-6 w-6 p-0" - aria-label="Dismiss" + aria-label={t.exportToast.dismiss} onClick={() => toast.dismiss(toastId)} > - Dismiss + {t.exportToast.dismiss}
diff --git a/src/renderer/components/custom/headphone-notice-dialog.tsx b/src/renderer/components/custom/headphone-notice-dialog.tsx index a6b8e068..d284ee7d 100644 --- a/src/renderer/components/custom/headphone-notice-dialog.tsx +++ b/src/renderer/components/custom/headphone-notice-dialog.tsx @@ -9,6 +9,7 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { useT } from '@/i18n'; interface HeadphoneNoticeDialogProps { open: boolean; @@ -60,6 +61,8 @@ export default function HeadphoneNoticeDialog({ onCancel, variant = 'live', }: HeadphoneNoticeDialogProps) { + const t = useT(); + // Proceeding closes through `onOpenChange` directly, so it never passes through here - Radix // does not call back for a close the caller made itself. const handleDismiss = () => { @@ -72,36 +75,19 @@ export default function HeadphoneNoticeDialog({ onProceed(); }; - const description = "This session needs the interviewer's voice going to your ears only."; - const copy = - variant === 'mock' - ? { - description, - rows: [ - { - icon: , - text: 'On speakers, the question you just heard can echo into the start of your answer.', - }, - { - icon: , - text: 'Your mic is muted while the interviewer speaks, but room reverb after it stops can still slip in as stray words.', - }, - ], - } - : { - description, - rows: [ - { - icon: , - text: 'On speakers, your microphone hears the interviewer as well as you do.', - }, - { - icon: , - // The failure is the quiet one, so it is named rather than left as "quality issues". - text: 'The app then reads their question as something you said, and stops answering it - with no error to tell you why.', - }, + const copy = { + description: t.headphoneNotice.description, + rows: + variant === 'mock' + ? [ + { icon: , text: t.headphoneNotice.mockSpeakers }, + { icon: , text: t.headphoneNotice.mockConsequence }, + ] + : [ + { icon: , text: t.headphoneNotice.liveSpeakers }, + { icon: , text: t.headphoneNotice.liveConsequence }, ], - }; + }; return ( (next ? onOpenChange(true) : handleDismiss())}> @@ -109,7 +95,7 @@ export default function HeadphoneNoticeDialog({ - Put your headphones on + {t.headphoneNotice.title} {copy.description} @@ -122,10 +108,10 @@ export default function HeadphoneNoticeDialog({ diff --git a/src/renderer/components/custom/hotkey-cheatsheet.tsx b/src/renderer/components/custom/hotkey-cheatsheet.tsx index c39935bc..fd8a85d6 100644 --- a/src/renderer/components/custom/hotkey-cheatsheet.tsx +++ b/src/renderer/components/custom/hotkey-cheatsheet.tsx @@ -7,6 +7,7 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { useT } from '@/i18n'; import { Hotkey, HOTKEY_GROUPS, HOTKEYS } from '@/lib/hotkeys'; import { cn } from '@/lib/utils'; @@ -34,15 +35,18 @@ const comboClass = (hk: Hotkey) => * which is what would put each group back on its own measurement. */ export function HotkeyCheatsheet() { + const t = useT(); + return (
{HOTKEY_GROUPS.map((group) => ( - +

- {group.label} + {t.hotkeys.groups[group.id]}

{group.keys.map((hk) => { const info = HOTKEYS[hk]; + const copy = t.hotkeys.keys[hk]; return ( {/* justify-center inside a cell the grid has already sized: the badges share a @@ -50,9 +54,9 @@ export function HotkeyCheatsheet() { a gap the eye reads as a missing character. */}
{info.combo}
-

{info.title}

+

{copy.title}

- {info.description} + {copy.description}

@@ -70,18 +74,18 @@ interface HotkeyCheatsheetDialogProps { } export function HotkeyCheatsheetDialog({ open, onOpenChange }: HotkeyCheatsheetDialogProps) { + const t = useT(); + return ( - Keyboard Shortcuts + {t.hotkeys.dialogTitle} {/* Deliberately does not promise a `?` shortcut. That one is registered by `StatusPanel`, which only renders in stealth mode - so "press ? anytime" was untrue everywhere else this dialog is opened from: the configuration page and the command palette. The status panel's own button carries the hint where it does work. */} - - Everything you can reach without touching the app during an interview. - + {t.hotkeys.dialogDescription}
diff --git a/src/renderer/components/custom/input-password.tsx b/src/renderer/components/custom/input-password.tsx index fd72fa9e..fb5dd5a5 100644 --- a/src/renderer/components/custom/input-password.tsx +++ b/src/renderer/components/custom/input-password.tsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; +import { useT } from '@/i18n'; interface InputPasswordProps extends React.InputHTMLAttributes { showPassword?: boolean; @@ -15,6 +16,7 @@ export function InputPassword({ className = '', ...props }: InputPasswordProps) { + const t = useT(); const [internalShowPassword, setInternalShowPassword] = useState(false); const showPassword = externalShowPassword ?? internalShowPassword; @@ -38,7 +40,7 @@ export function InputPassword({ variant="ghost" size="sm" tabIndex={-1} - aria-label={showPassword ? 'Hide password' : 'Show password'} + aria-label={showPassword ? t.inputPassword.hide : t.inputPassword.show} aria-pressed={showPassword} className="absolute right-0 top-0 h-full px-3 py-2" onClick={toggleShowPassword} diff --git a/src/renderer/components/custom/loading.tsx b/src/renderer/components/custom/loading.tsx index fcb41449..b3c2d94c 100644 --- a/src/renderer/components/custom/loading.tsx +++ b/src/renderer/components/custom/loading.tsx @@ -12,7 +12,7 @@ export function LoadingPage({ disclaimer }: LoadingProps) {
- Logo +

{APP_NAME}

{disclaimer}

diff --git a/src/renderer/components/custom/main-frame.tsx b/src/renderer/components/custom/main-frame.tsx index cce9fb62..5dacfeec 100644 --- a/src/renderer/components/custom/main-frame.tsx +++ b/src/renderer/components/custom/main-frame.tsx @@ -7,6 +7,7 @@ import { useConfigStore } from '@/hooks/use-config-store'; import { MainContainerContext } from '@/hooks/use-main-container'; import { useOnboardingDismissed } from '@/hooks/use-onboarding-dismissed'; import usePointerLockGuard from '@/hooks/use-pointer-lock-guard'; +import { useUiLanguageSync } from '@/i18n'; import type { PushNotification } from '@/types/push-notification'; import { CommandPalette } from './command-palette'; @@ -17,6 +18,12 @@ import { UpdateNotification } from './update-notification'; export default function MainFrame({ children }: { children: React.ReactNode }) { usePointerLockGuard(); + // Mirrors the chosen UI language onto `document.lang` and into the paint-time cache the next + // launch reads before the config store has answered. Here rather than in a provider because + // the strings themselves need no context - `useT` reads the config store directly - and this + // is the one component mounted for every route. + useUiLanguageSync(); + // Loaded here rather than per page. Routes reached directly - a reload on `/configuration`, the // onboarding gate on `/` - all read the config, and every one of them holding its own fetch is // how one of them ends up not having it. Pages that need a *fresh* read still ask for one. diff --git a/src/renderer/components/custom/mock-interview-setup-dialog.tsx b/src/renderer/components/custom/mock-interview-setup-dialog.tsx index a93e00f6..f6e605e5 100644 --- a/src/renderer/components/custom/mock-interview-setup-dialog.tsx +++ b/src/renderer/components/custom/mock-interview-setup-dialog.tsx @@ -10,6 +10,7 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { useMockInterviewSetupForm } from '@/hooks/use-mock-interview-setup-form'; +import { useT } from '@/i18n'; import type { MockInterviewSetup } from '@/types/mock-interview'; interface MockInterviewSetupDialogProps { @@ -34,9 +35,15 @@ export function MockInterviewSetupDialog({ onOpenChange, onStart, }: MockInterviewSetupDialogProps) { + const t = useT(); const form = useMockInterviewSetupForm(onStart); - const { starting, headphoneNoticeOpen, setHeadphoneNoticeOpen, handleStartClick, startAfterNotice } = - form; + const { + starting, + headphoneNoticeOpen, + setHeadphoneNoticeOpen, + handleStartClick, + startAfterNotice, + } = form; return ( <> @@ -50,20 +57,18 @@ export function MockInterviewSetupDialog({ - Mock interview - - The AI asks, you answer out loud. Nothing is saved unless you export it. - + {t.mock.setup.title} + {t.mock.setup.description} diff --git a/src/renderer/components/custom/mock-interview-setup-fields.tsx b/src/renderer/components/custom/mock-interview-setup-fields.tsx index 4d268190..bd67b701 100644 --- a/src/renderer/components/custom/mock-interview-setup-fields.tsx +++ b/src/renderer/components/custom/mock-interview-setup-fields.tsx @@ -9,32 +9,25 @@ import { SelectValue, } from '@/components/ui/select'; import type { MockInterviewSetupForm } from '@/hooks/use-mock-interview-setup-form'; +import { useT } from '@/i18n'; import { MockDifficulty, MockSeniority } from '@/types/mock-interview'; -const DIFFICULTIES: { value: MockDifficulty; label: string; description: string }[] = [ - { - value: MockDifficulty.Easy, - label: 'Warm-up', - description: 'Straightforward questions, one clear ask each.', - }, - { - value: MockDifficulty.Standard, - label: 'Standard', - description: 'What an ordinary interviewer would actually ask.', - }, - { - value: MockDifficulty.Hard, - label: 'Hard', - description: 'Probing questions on trade-offs and edge cases.', - }, -]; +/** + * The options, as enum values paired with the locale key that names them. The copy lives in + * `t.mock.setup`, so adding a difficulty cannot leave its description behind in one language. + */ +const DIFFICULTIES = [ + { value: MockDifficulty.Easy, key: 'easy' }, + { value: MockDifficulty.Standard, key: 'standard' }, + { value: MockDifficulty.Hard, key: 'hard' }, +] as const; -const SENIORITIES: { value: MockSeniority; label: string }[] = [ - { value: MockSeniority.Junior, label: 'Junior' }, - { value: MockSeniority.Mid, label: 'Mid-level' }, - { value: MockSeniority.Senior, label: 'Senior' }, - { value: MockSeniority.Staff, label: 'Staff+' }, -]; +const SENIORITIES = [ + { value: MockSeniority.Junior, key: 'junior' }, + { value: MockSeniority.Mid, key: 'mid' }, + { value: MockSeniority.Senior, key: 'senior' }, + { value: MockSeniority.Staff, key: 'staff' }, +] as const; const QUESTION_COUNTS = [3, 5, 8, 12] as const; @@ -46,6 +39,7 @@ const QUESTION_COUNTS = [3, 5, 8, 12] as const; * whatever role that context names, rather than a short label collected a second time here. */ export function MockInterviewSetupFields({ form }: { form: MockInterviewSetupForm }) { + const t = useT(); const { seniority, setSeniority, @@ -68,7 +62,7 @@ export function MockInterviewSetupFields({ form }: { form: MockInterviewSetupFor
{/* A Radix Select trigger is a button, not a form control, so htmlFor does not reach it. id + aria-labelledby is what associates the two - see audio-group.tsx. */} - + setQuestionCount(Number(v))}> @@ -95,8 +89,8 @@ export function MockInterviewSetupFields({ form }: { form: MockInterviewSetupFor interview the user can pick right here instead of a dead end. */} {QUESTION_COUNTS.map((n) => ( - {n} questions, about {Math.round(n * 2.5)} minutes - {canAfford(n) ? '' : ' - not enough credits'} + {t.mock.setup.questionOption(n, Math.round(n * 2.5))} + {canAfford(n) ? '' : t.mock.setup.cannotAfford} ))} @@ -113,15 +107,15 @@ export function MockInterviewSetupFields({ form }: { form: MockInterviewSetupFor meters a mock by the minute and has no per-question price to quote. */} {price !== null && ceiling !== null && (

- Costs {price} credits - {ceiling > price && <>, up to {ceiling} if the interviewer follows up on every answer}. - You have {credits.toLocaleString()}. + {t.mock.setup.priceLead} + {t.mock.setup.price(price)} + {ceiling > price && t.mock.setup.priceCeiling(ceiling)}. {t.mock.setup.balance(credits)}

)}
{/* No htmlFor: this labels the group via aria-labelledby below, not one control. */} - + - {d.label} + {t.mock.setup.difficultyOptions[d.key].label} + + + {t.mock.setup.difficultyOptions[d.key].description} - {d.description} ))} @@ -147,10 +143,7 @@ export function MockInterviewSetupFields({ form }: { form: MockInterviewSetupFor setting - a mock session reads the language the live assistant does. It used to be a read-only row here that told the user to go and change it on another screen, which is a strange thing to say on a dialog whose whole job is configuring the session. */} - +
); } diff --git a/src/renderer/components/custom/page-header.tsx b/src/renderer/components/custom/page-header.tsx index a0b3a51a..b47f15b9 100644 --- a/src/renderer/components/custom/page-header.tsx +++ b/src/renderer/components/custom/page-header.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { Button } from '@/components/ui/button'; +import { useT } from '@/i18n'; interface PageHeaderProps { title: string; @@ -23,6 +24,7 @@ interface PageHeaderProps { * covers. */ export default function PageHeader({ title, fallback = '/', children }: PageHeaderProps) { + const t = useT(); const navigate = useNavigate(); const location = useLocation(); @@ -39,7 +41,7 @@ export default function PageHeader({ title, fallback = '/', children }: PageHead size="icon-sm" onClick={handleBack} className="flex items-center shrink-0" - aria-label="Back" + aria-label={t.common.back} >
{!isStealth && ( @@ -184,9 +186,9 @@ function ActionSuggestionsPanel({ ); }} className="h-4 w-4 rounded border-border bg-background text-primary" - aria-label="Enable auto-scroll" + aria-label={t.panels.enableAutoScroll} /> - Auto-scroll + {t.panels.autoScroll} )}
@@ -196,7 +198,7 @@ function ActionSuggestionsPanel({ {!hasItems && (
-

No action suggestions yet

+

{t.panels.noTriggeredSuggestions}

)} @@ -274,7 +276,7 @@ function ActionSuggestionsPanel({ {s.state === SuggestionState.Stopped && (
- Suggestion canceled + {t.panels.suggestionCanceled}
)} @@ -296,7 +298,7 @@ function ActionSuggestionsPanel({ size="icon-sm" className="absolute bottom-3 right-3 rounded-full shadow-md bg-blue-600 text-white hover:bg-blue-600/90" onClick={() => scrollToLatest('smooth')} - aria-label="Scroll to top" + aria-label={t.panels.scrollToTop} > diff --git a/src/renderer/components/custom/panels/dock-resize-handle.tsx b/src/renderer/components/custom/panels/dock-resize-handle.tsx index 07343d99..98ff5f21 100644 --- a/src/renderer/components/custom/panels/dock-resize-handle.tsx +++ b/src/renderer/components/custom/panels/dock-resize-handle.tsx @@ -1,5 +1,6 @@ import { useRef } from 'react'; +import { useT } from '@/i18n'; import { cn } from '@/lib/utils'; interface DockResizeHandleProps { @@ -30,6 +31,7 @@ export default function DockResizeHandle({ onResize, onCommit, }: DockResizeHandleProps) { + const t = useT(); const dragRef = useRef<{ startY: number; startHeight: number } | null>(null); const clamp = (h: number) => Math.min(Math.max(Math.round(h), min), max); @@ -70,7 +72,7 @@ export default function DockResizeHandle({
onCommit(null)} - title="Drag to resize, double-click to reset" + title={t.panels.resizeHint} // touch-none keeps a pen or touch drag from scrolling the page instead of resizing className="group relative h-1.5 shrink-0 cursor-row-resize touch-none outline-none -my-0.5" > diff --git a/src/renderer/components/custom/panels/live-suggestions-panel.tsx b/src/renderer/components/custom/panels/live-suggestions-panel.tsx index da049351..c513136c 100644 --- a/src/renderer/components/custom/panels/live-suggestions-panel.tsx +++ b/src/renderer/components/custom/panels/live-suggestions-panel.tsx @@ -7,6 +7,7 @@ const MAX_QUESTION_LENGTH = 256; import { Card } from '@/components/ui/card'; import useIsStealthMode from '@/hooks/use-is-stealth-mode'; +import { useT } from '@/i18n'; import { newestTimestamp, stripDanglingEmphasis, @@ -82,6 +83,7 @@ function LiveSuggestionsPanel({ style, isRunning = false, }: LiveSuggestionsPanelProps) { + const t = useT(); const hasItems = suggestions.length > 0; // newest first: the incoming array is chronological, the panel renders it reversed @@ -215,7 +217,7 @@ function LiveSuggestionsPanel({ aria-hidden="true" /> )} -

Live Suggestions

+

{t.panels.liveSuggestions}

{!isStealth && ( @@ -230,9 +232,9 @@ function LiveSuggestionsPanel({ ); }} className="h-4 w-4 rounded border-border bg-background text-primary" - aria-label="Enable auto-scroll" + aria-label={t.panels.enableAutoScroll} /> - Auto-scroll + {t.panels.autoScroll} )}
@@ -242,7 +244,7 @@ function LiveSuggestionsPanel({ {!hasItems && (
-

No suggestions yet

+

{t.panels.noSuggestions}

)} @@ -307,7 +309,7 @@ function LiveSuggestionsPanel({ {s.state === SuggestionState.Idle && (
- Idle - no generation yet + {t.panels.idleNoGeneration}
)}
@@ -323,7 +325,7 @@ function LiveSuggestionsPanel({ size="icon-sm" className="absolute bottom-3 right-3 rounded-full shadow-md bg-blue-600 text-white hover:bg-blue-600/90" onClick={() => scrollToLatest('smooth')} - aria-label="Scroll to top" + aria-label={t.panels.scrollToTop} > diff --git a/src/renderer/components/custom/panels/mock-transcript-panel.tsx b/src/renderer/components/custom/panels/mock-transcript-panel.tsx index cebffb4a..50445c55 100644 --- a/src/renderer/components/custom/panels/mock-transcript-panel.tsx +++ b/src/renderer/components/custom/panels/mock-transcript-panel.tsx @@ -9,6 +9,7 @@ import { Checkbox } from '@/components/ui/checkbox'; import { Progress } from '@/components/ui/progress'; import { useAppState } from '@/hooks/use-app-state'; import { useConfigStore } from '@/hooks/use-config-store'; +import { useT } from '@/i18n'; import { cn } from '@/lib/utils'; import { isMockInterviewSessionActive, @@ -96,9 +97,10 @@ interface MockTranscriptPanelProps { * transcript reads, rather than as a single card that replaces its own content on every question. */ function MockTranscriptPanel({ session }: MockTranscriptPanelProps) { + const t = useT(); const { appState } = useAppState(); const { config, updateConfig } = useConfigStore(); - const username = appState?.interviewConfig?.fullName || 'You'; + const username = appState?.interviewConfig?.fullName || t.panels.you; const scrollerRef = useRef(null); const [autoScroll, setAutoScroll] = useState(() => config?.autoScrollTranscript ?? true); // A callback ref rather than a `useRef`, because the observer below has to be re-attached when @@ -205,12 +207,12 @@ function MockTranscriptPanel({ session }: MockTranscriptPanelProps) { aria-hidden="true" /> )} -

Mock Interview

+

{t.panels.mockInterview}

{totalQuestions > 0 && ( - Question {questionNumber} of {totalQuestions} + {t.panels.questionProgress(questionNumber, totalQuestions)} )} {/* Same control and the same stored preference as the live transcript dock. Without it @@ -227,14 +229,16 @@ function MockTranscriptPanel({ session }: MockTranscriptPanelProps) { ); }} className="h-4 w-4 rounded border-border bg-background" - aria-label="Enable auto-scroll" + aria-label={t.panels.enableAutoScroll} /> - Auto-scroll + {t.panels.autoScroll}
- {totalQuestions > 0 && } + {totalQuestions > 0 && ( + + )} {/* `overflow-x-hidden` is not decoration: `overflow-y-auto` on its own leaves the other axis computing to `auto` rather than staying visible, so the vertical scrollbar @@ -244,7 +248,7 @@ function MockTranscriptPanel({ session }: MockTranscriptPanelProps) {
{turns.length === 0 ? (
-

Preparing your first question…

+

{t.panels.preparingFirstQuestion}

) : (
@@ -255,15 +259,15 @@ function MockTranscriptPanel({ session }: MockTranscriptPanelProps) { 'w-20 shrink-0 truncate text-xs font-semibold', turn.speaker === 'candidate' ? 'text-primary' : 'text-foreground' )} - title={turn.speaker === 'candidate' ? username : 'Interviewer'} + title={turn.speaker === 'candidate' ? username : t.panels.interviewer} > - {turn.speaker === 'candidate' ? username : 'Interviewer'} + {turn.speaker === 'candidate' ? username : t.panels.interviewer}
{turn.isFollowUp && ( - Follow-up + {t.panels.followUp} )} {/* An answered turn can still be empty - "Done answering" pressed with nothing @@ -273,7 +277,7 @@ function MockTranscriptPanel({ session }: MockTranscriptPanelProps) { what happened. The export says the same thing with its own placeholder. */} {turn.skipped || !turn.text ? (

- {turn.skipped ? 'Skipped' : 'No answer'} + {turn.skipped ? t.panels.skipped : t.panels.noAnswer}

) : turn.streaming ? ( @@ -297,7 +301,7 @@ function MockTranscriptPanel({ session }: MockTranscriptPanelProps) { size="icon-sm" className="absolute bottom-3 right-3 rounded-full shadow-md bg-blue-600 text-white hover:bg-blue-600/90" onClick={() => scrollToEnd()} - aria-label="Scroll to bottom" + aria-label={t.panels.scrollToBottom} > diff --git a/src/renderer/components/custom/panels/transcript-panel.tsx b/src/renderer/components/custom/panels/transcript-panel.tsx index 9fee81c3..7fa6db34 100644 --- a/src/renderer/components/custom/panels/transcript-panel.tsx +++ b/src/renderer/components/custom/panels/transcript-panel.tsx @@ -5,6 +5,7 @@ import { Card } from '@/components/ui/card'; import { useAppState } from '@/hooks/use-app-state'; import { useConfigStore } from '@/hooks/use-config-store'; import useIsStealthMode from '@/hooks/use-is-stealth-mode'; +import { useT } from '@/i18n'; import { Speaker, type Transcript } from '@/types/transcript'; import { Button } from '../../ui/button'; @@ -55,6 +56,7 @@ interface TranscriptPanelProps { } function TranscriptPanel({ transcripts, isRunning = false }: TranscriptPanelProps) { + const t = useT(); const { config, updateConfig } = useConfigStore(); const { appState } = useAppState(); const username = appState?.interviewConfig?.fullName ?? ''; @@ -87,7 +89,7 @@ function TranscriptPanel({ transcripts, isRunning = false }: TranscriptPanelProp aria-hidden="true" /> )} -

Transcription

+

{t.panels.transcription}

{!isStealth && ( @@ -102,9 +104,9 @@ function TranscriptPanel({ transcripts, isRunning = false }: TranscriptPanelProp ); }} className="h-4 w-4 rounded border-border bg-background" - aria-label="Enable auto-scroll" + aria-label={t.panels.enableAutoScroll} /> - Auto-scroll + {t.panels.autoScroll} )}
@@ -112,13 +114,13 @@ function TranscriptPanel({ transcripts, isRunning = false }: TranscriptPanelProp
{transcripts.length === 0 ? (
-

No transcripts yet

+

{t.panels.noTranscripts}

) : ( <>
{runs.map((run) => { - const speaker = run.speaker === Speaker.Self ? username : 'Interviewer'; + const speaker = run.speaker === Speaker.Self ? username : t.panels.interviewer; return (
{/* Fixed-width column so wrapped/adjacent rows keep a stable left edge @@ -184,7 +186,7 @@ function TranscriptPanel({ transcripts, isRunning = false }: TranscriptPanelProp size="icon-sm" className="absolute bottom-3 right-3 rounded-full shadow-md bg-blue-600 text-white hover:bg-blue-600/90" onClick={() => endRef.current?.scrollIntoView({ behavior: 'smooth' })} - aria-label="Scroll to bottom" + aria-label={t.panels.scrollToBottom} > diff --git a/src/renderer/components/custom/payment/buy-credits-tab.tsx b/src/renderer/components/custom/payment/buy-credits-tab.tsx index 58ff22bc..019ef96c 100644 --- a/src/renderer/components/custom/payment/buy-credits-tab.tsx +++ b/src/renderer/components/custom/payment/buy-credits-tab.tsx @@ -13,21 +13,20 @@ import { SelectValue, } from '@/components/ui/select'; import { usePayment } from '@/hooks/use-payment'; +import { useT } from '@/i18n'; import { CREDITS_PER_MINUTE } from '@/lib/consts'; import { cn } from '@/lib/utils'; import type { AvailableCurrency, CreditPlanInfo } from '@/types/payment'; import { CreditPlan } from '@/types/payment'; -const planNames: Record = { - [CreditPlan.Starter]: 'Starter', - [CreditPlan.Pro]: 'Pro', - [CreditPlan.Enterprise]: 'Enterprise', -}; - -const planDescriptions: Record = { - [CreditPlan.Starter]: 'Perfect for trying out the platform', - [CreditPlan.Pro]: 'Best value for serious job seekers', - [CreditPlan.Enterprise]: 'For heavy users and teams', +/** + * Which locale entry names each plan. `Pro` is a real purchasable SKU, so the names stay as the + * brand's own words in both languages; only the descriptions are prose. + */ +const planKeys: Record = { + [CreditPlan.Starter]: 'starter', + [CreditPlan.Pro]: 'pro', + [CreditPlan.Enterprise]: 'enterprise', }; interface BuyCreditsTabProps { @@ -37,7 +36,12 @@ interface BuyCreditsTabProps { onPaymentCreated: (paymentId: string) => void; } -export default function BuyCreditsTab({ credits, creditsPerMinute, onPaymentCreated }: BuyCreditsTabProps) { +export default function BuyCreditsTab({ + credits, + creditsPerMinute, + onPaymentCreated, +}: BuyCreditsTabProps) { + const t = useT(); const effectiveCreditsPerMinute = creditsPerMinute ?? CREDITS_PER_MINUTE; const { plans, currencies, loading, error, createPayment } = usePayment(); const [selectedPlan, setSelectedPlan] = useState(null); @@ -108,22 +112,15 @@ export default function BuyCreditsTab({ credits, creditsPerMinute, onPaymentCrea return (
-
Current Balance
-
{credits.toLocaleString()} credits
+
{t.payment.buy.currentBalance}
+
{t.payment.buy.credits(credits)}
+ {/* Assembled in the locale rather than out of JSX fragments: the hours and minutes each + take their own plural form, and which one is a property of the number. */}
- Available for ~ - {availableHours > 0 && ( - <> - {availableHours} hour{availableHours !== 1 ? 's' : ''} - {availableRemMinutes > 0 ? ' ' : ''} - + {t.payment.buy.availableFor( + t.payment.buy.duration(availableHours, availableRemMinutes), + effectiveCreditsPerMinute )} - {availableRemMinutes > 0 && ( - <> - {availableRemMinutes} minute{availableRemMinutes !== 1 ? 's' : ''} - - )}{' '} - ({effectiveCreditsPerMinute} credits per minute)
@@ -135,7 +132,7 @@ export default function BuyCreditsTab({ credits, creditsPerMinute, onPaymentCrea {loading && plans.length === 0 ? (
- +
) : ( <> @@ -144,8 +141,11 @@ export default function BuyCreditsTab({ credits, creditsPerMinute, onPaymentCrea const isPro = plan.plan === CreditPlan.Pro; const isSelected = selectedPlan?.plan === plan.plan; const minutes = Math.floor(plan.credits / effectiveCreditsPerMinute); - const planName = planNames[plan.plan] || plan.plan; - const planDescription = planDescriptions[plan.plan] || plan.description || ''; + const planKey = planKeys[plan.plan]; + const planName = planKey ? t.payment.buy.planNames[planKey] : plan.plan; + const planDescription = planKey + ? t.payment.buy.planDescriptions[planKey] + : plan.description || ''; return ( - Most Popular + {t.payment.buy.mostPopular}
)} @@ -180,12 +180,11 @@ export default function BuyCreditsTab({ credits, creditsPerMinute, onPaymentCrea
${plan.priceUsd} - {' '} - / {plan.credits.toLocaleString()} credits + {t.payment.buy.perCredits(plan.credits)}

- ~{minutes.toLocaleString()} minutes of AI assistance + {t.payment.buy.minutesOfAssistance(minutes)}

@@ -209,10 +208,10 @@ export default function BuyCreditsTab({ credits, creditsPerMinute, onPaymentCrea {isSelected ? ( <> - Selected + {t.payment.buy.selected} ) : ( - 'Buy' + t.payment.buy.buy )} @@ -224,17 +223,18 @@ export default function BuyCreditsTab({ credits, creditsPerMinute, onPaymentCrea {selectedPlan && ( - Payment Details + {t.payment.buy.detailsTitle} - Complete your purchase of{' '} - {selectedPlan.credits.toLocaleString()} credits{' '} - for ${selectedPlan.priceUsd} USD + {t.payment.buy.detailsLead} + {t.payment.buy.credits(selectedPlan.credits)} + {t.payment.buy.detailsFor} + ${selectedPlan.priceUsd} USD
setPaymentId(e.target.value)} className="max-w-40" maxLength={100} />
@@ -337,10 +343,12 @@ export default function PaymentStatusTab({ initialPaymentId = '' }: PaymentStatu getStatusBadgeColor(paymentStatus.payment_status) )} > - {getStatusLabel(paymentStatus.payment_status)} + {getStatusLabel(t, paymentStatus.payment_status)} - Order #{paymentStatus.order_id} + + {t.payment.status.order(paymentStatus.order_id)} +
- Refreshing… + {t.payment.status.refreshing}
-

Amount to Pay

+

{t.payment.status.amountToPay}

{paymentStatus.pay_amount} {paymentStatus.pay_currency.toUpperCase()}

-

Price (USD)

+

{t.payment.status.priceUsd}

${paymentStatus.price_amount} {paymentStatus.price_currency}

{paymentStatus.actually_paid != null && paymentStatus.actually_paid > 0 && (
-

Actually Paid

+

{t.payment.status.actuallyPaid}

{paymentStatus.actually_paid} {paymentStatus.pay_currency.toUpperCase()}

@@ -382,16 +390,17 @@ export default function PaymentStatusTab({ initialPaymentId = '' }: PaymentStatu paymentStatus.payment_status !== PaymentStatus.Expired && ( <>
-

Payment Methods

+

{t.payment.status.paymentMethods}

- QR Code - Address + {t.payment.status.qrTab} + {t.payment.status.addressTab}

- Scan with your wallet app + {t.payment.status.scanWithWallet}

- QR code includes address and amount ({paymentStatus.pay_amount}{' '} - {paymentStatus.pay_currency.toUpperCase()}) + {t.payment.status.qrIncludes( + `${paymentStatus.pay_amount} ${paymentStatus.pay_currency.toUpperCase()}` + )}

@@ -411,7 +421,9 @@ export default function PaymentStatusTab({ initialPaymentId = '' }: PaymentStatu
-

Payment Address

+

+ {t.payment.status.paymentAddress} +

-

Amount to Send

+

+ {t.payment.status.amountToSend} +

- Send exactly this amount to the address above. + {t.payment.status.sendExactly}

@@ -467,10 +481,10 @@ export default function PaymentStatusTab({ initialPaymentId = '' }: PaymentStatu {paymentStatus.payment_status === PaymentStatus.Finished && (

- Payment Successful! + {t.payment.status.successTitle}

- Your credits have been added to your account. + {t.payment.status.successBody}

)} @@ -479,13 +493,14 @@ export default function PaymentStatusTab({ initialPaymentId = '' }: PaymentStatu paymentStatus.payment_status === PaymentStatus.Expired) && (

- Payment{' '} - {paymentStatus.payment_status === PaymentStatus.Expired ? 'Expired' : 'Failed'} + {paymentStatus.payment_status === PaymentStatus.Expired + ? t.payment.status.expiredTitle + : t.payment.status.failedTitle}

{paymentStatus.payment_status === PaymentStatus.Expired - ? 'This payment has expired. Please create a new payment.' - : 'The payment could not be processed. Please try again.'} + ? t.payment.status.expiredBody + : t.payment.status.failedBody}

)} @@ -494,13 +509,13 @@ export default function PaymentStatusTab({ initialPaymentId = '' }: PaymentStatu
{paymentStatus.created_at && (
- Created: + {t.payment.status.created} {new Date(paymentStatus.created_at).toLocaleString()}
)} {paymentStatus.updated_at && (
- Updated: + {t.payment.status.updated} {new Date(paymentStatus.updated_at).toLocaleString()}
)} diff --git a/src/renderer/components/custom/payment/payment-utils.ts b/src/renderer/components/custom/payment/payment-utils.ts index 4bbeef74..bf1c7789 100644 --- a/src/renderer/components/custom/payment/payment-utils.ts +++ b/src/renderer/components/custom/payment/payment-utils.ts @@ -2,6 +2,7 @@ * Payment utility functions */ +import type { Translation } from '@/i18n'; import { PaymentStatus } from '@/types/payment'; export function getStatusBadgeColor(status: PaymentStatus): string { @@ -19,26 +20,33 @@ export function getStatusBadgeColor(status: PaymentStatus): string { } } -export function getStatusLabel(status: PaymentStatus): string { +/** + * Takes the dictionary rather than calling `useT`, because this is reached from a table cell's + * render as well as from a memoised card - a plain function either way, not a component. + * + * The fallback is the raw status string, which is what a backend ahead of this build sends: an + * untranslated code on screen is poor, and a blank badge where the status should be is worse. + */ +export function getStatusLabel(t: Translation, status: PaymentStatus): string { switch (status) { case PaymentStatus.Waiting: - return 'Waiting'; + return t.payment.statusLabels.waiting; case PaymentStatus.Confirming: - return 'Confirming'; + return t.payment.statusLabels.confirming; case PaymentStatus.Confirmed: - return 'Confirmed'; + return t.payment.statusLabels.confirmed; case PaymentStatus.Sending: - return 'Sending'; + return t.payment.statusLabels.sending; case PaymentStatus.PartiallyPaid: - return 'Partially Paid'; + return t.payment.statusLabels.partiallyPaid; case PaymentStatus.Finished: - return 'Finished'; + return t.payment.statusLabels.finished; case PaymentStatus.Failed: - return 'Failed'; + return t.payment.statusLabels.failed; case PaymentStatus.Refunded: - return 'Refunded'; + return t.payment.statusLabels.refunded; case PaymentStatus.Expired: - return 'Expired'; + return t.payment.statusLabels.expired; default: return status; } diff --git a/src/renderer/components/custom/permission-gate-dialog.tsx b/src/renderer/components/custom/permission-gate-dialog.tsx index a3886ca7..246e0469 100644 --- a/src/renderer/components/custom/permission-gate-dialog.tsx +++ b/src/renderer/components/custom/permission-gate-dialog.tsx @@ -10,12 +10,14 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { type PermStatus, usePermissions } from '@/hooks/use-permissions'; +import { useT } from '@/i18n'; import { getElectron } from '@/lib/utils'; interface PermissionGateDialogProps { open: boolean; onOpenChange: (open: boolean) => void; onProceed: () => void; + /** Defaults to the locale's own "Start"; the mock flow passes its own word. */ proceedLabel?: string; } @@ -23,8 +25,9 @@ export default function PermissionGateDialog({ open, onOpenChange, onProceed, - proceedLabel = 'Start', + proceedLabel, }: PermissionGateDialogProps) { + const t = useT(); const { status, loading, allGranted, recheck } = usePermissions(open); const { screenNeedsRelaunch } = status; const [requesting, setRequesting] = useState(false); @@ -56,31 +59,31 @@ export default function PermissionGateDialog({ - Permissions Required + {t.permissionGate.title}
} - label="Microphone" + label={t.permissionGate.microphone} status={status.mic} note={ status.mic === 'unknown' - ? 'Checking...' + ? t.permissionGate.micChecking : status.mic === 'granted' - ? 'Access granted' + ? t.permissionGate.micGranted : status.mic === 'denied' || status.mic === 'restricted' - ? 'Enable in System Settings, then click Check Again' - : 'Required to capture your voice' + ? t.permissionGate.micBlocked + : t.permissionGate.micRequired } action={ status.mic === 'not-determined' ? ( ) : status.mic === 'denied' || status.mic === 'restricted' ? ( ) : null } @@ -88,31 +91,31 @@ export default function PermissionGateDialog({ } - label="Screen Recording" + label={t.permissionGate.screenRecording} status={screenNeedsRelaunch ? 'not-determined' : status.screen} note={ status.screen === 'unknown' - ? 'Checking...' + ? t.permissionGate.screenChecking : screenNeedsRelaunch - ? 'Granted - restart the app to apply before starting' + ? t.permissionGate.screenNeedsRelaunch : status.screen === 'granted' - ? 'Access granted' + ? t.permissionGate.screenGranted : status.screen === 'not-determined' - ? 'Will be requested when recording starts' - : 'Enable in System Settings, then restart the app to apply' + ? t.permissionGate.screenNotDetermined + : t.permissionGate.screenBlocked } action={ screenNeedsRelaunch ? ( ) : status.screen === 'denied' || status.screen === 'restricted' ? (
) : null @@ -122,13 +125,13 @@ export default function PermissionGateDialog({ diff --git a/src/renderer/components/custom/running-indicator.tsx b/src/renderer/components/custom/running-indicator.tsx index aa1e6705..50905b33 100644 --- a/src/renderer/components/custom/running-indicator.tsx +++ b/src/renderer/components/custom/running-indicator.tsx @@ -1,3 +1,4 @@ +import { useT } from '@/i18n'; import { RunningState } from '@/types/app-state'; interface RunningIndicatorProps { @@ -6,33 +7,42 @@ interface RunningIndicatorProps { className?: string; } +/** + * The width is fixed so the badge does not change size as the state moves - it sits at the left + * end of the status row, and a badge that resized would shift everything beside it four times a + * session. `w-28` rather than `w-24` because the labels are translated: nine characters of + * uppercase bold Cyrillic (`ОСТАНОВКА`) do not fit 96px once the dot and the padding are out, + * and the overflow is a second line rather than a clip, which makes the whole row taller. + */ export function RunningIndicator({ runningState, compact = false, className = '', }: RunningIndicatorProps) { + const t = useT(); + const indicatorConfig: Record< RunningState, { dotClass: string; label: string; labelClass: string } > = { [RunningState.Idle]: { dotClass: 'bg-muted-foreground', - label: 'Idle', + label: t.runningIndicator.idle, labelClass: 'text-muted-foreground', }, [RunningState.Starting]: { dotClass: 'bg-primary animate-pulse', - label: 'Starting', + label: t.runningIndicator.starting, labelClass: 'text-primary animate-pulse', }, [RunningState.Running]: { dotClass: 'bg-destructive animate-pulse', - label: 'Running', + label: t.runningIndicator.running, labelClass: 'text-destructive animate-pulse', }, [RunningState.Stopping]: { dotClass: 'bg-destructive animate-pulse', - label: 'Stopping', + label: t.runningIndicator.stopping, labelClass: 'text-destructive animate-pulse', }, }; @@ -44,7 +54,7 @@ export function RunningIndicator({ } return ( -
+
{label}
diff --git a/src/renderer/components/custom/save-history-dialog.tsx b/src/renderer/components/custom/save-history-dialog.tsx index d7a5d980..02691967 100644 --- a/src/renderer/components/custom/save-history-dialog.tsx +++ b/src/renderer/components/custom/save-history-dialog.tsx @@ -14,118 +14,12 @@ import { import { useAppState } from '@/hooks/use-app-state'; import { type SaveHistoryReason, useSaveHistoryPrompt } from '@/hooks/use-save-history-guard'; import useTools from '@/hooks/use-tools'; +import { useT } from '@/i18n'; import { getElectron } from '@/lib/utils'; import type { ExportFormat } from '@/types/export'; import { showExportSuccessToast } from './export-success-toast'; -interface Copy { - title: string; - body: string; - discard: string; -} - -/** - * The action is named in the title and again on the button that goes through with it. "Discard" - * on its own is the same word for four different losses, and this dialog can appear on a close - * the user asked for seconds ago and on a Clear they pressed by accident. - * - * `stop` is the one entry that is not a guard. The other four are asked *before* the thing that - * would destroy the interview and can be answered with "not now", which leaves it alone; the - * session is already over by the time `stop` appears, and the buffers are dropped the moment it - * closes. That is why it has no Cancel and cannot be dismissed - see `dismissible` below. - */ -const COPY: Record = { - clear: { - title: 'Save this interview before clearing?', - body: 'Clearing drops the transcript and the suggestions from this session, and nothing is written to disk until you export.', - discard: 'Clear without saving', - }, - start: { - title: 'Save this interview before starting a new one?', - body: 'Starting a session drops the transcript and the suggestions from the last one, and nothing is written to disk until you export.', - discard: 'Start without saving', - }, - close: { - title: 'Save this interview before closing?', - body: 'Closing drops the transcript and the suggestions from this session, and nothing is written to disk until you export.', - discard: 'Close without saving', - }, - update: { - title: 'Save this interview before installing the update?', - body: 'Installing restarts the app and drops the transcript and the suggestions from this session, and nothing is written to disk until you export.', - discard: 'Install without saving', - }, - signout: { - title: 'Save this interview before signing out?', - body: 'Signing out drops the transcript and the suggestions from this session, and nothing is written to disk until you export.', - discard: 'Sign out without saving', - }, - stop: { - title: 'Save this interview?', - body: 'Your interview has ended. The transcript and the suggestions are dropped from here, and nothing has been written to disk.', - discard: 'Discard and go home', - }, - // Only ever reached with a mock report as the subject, so they carry no live wording to - // override below. - 'mock-done': { - title: 'Save your report before you finish?', - body: 'Your score, the feedback and every answer you gave exist only in this app until you save them to a file.', - discard: 'Finish without saving', - }, - 'mock-again': { - title: 'Save this report before the next round?', - body: 'Practising again starts a fresh interview and replaces this score, its feedback and the answers behind it.', - discard: 'Practise again without saving', - }, -}; - -/** - * What the same six reasons say when the thing at risk is a mock report rather than a live - * interview. - * - * The live wording is specific in a way that becomes wrong here: it names a transcript and - * suggestions, which is a live session's output and not a mock one's. A mock session produces a - * score, written feedback and the answers the candidate gave, and the file it writes is a report - * rather than a record of a call. Reading "save this interview before clearing" over a scored - * report was the version of that mismatch the report screen actually shipped. - * - * Partial on purpose: `mock-done` and `mock-again` are raised only from that screen and are - * already written for it, so an entry here would be a second copy of the same words. - */ -const MOCK_COPY: Partial> = { - clear: { - title: 'Save your mock interview report first?', - body: 'Clearing drops this report and the answers behind it, and nothing is written to disk until you save.', - discard: 'Clear without saving', - }, - start: { - title: 'Save your mock interview report first?', - body: 'Starting a session replaces this report and the answers behind it, and nothing is written to disk until you save.', - discard: 'Start without saving', - }, - close: { - title: 'Save your mock interview report before closing?', - body: 'This report and the answers behind it exist only in this app, and closing drops them.', - discard: 'Close without saving', - }, - update: { - title: 'Save your mock interview report before installing the update?', - body: 'Installing restarts the app, which drops this report and the answers behind it.', - discard: 'Install without saving', - }, - signout: { - title: 'Save your mock interview report before signing out?', - body: 'Signing out drops this report and the answers behind it, and nothing has been written to disk.', - discard: 'Sign out without saving', - }, - stop: { - title: 'Save your mock interview report?', - body: 'The interview has ended. This report and the answers behind it are dropped from here, and nothing has been written to disk.', - discard: 'Discard and go home', - }, -}; - /** * Asks whether to export before something destroys the interview. * @@ -135,6 +29,7 @@ const MOCK_COPY: Partial> = { * that raised it, since answering it is what sends the user home. */ export default function SaveHistoryDialog() { + const t = useT(); const { reason, settle, prompt } = useSaveHistoryPrompt(); const { exportTranscript, exportMockReport } = useTools(); const { appState } = useAppState(); @@ -177,20 +72,32 @@ export default function SaveHistoryDialog() { console.error(error); // The prompt stays open on a failure. Going ahead with the action here would destroy the // interview the user has just asked to keep, on the one path where saving did not work. - toast.error(error instanceof Error ? error.message : 'Failed to export interview'); + toast.error(error instanceof Error ? error.message : t.saveHistory.exportFailed); } finally { setSaving(null); } }; - const copy = reason ? (isMockSubject ? (MOCK_COPY[reason] ?? COPY[reason]) : COPY[reason]) : null; + // `t.saveHistory.mock` is partial on purpose: `mock-done` and `mock-again` are raised only + // from the report screen and are already written for it, so an entry there would be a second + // copy of the same words. + // + // Read through a `Partial` view rather than cast `reason` into the narrower key union. The cast + // would be asserting something false - `reason` really can be `mock-done`, which that object + // really does not have - and it is the `??` below that handles it. The view says the lookup can + // miss; the cast said it cannot and then relied on it doing so anyway. + const mockCopy: Partial> = + t.saveHistory.mock; + const copy = reason + ? isMockSubject + ? (mockCopy[reason] ?? t.saveHistory.live[reason]) + : t.saveHistory.live[reason] + : null; const busy = saving !== null; // Both formats write the same content, so the choice is one of what to do with the file // afterwards rather than of what is being kept - said once, under the two buttons, instead of // left for the user to infer from two equally-weighted primaries. - const formatHint = isMockSubject - ? 'Word to share or print, Markdown to keep alongside your notes.' - : 'Word to share or print, Markdown to keep as plain text.'; + const formatHint = isMockSubject ? t.saveHistory.formatHintMock : t.saveHistory.formatHintLive; // Every other reason can be answered with "not now" by pressing Esc, and that answer leaves // the interview exactly where it was. After a stop there is no "not now" left to mean - the @@ -233,7 +140,7 @@ export default function SaveHistoryDialog() { ) : ( )} - Save as Word + {t.saveHistory.saveAsWord}

{formatHint}

@@ -261,7 +168,7 @@ export default function SaveHistoryDialog() { onClick={() => settle(false)} disabled={busy} > - Cancel + {t.common.cancel} )}