fix(i18n): close shared ui gaps

This commit is contained in:
cesnimda
2026-08-29 22:52:24 +02:00
parent 3c9b22c97e
commit 6210909b62
11 changed files with 35 additions and 15 deletions
+1
View File
@@ -90,6 +90,7 @@ Updated: 2026-08-29
- CV Builder focused verification: 12/12 passed; TypeScript passed after the final format-label pass.
- Admin System focused verification: 3/3 passed, including Bokmål benchmark diagnostics that preserve runtime values; TypeScript passed.
- Job creation and Gmail workflow localization verification: 4 focused suites, 11/11 passed; TypeScript passed. The add-job stepper, Gmail continuity management, review queue, usage progress labels, errors and status copy now switch between English and Bokmål without translating user/job/email content.
- Shared active-surface localization/accessibility verification: Profile 13/13 and settings/auth/landing 18/18 passed; TypeScript passed. Tag entry, Pro notices, disabled registration, profile load recovery, dashboard totals, language selector semantics, job-selection checkboxes and locale-aware correspondence dates were aligned with the global EN/NB system.
- Backend matcher/intelligence focused verification: 35/35 passed, including detection of a manually created Norwegian advert with no saved translation.
- Full backend: 712/712 tests passed on .NET 9.
- Next optimized production build and TypeScript: passed after the Job Workspace/checklist batch.
@@ -515,7 +515,7 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr
<ListItem key={match.id} sx={{ px: 0 }}>
<ListItemText
primary={`${match.company} - ${match.jobTitle}`}
secondary={`${match.reason}${match.status}${new Date(match.dateApplied).toLocaleDateString()}`}
secondary={`${match.reason}${match.status}${new Date(match.dateApplied).toLocaleDateString(language === "nb" ? "nb-NO" : "en")}`}
/>
</ListItem>
))}
@@ -488,7 +488,7 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
) : null}
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "flex-end", mt: 0.75 }}>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{isMe ? t("correspondenceMe") : t("correspondenceCompany")}{m.channel ? ` - ${m.channel}` : ""}{m.date ? ` - ${new Date(m.date).toLocaleString()}` : ""}
{isMe ? t("correspondenceMe") : t("correspondenceCompany")}{m.channel ? ` - ${m.channel}` : ""}{m.date ? ` - ${new Date(m.date).toLocaleString(language === "nb" ? "nb-NO" : "en")}` : ""}
</Typography>
<IconButton size="small" aria-label={t("correspondenceDeleteTitle")} onClick={() => void deleteMessage(m.id)} sx={{ color: "text.secondary" }}>
<DeleteOutlineIcon fontSize="small" />
@@ -654,7 +654,7 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
))}
</Box>
) : null}
{gmailStatus.lastSyncedAt ? <Chip label={t("correspondenceLastSynced", { date: new Date(gmailStatus.lastSyncedAt).toLocaleString() })} size="small" /> : null}
{gmailStatus.lastSyncedAt ? <Chip label={t("correspondenceLastSynced", { date: new Date(gmailStatus.lastSyncedAt).toLocaleString(language === "nb" ? "nb-NO" : "en") })} size="small" /> : null}
{gmailStatus.lastSyncAttemptedAt ? <Chip label={t("correspondenceSyncChecked", { date: new Date(gmailStatus.lastSyncAttemptedAt).toLocaleString(language === "nb" ? "nb-NO" : "en") })} size="small" variant="outlined" /> : null}
{gmailStatus.lastSyncStatus === "error" && gmailStatus.lastSyncError ? <Chip label={t("correspondenceSyncIssue", { error: gmailStatus.lastSyncError })} size="small" color="warning" variant="outlined" /> : null}
{linkedThreadIds.length > 0 ? (
@@ -710,7 +710,7 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
<ListItemButton sx={{ alignItems: "flex-start", px: 0, py: 1 }}>
<ListItemText
secondaryTypographyProps={{ component: "div" }}
primary={<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}><Typography sx={{ fontWeight: 700 }}>{message.subject || t("correspondenceNoSubject")}</Typography><Typography variant="caption" sx={{ color: "text.secondary" }}>{message.date ? new Date(message.date).toLocaleString() : ""}</Typography></Box>}
primary={<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}><Typography sx={{ fontWeight: 700 }}>{message.subject || t("correspondenceNoSubject")}</Typography><Typography variant="caption" sx={{ color: "text.secondary" }}>{message.date ? new Date(message.date).toLocaleString(language === "nb" ? "nb-NO" : "en") : ""}</Typography></Box>}
secondary={<Box component="span" sx={{ mt: 0.5, display: "block" }}>
<Typography component="span" variant="body2" sx={{ color: "text.primary", display: "block" }}>{t("correspondenceFromLabel", { value: message.from || t("correspondenceUnknown") })}</Typography>
<Typography component="span" variant="body2" sx={{ color: "text.secondary", mt: 0.25, display: "block" }}>{message.snippet}</Typography>
@@ -654,7 +654,7 @@ export default function DashboardView() {
<Box key={series.tag}>
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 0.5, gap: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 800, overflowWrap: "anywhere" }}>{series.tag}</Typography>
<Typography variant="caption" sx={{ color: "text.secondary" }}>{series.counts.reduce((sum, value) => sum + value, 0)} total</Typography>
<Typography variant="caption" sx={{ color: "text.secondary" }}>{t("dashboardTotalCount", { count: series.counts.reduce((sum, value) => sum + value, 0) })}</Typography>
</Box>
<Box sx={{ display: "grid", gridTemplateColumns: `repeat(${series.counts.length}, 1fr)`, gap: 0.5 }}>
{series.counts.map((count, i) => (
+2 -2
View File
@@ -771,7 +771,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
<Table size="small" sx={{ minWidth: 980 }}>
<TableHead>
<TableRow>
<TableCell padding="checkbox"><Checkbox checked={selectedAllOnPage} indeterminate={selectedIds.length > 0 && !selectedAllOnPage} onChange={(e) => toggleSelectAll(e.target.checked)} /></TableCell>
<TableCell padding="checkbox"><Checkbox inputProps={{ "aria-label": t("jobTableSelectAll") }} checked={selectedAllOnPage} indeterminate={selectedIds.length > 0 && !selectedAllOnPage} onChange={(e) => toggleSelectAll(e.target.checked)} /></TableCell>
<TableCell sortDirection={sortBy === "company" ? sortDir : false}><TableSortLabel active={sortBy === "company"} direction={sortBy === "company" ? sortDir : "asc"} onClick={() => requestSort("company")}>{t("jobTableCompany")}</TableSortLabel></TableCell>
<TableCell sortDirection={sortBy === "jobTitle" ? sortDir : false}><TableSortLabel active={sortBy === "jobTitle"} direction={sortBy === "jobTitle" ? sortDir : "asc"} onClick={() => requestSort("jobTitle")}>{t("jobTableRole")}</TableSortLabel></TableCell>
<TableCell sortDirection={sortBy === "location" ? sortDir : false}><TableSortLabel active={sortBy === "location"} direction={sortBy === "location" ? sortDir : "asc"} onClick={() => requestSort("location")}>{t("jobTableLocation")}</TableSortLabel></TableCell>
@@ -809,7 +809,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
"&:focus-visible": { outline: "3px solid", outlineColor: "primary.main", outlineOffset: -3 },
}}
>
<TableCell padding="checkbox"><Checkbox checked={isSelected} onChange={(e) => toggleSelected(job.id, e.target.checked)} /></TableCell>
<TableCell padding="checkbox"><Checkbox inputProps={{ "aria-label": t("jobTableSelectJob", { title: job.jobTitle }) }} checked={isSelected} onChange={(e) => toggleSelected(job.id, e.target.checked)} /></TableCell>
<TableCell sx={{ minWidth: 140, fontWeight: 700 }}>{job.company?.name ?? ""}</TableCell>
<TableCell>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
@@ -1,6 +1,7 @@
import React, { useState } from "react";
import CloseIcon from "@mui/icons-material/Close";
import { Alert, Button, IconButton, Stack, Typography } from "@mui/material";
import { useOptionalI18n } from "../i18n/I18nProvider";
type Props = {
featureKey: string;
@@ -13,6 +14,7 @@ function storageKey(featureKey: string) {
}
export default function ProFeatureNotice({ featureKey, title, children }: Props) {
const i18n = useOptionalI18n();
const [dismissed, setDismissed] = useState(() => {
if (typeof window === "undefined") return false;
try {
@@ -38,8 +40,8 @@ export default function ProFeatureNotice({ featureKey, title, children }: Props)
severity="info"
action={(
<Stack direction="row" spacing={0.5} alignItems="center">
<Button href="/settings" size="small">View Pro</Button>
<IconButton size="small" aria-label={`Dismiss ${title}`} onClick={dismiss}>
<Button href="/settings" size="small">{i18n?.t("proFeatureView") ?? "View Pro"}</Button>
<IconButton size="small" aria-label={i18n?.t("proFeatureDismiss", { title }) ?? `Dismiss ${title}`} onClick={dismiss}>
<CloseIcon fontSize="small" />
</IconButton>
</Stack>
+4 -2
View File
@@ -1,9 +1,10 @@
import React from "react";
import { Autocomplete, Chip, TextField } from "@mui/material";
import { useI18n } from "../i18n/I18nProvider";
export default function TagsInput({
label = "Tags",
label,
value,
onChange,
}: {
@@ -11,6 +12,7 @@ export default function TagsInput({
value: string[];
onChange: (next: string[]) => void;
}) {
const { t } = useI18n();
return (
<Autocomplete
multiple
@@ -28,7 +30,7 @@ export default function TagsInput({
<Chip variant="outlined" label={option} {...getTagProps({ index })} key={option} size="small" />
))
}
renderInput={(params) => <TextField {...params} label={label} placeholder="Add a tag and press Enter" />}
renderInput={(params) => <TextField {...params} label={label ?? t("cvEditorTags")} placeholder={t("tagsInputPlaceholder")} />}
/>
);
}
+14
View File
@@ -4,6 +4,7 @@ export const translations = {
en: {
appTitle: "Jobbjakt",
appTagline: "Track your hunt",
applicationLanguage: "Application language",
dashboard: "Dashboard",
jobApplications: "Job Applications",
reminders: "Reminders",
@@ -898,6 +899,12 @@ export const translations = {
addJobModalTailoredCvRequiresPro: "Tailored CV generation requires Pro",
addJobModalTailoredCvHelp: "Uses your reviewed Career Profile and keeps the result as an editable suggestion.",
addJobModalNoAiHelp: "Create and track the job normally; no AI operation will be started.",
tagsInputPlaceholder: "Add a tag and press Enter",
proFeatureView: "View Pro",
proFeatureDismiss: "Dismiss {title}",
registrationUnavailable: "Registration is currently unavailable.",
dashboardTotalCount: "{count} total",
jobTableSelectJob: "Select {title}",
characterCount: "{count} characters",
back: "Back",
continue: "Continue",
@@ -2275,6 +2282,7 @@ export const translations = {
nb: {
appTitle: "Jobbjakt",
appTagline: "Hold oversikt over jobbsøkingen",
applicationLanguage: "Språk i appen",
dashboard: "Dashboard",
jobApplications: "Jobbsøknader",
reminders: "Påminnelser",
@@ -3169,6 +3177,12 @@ export const translations = {
addJobModalTailoredCvRequiresPro: "Tilpasset CV-generering krever Pro",
addJobModalTailoredCvHelp: "Bruker den gjennomgåtte Karriereprofilen og beholder resultatet som et redigerbart forslag.",
addJobModalNoAiHelp: "Opprett og spor jobben som vanlig; ingen KI-operasjon startes.",
tagsInputPlaceholder: "Legg til en tagg og trykk Enter",
proFeatureView: "Se Pro",
proFeatureDismiss: "Lukk {title}",
registrationUnavailable: "Registrering er ikke tilgjengelig for øyeblikket.",
dashboardTotalCount: "{count} totalt",
jobTableSelectJob: "Velg {title}",
characterCount: "{count} tegn",
back: "Tilbake",
continue: "Fortsett",
+2 -1
View File
@@ -557,8 +557,9 @@ export default function AppShell({
}
export function StackLanguageToggle({ language, setLanguage }: { language: "en" | "nb"; setLanguage: (language: "en" | "nb") => void }) {
const { t } = useI18n();
return (
<ButtonGroup size="small" variant="outlined" aria-label="Application language" sx={{ flex: "0 0 auto", "& .MuiButton-root": { minWidth: 36, px: 0.6, fontWeight: 800 } }}>
<ButtonGroup size="small" variant="outlined" aria-label={t("applicationLanguage")} sx={{ flex: "0 0 auto", "& .MuiButton-root": { minWidth: 36, px: 0.6, fontWeight: 800 } }}>
<Button aria-label="English" aria-pressed={language === "en"} variant={language === "en" ? "contained" : "outlined"} onClick={() => setLanguage("en")}>EN</Button>
<Button aria-label="Norsk" aria-pressed={language === "nb"} variant={language === "nb" ? "contained" : "outlined"} onClick={() => setLanguage("nb")}>NO</Button>
</ButtonGroup>
+1 -1
View File
@@ -163,7 +163,7 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo
onSubmit={(e) => { e.preventDefault(); void submit(registerMode ? "register" : "login"); }}
sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}
>
{registerMode && cfg && !allowReg ? <Alert severity="info">Registration is currently unavailable.</Alert> : null}
{registerMode && cfg && !allowReg ? <Alert severity="info">{t("registrationUnavailable")}</Alert> : null}
{cfg?.requireEmailVerification && emailNotVerified && (
<Alert
severity="warning"
+2 -2
View File
@@ -162,8 +162,8 @@ export default function ProfilePage() {
/>
{loadError ? (
<Alert severity="error" sx={{ mb: 2, borderRadius: 2.5 }} action={<Button color="inherit" size="small" onClick={() => void loadProfile()}>Retry</Button>}>
Unable to load profile.
<Alert severity="error" sx={{ mb: 2, borderRadius: 2.5 }} action={<Button color="inherit" size="small" onClick={() => void loadProfile()}>{t("retry")}</Button>}>
{t("profileLoadFailed")}
<Typography variant="body2" sx={{ mt: 0.5 }}>{loadError}</Typography>
</Alert>
) : null}