fix(i18n): localize discovery and accounts

This commit is contained in:
cesnimda
2026-08-29 22:01:26 +02:00
parent 457f0601c0
commit f89e37bb4f
7 changed files with 195 additions and 56 deletions
+3
View File
@@ -28,6 +28,8 @@ Updated: 2026-08-29
- Localized backend-owned career completeness labels through one shared UI mapping so Overview and Profile display Bokmål without changing the API contract or stored profile data. - Localized backend-owned career completeness labels through one shared UI mapping so Overview and Profile display Bokmål without changing the API contract or stored profile data.
- Removed the remaining mojibake from the translation catalogue, including account email, Microsoft relink, account-deletion, and learning-path messages. - Removed the remaining mojibake from the translation catalogue, including account email, Microsoft relink, account-deletion, and learning-path messages.
- Localized the Operations and Notifications activity page controls, dates, progress accessibility labels, empty/error/loading states, cancellation feedback, and retry/dismiss actions in English and Norwegian Bokmål. - Localized the Operations and Notifications activity page controls, dates, progress accessibility labels, empty/error/loading states, cancellation feedback, and retry/dismiss actions in English and Norwegian Bokmål.
- Localized Job Discovery across search, source guidance, retry/empty/loading states, result sorting, vacancy metadata, locale-aware dates, and save/view actions.
- Localized Connected Accounts and the shared email-provider connection controls, validation, OAuth/IMAP feedback, connection states, and accessible form labels without translating provider names or user email addresses.
### In progress ### In progress
@@ -74,6 +76,7 @@ Updated: 2026-08-29
- Career Profile and Operations focused verification: 2 suites, 16/16 passed, including Bokmål completeness/confidence and operations empty-state regressions. - Career Profile and Operations focused verification: 2 suites, 16/16 passed, including Bokmål completeness/confidence and operations empty-state regressions.
- Full frontend after the Career/Operations localization batch: 62 suites, 260/260 tests passed. - Full frontend after the Career/Operations localization batch: 62 suites, 260/260 tests passed.
- Next optimized production build and its integrated TypeScript check passed after the Career/Operations localization batch. - Next optimized production build and its integrated TypeScript check passed after the Career/Operations localization batch.
- Job Discovery and email-provider connections focused verification: 2 suites, 8/8 passed, including Bokmål workflow regressions; TypeScript passed.
- Backend matcher/intelligence focused verification: 35/35 passed, including detection of a manually created Norwegian advert with no saved translation. - 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. - Full backend: 712/712 tests passed on .NET 9.
- Next optimized production build and TypeScript: passed after the Job Workspace/checklist batch. - Next optimized production build and TypeScript: passed after the Job Workspace/checklist batch.
@@ -4,6 +4,7 @@ import { Box, Button, Checkbox, Chip, Divider, FormControlLabel, Paper, Stack, T
import CheckCircleIcon from "@mui/icons-material/CheckCircle"; import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import { api, getApiErrorMessage } from "../api"; import { api, getApiErrorMessage } from "../api";
import { useI18n } from "../i18n/I18nProvider";
import { useToast } from "../toast"; import { useToast } from "../toast";
import type { GmailStatus, ImapStatus, MicrosoftGraphStatus } from "../types"; import type { GmailStatus, ImapStatus, MicrosoftGraphStatus } from "../types";
@@ -13,6 +14,7 @@ import type { GmailStatus, ImapStatus, MicrosoftGraphStatus } from "../types";
// form submitted to POST /api/imap/connect, which verifies the connection before storing it. // form submitted to POST /api/imap/connect, which verifies the connection before storing it.
export default function EmailProviderConnections() { export default function EmailProviderConnections() {
const { toast } = useToast(); const { toast } = useToast();
const { t } = useI18n();
const [gmailStatus, setGmailStatus] = useState<GmailStatus | null>(null); const [gmailStatus, setGmailStatus] = useState<GmailStatus | null>(null);
const [microsoftStatus, setMicrosoftStatus] = useState<MicrosoftGraphStatus | null>(null); const [microsoftStatus, setMicrosoftStatus] = useState<MicrosoftGraphStatus | null>(null);
@@ -59,32 +61,32 @@ export default function EmailProviderConnections() {
const data = event.data as { source?: string; status?: string; message?: string }; const data = event.data as { source?: string; status?: string; message?: string };
if (data?.source === "jobtracker-gmail-oauth") { if (data?.source === "jobtracker-gmail-oauth") {
if (data.status === "connected") { if (data.status === "connected") {
toast(data.message || "Gmail connected.", "success"); toast(data.message || t("emailConnectionsGmailConnected"), "success");
void loadGmailStatus(); void loadGmailStatus();
} else { } else {
toast(data.message || "Gmail connection failed.", "error"); toast(data.message || t("emailConnectionsGmailFailed"), "error");
} }
} else if (data?.source === "jobtracker-microsoft-oauth") { } else if (data?.source === "jobtracker-microsoft-oauth") {
if (data.status === "connected") { if (data.status === "connected") {
toast(data.message || "Outlook connected.", "success"); toast(data.message || t("emailConnectionsOutlookConnected"), "success");
void loadMicrosoftStatus(); void loadMicrosoftStatus();
} else { } else {
toast(data.message || "Outlook connection failed.", "error"); toast(data.message || t("emailConnectionsOutlookFailed"), "error");
} }
} }
}; };
window.addEventListener("message", onMessage); window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage); return () => window.removeEventListener("message", onMessage);
}, [loadGmailStatus, loadMicrosoftStatus, toast]); }, [loadGmailStatus, loadMicrosoftStatus, t, toast]);
const connectViaPopup = async (connectUrlPath: string, popupName: string, providerLabel: string) => { const connectViaPopup = async (connectUrlPath: string, popupName: string, providerLabel: string) => {
try { try {
const res = await api.get<{ url: string }>(connectUrlPath); const res = await api.get<{ url: string }>(connectUrlPath);
const popup = window.open(res.data.url, popupName, "width=620,height=760,resizable=yes,scrollbars=yes"); const popup = window.open(res.data.url, popupName, "width=620,height=760,resizable=yes,scrollbars=yes");
if (!popup) toast("Your browser blocked the connect popup. Allow popups and try again.", "error"); if (!popup) toast(t("emailConnectionsPopupBlocked"), "error");
} catch (error) { } catch (error) {
toast(getApiErrorMessage(error, `Failed to start ${providerLabel} connection.`), "error"); toast(getApiErrorMessage(error, t("emailConnectionsStartFailed", { provider: providerLabel })), "error");
} }
}; };
@@ -92,15 +94,15 @@ export default function EmailProviderConnections() {
try { try {
await api.delete(path); await api.delete(path);
await reload(); await reload();
toast(`${providerLabel} disconnected.`, "success"); toast(t("emailConnectionsDisconnected", { provider: providerLabel }), "success");
} catch (error) { } catch (error) {
toast(getApiErrorMessage(error, `Failed to disconnect ${providerLabel}.`), "error"); toast(getApiErrorMessage(error, t("emailConnectionsDisconnectFailed", { provider: providerLabel })), "error");
} }
}; };
const connectImap = async () => { const connectImap = async () => {
if (!imapForm.host.trim() || !imapForm.username.trim() || !imapForm.password) { if (!imapForm.host.trim() || !imapForm.username.trim() || !imapForm.password) {
toast("Host, username, and password are required.", "error"); toast(t("emailConnectionsRequired"), "error");
return; return;
} }
setImapConnecting(true); setImapConnecting(true);
@@ -108,9 +110,9 @@ export default function EmailProviderConnections() {
await api.post("/imap/connect", imapForm); await api.post("/imap/connect", imapForm);
setImapForm((prev) => ({ ...prev, password: "" })); setImapForm((prev) => ({ ...prev, password: "" }));
await loadImapStatus(); await loadImapStatus();
toast("IMAP account connected.", "success"); toast(t("emailConnectionsImapConnected"), "success");
} catch (error) { } catch (error) {
toast(getApiErrorMessage(error, "Failed to connect IMAP account."), "error"); toast(getApiErrorMessage(error, t("emailConnectionsImapFailed")), "error");
} finally { } finally {
setImapConnecting(false); setImapConnecting(false);
} }
@@ -118,9 +120,9 @@ export default function EmailProviderConnections() {
return ( return (
<Paper sx={{ p: 2 }}> <Paper sx={{ p: 2 }}>
<Typography sx={{ fontWeight: 950, mb: 0.5 }}>Linked email accounts</Typography> <Typography sx={{ fontWeight: 950, mb: 0.5 }}>{t("emailConnectionsTitle")}</Typography>
<Typography sx={{ color: "text.secondary", mb: 2 }}> <Typography sx={{ color: "text.secondary", mb: 2 }}>
Connect a mailbox so recruiter correspondence can be linked to jobs automatically. {t("emailConnectionsBody")}
</Typography> </Typography>
<Stack spacing={2}> <Stack spacing={2}>
@@ -146,7 +148,7 @@ export default function EmailProviderConnections() {
<Box> <Box>
<ProviderRow <ProviderRow
label="Other (IMAP)" label={t("emailConnectionsOtherImap")}
connected={Boolean(imapStatus?.connected)} connected={Boolean(imapStatus?.connected)}
address={imapStatus?.username ?? null} address={imapStatus?.username ?? null}
onDisconnect={() => void disconnect("/imap/connection", loadImapStatus, "IMAP")} onDisconnect={() => void disconnect("/imap/connection", loadImapStatus, "IMAP")}
@@ -155,28 +157,28 @@ export default function EmailProviderConnections() {
<Box sx={{ mt: 1.5, display: "grid", gap: 1.25, gridTemplateColumns: { xs: "1fr", sm: "2fr 1fr" }, maxWidth: 520 }}> <Box sx={{ mt: 1.5, display: "grid", gap: 1.25, gridTemplateColumns: { xs: "1fr", sm: "2fr 1fr" }, maxWidth: 520 }}>
<TextField <TextField
size="small" size="small"
label="IMAP host" label={t("emailConnectionsImapHost")}
placeholder="imap.example.com" placeholder="imap.example.com"
value={imapForm.host} value={imapForm.host}
onChange={(e) => setImapForm((prev) => ({ ...prev, host: e.target.value }))} onChange={(e) => setImapForm((prev) => ({ ...prev, host: e.target.value }))}
/> />
<TextField <TextField
size="small" size="small"
label="Port" label={t("emailConnectionsPort")}
type="number" type="number"
value={imapForm.port} value={imapForm.port}
onChange={(e) => setImapForm((prev) => ({ ...prev, port: Number(e.target.value) || 993 }))} onChange={(e) => setImapForm((prev) => ({ ...prev, port: Number(e.target.value) || 993 }))}
/> />
<TextField <TextField
size="small" size="small"
label="Username" label={t("emailConnectionsUsername")}
value={imapForm.username} value={imapForm.username}
onChange={(e) => setImapForm((prev) => ({ ...prev, username: e.target.value }))} onChange={(e) => setImapForm((prev) => ({ ...prev, username: e.target.value }))}
sx={{ gridColumn: "1 / -1" }} sx={{ gridColumn: "1 / -1" }}
/> />
<TextField <TextField
size="small" size="small"
label="Password" label={t("emailConnectionsPassword")}
type="password" type="password"
value={imapForm.password} value={imapForm.password}
onChange={(e) => setImapForm((prev) => ({ ...prev, password: e.target.value }))} onChange={(e) => setImapForm((prev) => ({ ...prev, password: e.target.value }))}
@@ -185,7 +187,7 @@ export default function EmailProviderConnections() {
<FormControlLabel <FormControlLabel
sx={{ gridColumn: "1 / -1" }} sx={{ gridColumn: "1 / -1" }}
control={<Checkbox checked={imapForm.useSsl} onChange={(e) => setImapForm((prev) => ({ ...prev, useSsl: e.target.checked }))} />} control={<Checkbox checked={imapForm.useSsl} onChange={(e) => setImapForm((prev) => ({ ...prev, useSsl: e.target.checked }))} />}
label="Use SSL/TLS" label={t("emailConnectionsUseSsl")}
/> />
<Button <Button
variant="contained" variant="contained"
@@ -193,7 +195,7 @@ export default function EmailProviderConnections() {
disabled={imapConnecting} disabled={imapConnecting}
sx={{ gridColumn: "1 / -1", justifySelf: "start" }} sx={{ gridColumn: "1 / -1", justifySelf: "start" }}
> >
{imapConnecting ? "Connecting" : "Connect IMAP account"} {imapConnecting ? t("emailConnectionsConnecting") : t("emailConnectionsConnectImap")}
</Button> </Button>
</Box> </Box>
)} )}
@@ -216,6 +218,7 @@ function ProviderRow({
onConnect?: () => void; onConnect?: () => void;
onDisconnect: () => void; onDisconnect: () => void;
}) { }) {
const { t } = useI18n();
return ( return (
<Stack direction="row" alignItems="center" justifyContent="space-between" flexWrap="wrap" gap={1}> <Stack direction="row" alignItems="center" justifyContent="space-between" flexWrap="wrap" gap={1}>
<Box> <Box>
@@ -226,17 +229,17 @@ function ProviderRow({
icon={<CheckCircleIcon fontSize="small" />} icon={<CheckCircleIcon fontSize="small" />}
color="success" color="success"
variant="outlined" variant="outlined"
label={address || "Connected"} label={address || t("emailConnectionsConnected")}
sx={{ mt: 0.5 }} sx={{ mt: 0.5 }}
/> />
) : ( ) : (
<Typography variant="caption" sx={{ color: "text.secondary" }}>Not connected</Typography> <Typography variant="caption" sx={{ color: "text.secondary" }}>{t("emailConnectionsNotConnected")}</Typography>
)} )}
</Box> </Box>
{connected ? ( {connected ? (
<Button size="small" variant="outlined" color="error" onClick={onDisconnect}>Disconnect</Button> <Button size="small" variant="outlined" color="error" onClick={onDisconnect}>{t("emailConnectionsDisconnect")}</Button>
) : ( ) : (
onConnect && <Button size="small" variant="outlined" onClick={onConnect}>Connect</Button> onConnect && <Button size="small" variant="outlined" onClick={onConnect}>{t("emailConnectionsConnect")}</Button>
)} )}
</Stack> </Stack>
); );
@@ -4,6 +4,7 @@ import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { ToastProvider } from "./toast"; import { ToastProvider } from "./toast";
import { api } from "./api"; import { api } from "./api";
import { I18nProvider } from "./i18n/I18nProvider";
import EmailProviderConnections from "./components/EmailProviderConnections"; import EmailProviderConnections from "./components/EmailProviderConnections";
jest.mock("./api", () => ({ jest.mock("./api", () => ({
@@ -21,7 +22,7 @@ const mockedApi = api as jest.Mocked<typeof api>;
function renderComponent() { function renderComponent() {
return render( return render(
<ToastProvider> <ToastProvider>
<EmailProviderConnections /> <I18nProvider><EmailProviderConnections /></I18nProvider>
</ToastProvider>, </ToastProvider>,
); );
} }
@@ -29,6 +30,18 @@ function renderComponent() {
describe("EmailProviderConnections", () => { describe("EmailProviderConnections", () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
window.localStorage.clear();
});
it("renders connection controls in Norwegian Bokmål", async () => {
window.localStorage.setItem("uiLanguage", "nb");
mockedApi.get.mockResolvedValue({ data: { connected: false } } as any);
renderComponent();
expect(await screen.findByText("Tilkoblede e-postkontoer")).toBeInTheDocument();
expect(screen.getByLabelText("IMAP-vert")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Koble til IMAP-konto" })).toBeInTheDocument();
}); });
it("renders connected state for Gmail and Outlook, disconnected form for IMAP", async () => { it("renders connected state for Gmail and Outlook, disconnected form for IMAP", async () => {
+100
View File
@@ -83,6 +83,29 @@ export const translations = {
languageNorwegianBokmal: "Norsk bokmål", languageNorwegianBokmal: "Norsk bokmål",
discoverJobs: "Discover jobs", discoverJobs: "Discover jobs",
discoverJobsSubtitle: "Search official job-board feeds and save opportunities to your tracker.", discoverJobsSubtitle: "Search official job-board feeds and save opportunities to your tracker.",
jobDiscoveryRoleCompany: "Role or company",
jobDiscoveryMunicipality: "Municipality",
jobDiscoverySearchNav: "Search NAV",
jobDiscoverySourceHelp: "Official Norwegian vacancies from NAV. FINN, Indeed and LinkedIn jobs can still be captured by URL.",
jobDiscoveryUnavailable: "NAV job discovery is temporarily unavailable.",
jobDiscoveryGuidance: "Search recent active vacancies by role, company, or municipality.",
jobDiscoveryNoResults: "No active vacancies matched this search. Try a broader role, company, or municipality.",
jobDiscoverySearching: "Searching NAV vacancies…",
jobDiscoveryResultOne: "{count} vacancy found",
jobDiscoveryResultMany: "{count} vacancies found",
jobDiscoverySort: "Sort results",
jobDiscoverySortUpdated: "Recently updated",
jobDiscoverySortDeadline: "Deadline soonest",
jobDiscoverySortTitle: "Title AZ",
jobDiscoveryLocationMissing: "Location not provided",
jobDiscoverySearchedListing: "Searched listing",
jobDiscoverySourceTypeMissing: "Source type unavailable",
jobDiscoveryRetrieved: "Retrieved {date}",
jobDiscoveryListingUpdated: "Listing updated {date}",
jobDiscoveryDeadline: "Application deadline: {date}",
jobDiscoveryWorkArrangementMissing: "Work arrangement not provided by this source",
jobDiscoveryViewListing: "View listing",
jobDiscoverySaveTracker: "Save to tracker",
jobDetails: "Job details", jobDetails: "Job details",
jobDetailsWorkspaceSubtitle: "Manage this application, its documents, timeline, and correspondence.", jobDetailsWorkspaceSubtitle: "Manage this application, its documents, timeline, and correspondence.",
operations: "Operations", operations: "Operations",
@@ -99,6 +122,33 @@ export const translations = {
operationsFailed: "Failed: {category}", operationsFailed: "Failed: {category}",
gmailReviewQueue: "Gmail review queue", gmailReviewQueue: "Gmail review queue",
connectedAccounts: "Connected accounts", connectedAccounts: "Connected accounts",
connectedAccountsBody: "Connect the inboxes Jobbjakt may use to identify recruiter correspondence. Connecting an inbox never sends a message on your behalf.",
connectedAccountsBack: "Back to settings",
emailConnectionsTitle: "Linked email accounts",
emailConnectionsBody: "Connect a mailbox so recruiter correspondence can be linked to jobs automatically.",
emailConnectionsGmailConnected: "Gmail connected.",
emailConnectionsGmailFailed: "Gmail connection failed.",
emailConnectionsOutlookConnected: "Outlook connected.",
emailConnectionsOutlookFailed: "Outlook connection failed.",
emailConnectionsPopupBlocked: "Your browser blocked the connect popup. Allow popups and try again.",
emailConnectionsStartFailed: "Failed to start {provider} connection.",
emailConnectionsDisconnected: "{provider} disconnected.",
emailConnectionsDisconnectFailed: "Failed to disconnect {provider}.",
emailConnectionsRequired: "Host, username, and password are required.",
emailConnectionsImapConnected: "IMAP account connected.",
emailConnectionsImapFailed: "Failed to connect IMAP account.",
emailConnectionsOtherImap: "Other (IMAP)",
emailConnectionsImapHost: "IMAP host",
emailConnectionsPort: "Port",
emailConnectionsUsername: "Username",
emailConnectionsPassword: "Password",
emailConnectionsUseSsl: "Use SSL/TLS",
emailConnectionsConnecting: "Connecting…",
emailConnectionsConnectImap: "Connect IMAP account",
emailConnectionsConnected: "Connected",
emailConnectionsNotConnected: "Not connected",
emailConnectionsConnect: "Connect",
emailConnectionsDisconnect: "Disconnect",
correspondenceInboxTitle: "Correspondence inbox", correspondenceInboxTitle: "Correspondence inbox",
careerWorkspace: "Career Workspace", careerWorkspace: "Career Workspace",
cvBuilder: "CV Builder", cvBuilder: "CV Builder",
@@ -1951,6 +2001,29 @@ export const translations = {
languageNorwegianBokmal: "Norsk bokmål", languageNorwegianBokmal: "Norsk bokmål",
discoverJobs: "Finn jobber", discoverJobs: "Finn jobber",
discoverJobsSubtitle: "Søk i offisielle jobbportaler og lagre muligheter i oversikten din.", discoverJobsSubtitle: "Søk i offisielle jobbportaler og lagre muligheter i oversikten din.",
jobDiscoveryRoleCompany: "Rolle eller bedrift",
jobDiscoveryMunicipality: "Kommune",
jobDiscoverySearchNav: "Søk i NAV",
jobDiscoverySourceHelp: "Offisielle norske stillinger fra NAV. Stillinger fra FINN, Indeed og LinkedIn kan fortsatt hentes inn via nettadresse.",
jobDiscoveryUnavailable: "NAVs jobbsøk er midlertidig utilgjengelig.",
jobDiscoveryGuidance: "Søk i nylige aktive stillinger etter rolle, bedrift eller kommune.",
jobDiscoveryNoResults: "Ingen aktive stillinger samsvarte med søket. Prøv en bredere rolle, bedrift eller kommune.",
jobDiscoverySearching: "Søker i NAV-stillinger…",
jobDiscoveryResultOne: "{count} stilling funnet",
jobDiscoveryResultMany: "{count} stillinger funnet",
jobDiscoverySort: "Sorter resultater",
jobDiscoverySortUpdated: "Nylig oppdatert",
jobDiscoverySortDeadline: "Nærmeste frist",
jobDiscoverySortTitle: "Tittel A–Å",
jobDiscoveryLocationMissing: "Sted er ikke oppgitt",
jobDiscoverySearchedListing: "Funnet gjennom søk",
jobDiscoverySourceTypeMissing: "Kildetype er ikke tilgjengelig",
jobDiscoveryRetrieved: "Hentet {date}",
jobDiscoveryListingUpdated: "Stillingen ble oppdatert {date}",
jobDiscoveryDeadline: "Søknadsfrist: {date}",
jobDiscoveryWorkArrangementMissing: "Arbeidsform er ikke oppgitt av kilden",
jobDiscoveryViewListing: "Vis stilling",
jobDiscoverySaveTracker: "Lagre i oversikten",
jobDetails: "Jobbdetaljer", jobDetails: "Jobbdetaljer",
jobDetailsWorkspaceSubtitle: "Administrer søknaden, dokumentene, tidslinjen og korrespondansen.", jobDetailsWorkspaceSubtitle: "Administrer søknaden, dokumentene, tidslinjen og korrespondansen.",
operations: "Operasjoner", operations: "Operasjoner",
@@ -1967,6 +2040,33 @@ export const translations = {
operationsFailed: "Mislyktes: {category}", operationsFailed: "Mislyktes: {category}",
gmailReviewQueue: "Gmail-gjennomgang", gmailReviewQueue: "Gmail-gjennomgang",
connectedAccounts: "Tilkoblede kontoer", connectedAccounts: "Tilkoblede kontoer",
connectedAccountsBody: "Koble til innboksene Jobbjakt kan bruke til å identifisere korrespondanse med rekrutterere. En tilkoblet innboks sender aldri meldinger på dine vegne.",
connectedAccountsBack: "Tilbake til innstillinger",
emailConnectionsTitle: "Tilkoblede e-postkontoer",
emailConnectionsBody: "Koble til en innboks slik at korrespondanse med rekrutterere automatisk kan knyttes til jobber.",
emailConnectionsGmailConnected: "Gmail er koblet til.",
emailConnectionsGmailFailed: "Tilkobling til Gmail mislyktes.",
emailConnectionsOutlookConnected: "Outlook er koblet til.",
emailConnectionsOutlookFailed: "Tilkobling til Outlook mislyktes.",
emailConnectionsPopupBlocked: "Nettleseren blokkerte tilkoblingsvinduet. Tillat sprettoppvinduer og prøv igjen.",
emailConnectionsStartFailed: "Kunne ikke starte tilkoblingen til {provider}.",
emailConnectionsDisconnected: "{provider} er koblet fra.",
emailConnectionsDisconnectFailed: "Kunne ikke koble fra {provider}.",
emailConnectionsRequired: "Vert, brukernavn og passord er påkrevd.",
emailConnectionsImapConnected: "IMAP-kontoen er koblet til.",
emailConnectionsImapFailed: "Kunne ikke koble til IMAP-kontoen.",
emailConnectionsOtherImap: "Annen konto (IMAP)",
emailConnectionsImapHost: "IMAP-vert",
emailConnectionsPort: "Port",
emailConnectionsUsername: "Brukernavn",
emailConnectionsPassword: "Passord",
emailConnectionsUseSsl: "Bruk SSL/TLS",
emailConnectionsConnecting: "Kobler til…",
emailConnectionsConnectImap: "Koble til IMAP-konto",
emailConnectionsConnected: "Tilkoblet",
emailConnectionsNotConnected: "Ikke tilkoblet",
emailConnectionsConnect: "Koble til",
emailConnectionsDisconnect: "Koble fra",
correspondenceInboxTitle: "Korrespondanseinnboks", correspondenceInboxTitle: "Korrespondanseinnboks",
careerWorkspace: "Karriereområde", careerWorkspace: "Karriereområde",
cvBuilder: "CV-bygger", cvBuilder: "CV-bygger",
+20 -4
View File
@@ -3,16 +3,22 @@ import "@testing-library/jest-dom";
import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom"; import { MemoryRouter } from "react-router-dom";
import { api } from "./api"; import { api } from "./api";
import { I18nProvider } from "./i18n/I18nProvider";
import JobDiscoveryPage from "./views/JobDiscoveryPage"; import JobDiscoveryPage from "./views/JobDiscoveryPage";
jest.mock("./api", () => ({ api: { get: jest.fn() } })); jest.mock("./api", () => ({ api: { get: jest.fn() } }));
const mockedApi = api as jest.Mocked<typeof api>; const mockedApi = api as jest.Mocked<typeof api>;
function renderPage() {
return render(<I18nProvider><MemoryRouter><JobDiscoveryPage /></MemoryRouter></I18nProvider>);
}
beforeEach(() => window.localStorage.clear());
afterEach(() => jest.clearAllMocks()); afterEach(() => jest.clearAllMocks());
test("shows honest source metadata and offers the existing reviewed save flow", async () => { test("shows honest source metadata and offers the existing reviewed save flow", async () => {
mockedApi.get.mockResolvedValue({ data: [{ id: "abc", title: "Backend Developer", company: "Acme", location: "OSLO", modifiedAt: "2026-08-01T10:00:00Z", deadline: "2026-08-15T23:59:59Z", url: "https://arbeidsplassen.nav.no/stillinger/stilling/abc", source: "nav", sourceName: "NAV Arbeidsplassen", acquisitionType: "searched", retrievedAt: "2026-08-10T10:00:00Z", countryCode: "NO" }] } as any); mockedApi.get.mockResolvedValue({ data: [{ id: "abc", title: "Backend Developer", company: "Acme", location: "OSLO", modifiedAt: "2026-08-01T10:00:00Z", deadline: "2026-08-15T23:59:59Z", url: "https://arbeidsplassen.nav.no/stillinger/stilling/abc", source: "nav", sourceName: "NAV Arbeidsplassen", acquisitionType: "searched", retrievedAt: "2026-08-10T10:00:00Z", countryCode: "NO" }] } as any);
render(<MemoryRouter><JobDiscoveryPage /></MemoryRouter>); renderPage();
fireEvent.change(screen.getByLabelText("Role or company"), { target: { value: "backend" } }); fireEvent.change(screen.getByLabelText("Role or company"), { target: { value: "backend" } });
fireEvent.click(screen.getByRole("button", { name: "Search NAV" })); fireEvent.click(screen.getByRole("button", { name: "Search NAV" }));
@@ -27,7 +33,7 @@ test("shows honest source metadata and offers the existing reviewed save flow",
test("distinguishes the initial guidance from a completed empty search", async () => { test("distinguishes the initial guidance from a completed empty search", async () => {
mockedApi.get.mockResolvedValue({ data: [] } as any); mockedApi.get.mockResolvedValue({ data: [] } as any);
render(<MemoryRouter><JobDiscoveryPage /></MemoryRouter>); renderPage();
expect(screen.getByText("Search recent active vacancies by role, company, or municipality.")).toBeInTheDocument(); expect(screen.getByText("Search recent active vacancies by role, company, or municipality.")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Search NAV" })); fireEvent.click(screen.getByRole("button", { name: "Search NAV" }));
@@ -38,7 +44,7 @@ test("distinguishes the initial guidance from a completed empty search", async (
test("retries the last submitted search after an error", async () => { test("retries the last submitted search after an error", async () => {
mockedApi.get.mockRejectedValueOnce(new Error("offline")).mockResolvedValueOnce({ data: [] } as any); mockedApi.get.mockRejectedValueOnce(new Error("offline")).mockResolvedValueOnce({ data: [] } as any);
render(<MemoryRouter><JobDiscoveryPage /></MemoryRouter>); renderPage();
fireEvent.change(screen.getByLabelText("Role or company"), { target: { value: " utvikler " } }); fireEvent.change(screen.getByLabelText("Role or company"), { target: { value: " utvikler " } });
fireEvent.change(screen.getByLabelText("Municipality"), { target: { value: " Oslo " } }); fireEvent.change(screen.getByLabelText("Municipality"), { target: { value: " Oslo " } });
@@ -54,7 +60,7 @@ test("sorts returned vacancies by title", async () => {
{ id: "2", title: "Zoologist", url: "https://arbeidsplassen.nav.no/2", source: "nav", countryCode: "NO" }, { id: "2", title: "Zoologist", url: "https://arbeidsplassen.nav.no/2", source: "nav", countryCode: "NO" },
{ id: "1", title: "Analyst", url: "https://arbeidsplassen.nav.no/1", source: "nav", countryCode: "NO" }, { id: "1", title: "Analyst", url: "https://arbeidsplassen.nav.no/1", source: "nav", countryCode: "NO" },
] } as any); ] } as any);
render(<MemoryRouter><JobDiscoveryPage /></MemoryRouter>); renderPage();
fireEvent.click(screen.getByRole("button", { name: "Search NAV" })); fireEvent.click(screen.getByRole("button", { name: "Search NAV" }));
await screen.findByText("2 vacancies found"); await screen.findByText("2 vacancies found");
@@ -64,3 +70,13 @@ test("sorts returned vacancies by title", async () => {
const titles = screen.getAllByRole("heading", { level: 6 }).map((element) => element.textContent); const titles = screen.getAllByRole("heading", { level: 6 }).map((element) => element.textContent);
expect(titles).toEqual(["Analyst", "Zoologist"]); expect(titles).toEqual(["Analyst", "Zoologist"]);
}); });
test("renders the discovery workflow in Norwegian Bokmål", async () => {
window.localStorage.setItem("uiLanguage", "nb");
mockedApi.get.mockResolvedValue({ data: [] } as any);
renderPage();
expect(screen.getByLabelText("Rolle eller bedrift")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Søk i NAV" }));
expect(await screen.findByText(/Ingen aktive stillinger samsvarte/)).toBeInTheDocument();
});
@@ -4,18 +4,20 @@ import { Box, Button, Paper, Typography } from "@mui/material";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import EmailProviderConnections from "../components/EmailProviderConnections"; import EmailProviderConnections from "../components/EmailProviderConnections";
import { useI18n } from "../i18n/I18nProvider";
export default function ConnectedAccountsPage() { export default function ConnectedAccountsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useI18n();
return ( return (
<Box sx={{ display: "grid", gap: 2 }}> <Box sx={{ display: "grid", gap: 2 }}>
<Paper sx={{ p: 2.5, borderRadius: 4, boxShadow: "0px 8px 24px -12px rgba(15,23,42,0.12)" }}> <Paper sx={{ p: 2.5, borderRadius: 4, boxShadow: "0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
<Typography variant="h5" sx={{ fontWeight: 900, mb: 0.5 }}>Connected accounts</Typography> <Typography variant="h5" sx={{ fontWeight: 900, mb: 0.5 }}>{t("connectedAccounts")}</Typography>
<Typography sx={{ color: "text.secondary", maxWidth: 720 }}> <Typography sx={{ color: "text.secondary", maxWidth: 720 }}>
Connect the inboxes Jobbjakt may use to identify recruiter correspondence. Connecting an inbox never sends a message on your behalf. {t("connectedAccountsBody")}
</Typography> </Typography>
<Button sx={{ mt: 1 }} onClick={() => navigate("/settings")}>Back to settings</Button> <Button sx={{ mt: 1 }} onClick={() => navigate("/settings")}>{t("connectedAccountsBack")}</Button>
</Paper> </Paper>
<EmailProviderConnections /> <EmailProviderConnections />
</Box> </Box>
+25 -23
View File
@@ -4,6 +4,7 @@ import SearchIcon from "@mui/icons-material/Search";
import AddIcon from "@mui/icons-material/Add"; import AddIcon from "@mui/icons-material/Add";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { api } from "../api"; import { api } from "../api";
import { useI18n } from "../i18n/I18nProvider";
type DiscoveredJob = { type DiscoveredJob = {
id: string; id: string;
@@ -20,11 +21,11 @@ type DiscoveredJob = {
countryCode: string; countryCode: string;
}; };
const formatDate = (value?: string) => value ? new Date(value).toLocaleDateString() : null;
type SortOrder = "updated" | "deadline" | "title"; type SortOrder = "updated" | "deadline" | "title";
export default function JobDiscoveryPage() { export default function JobDiscoveryPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { language, t } = useI18n();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [location, setLocation] = useState(""); const [location, setLocation] = useState("");
const [jobs, setJobs] = useState<DiscoveredJob[]>([]); const [jobs, setJobs] = useState<DiscoveredJob[]>([]);
@@ -39,7 +40,7 @@ export default function JobDiscoveryPage() {
try { try {
const response = await api.get<DiscoveredJob[]>("/job-discovery/search", { params: { q: nextQuery || undefined, location: nextLocation || undefined } }); const response = await api.get<DiscoveredJob[]>("/job-discovery/search", { params: { q: nextQuery || undefined, location: nextLocation || undefined } });
setJobs(response.data ?? []); setJobs(response.data ?? []);
} catch { setError("NAV job discovery is temporarily unavailable."); } } catch { setError(t("jobDiscoveryUnavailable")); }
finally { setLoading(false); } finally { setLoading(false); }
}; };
@@ -57,26 +58,27 @@ export default function JobDiscoveryPage() {
} }
return new Date(right.modifiedAt || 0).getTime() - new Date(left.modifiedAt || 0).getTime(); return new Date(right.modifiedAt || 0).getTime() - new Date(left.modifiedAt || 0).getTime();
}), [jobs, sortOrder]); }), [jobs, sortOrder]);
const formatDate = (value?: string) => value ? new Date(value).toLocaleDateString(language === "nb" ? "nb-NO" : "en") : null;
return ( return (
<Stack spacing={3}> <Stack spacing={3}>
<Box component="form" onSubmit={search} sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "2fr 1fr auto" }, gap: 1.5 }}> <Box component="form" onSubmit={search} sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "2fr 1fr auto" }, gap: 1.5 }}>
<TextField label="Role or company" value={query} onChange={(event) => setQuery(event.target.value)} /> <TextField label={t("jobDiscoveryRoleCompany")} value={query} onChange={(event) => setQuery(event.target.value)} />
<TextField label="Municipality" value={location} onChange={(event) => setLocation(event.target.value)} /> <TextField label={t("jobDiscoveryMunicipality")} value={location} onChange={(event) => setLocation(event.target.value)} />
<Button type="submit" variant="contained" startIcon={loading ? <CircularProgress size={18} color="inherit" /> : <SearchIcon />} disabled={loading}>Search NAV</Button> <Button type="submit" variant="contained" startIcon={loading ? <CircularProgress size={18} color="inherit" /> : <SearchIcon />} disabled={loading}>{t("jobDiscoverySearchNav")}</Button>
</Box> </Box>
<Alert severity="info" variant="outlined" sx={{ color: "text.primary", "& .MuiAlert-icon": { color: "info.main" } }}>Official Norwegian vacancies from NAV. FINN, Indeed and LinkedIn jobs can still be captured by URL.</Alert> <Alert severity="info" variant="outlined" sx={{ color: "text.primary", "& .MuiAlert-icon": { color: "info.main" } }}>{t("jobDiscoverySourceHelp")}</Alert>
{error ? <Alert severity="error" variant="outlined" sx={{ color: "text.primary" }} action={<Button color="inherit" onClick={() => void runSearch(lastSearch.query, lastSearch.location)} disabled={loading}>Retry</Button>}>{error}</Alert> : null} {error ? <Alert severity="error" variant="outlined" sx={{ color: "text.primary" }} action={<Button color="inherit" onClick={() => void runSearch(lastSearch.query, lastSearch.location)} disabled={loading}>{t("retry")}</Button>}>{error}</Alert> : null}
{!loading && !hasSearched ? <Typography color="text.secondary">Search recent active vacancies by role, company, or municipality.</Typography> : null} {!loading && !hasSearched ? <Typography color="text.secondary">{t("jobDiscoveryGuidance")}</Typography> : null}
{!loading && hasSearched && !error && jobs.length === 0 ? <Alert severity="info" variant="outlined" sx={{ color: "text.primary" }}>No active vacancies matched this search. Try a broader role, company, or municipality.</Alert> : null} {!loading && hasSearched && !error && jobs.length === 0 ? <Alert severity="info" variant="outlined" sx={{ color: "text.primary" }}>{t("jobDiscoveryNoResults")}</Alert> : null}
{loading ? <Typography role="status" color="text.secondary">Searching NAV vacancies</Typography> : null} {loading ? <Typography role="status" color="text.secondary">{t("jobDiscoverySearching")}</Typography> : null}
{jobs.length > 0 ? ( {jobs.length > 0 ? (
<Box sx={{ display: "flex", gap: 2, alignItems: { xs: "stretch", sm: "center" }, justifyContent: "space-between", flexDirection: { xs: "column", sm: "row" } }}> <Box sx={{ display: "flex", gap: 2, alignItems: { xs: "stretch", sm: "center" }, justifyContent: "space-between", flexDirection: { xs: "column", sm: "row" } }}>
<Typography role="status">{jobs.length} {jobs.length === 1 ? "vacancy" : "vacancies"} found</Typography> <Typography role="status">{t(jobs.length === 1 ? "jobDiscoveryResultOne" : "jobDiscoveryResultMany", { count: jobs.length })}</Typography>
<TextField select size="small" label="Sort results" value={sortOrder} onChange={(event) => setSortOrder(event.target.value as SortOrder)} sx={{ minWidth: 190 }}> <TextField select size="small" label={t("jobDiscoverySort")} value={sortOrder} onChange={(event) => setSortOrder(event.target.value as SortOrder)} sx={{ minWidth: 190 }}>
<MenuItem value="updated">Recently updated</MenuItem> <MenuItem value="updated">{t("jobDiscoverySortUpdated")}</MenuItem>
<MenuItem value="deadline">Deadline soonest</MenuItem> <MenuItem value="deadline">{t("jobDiscoverySortDeadline")}</MenuItem>
<MenuItem value="title">Title AZ</MenuItem> <MenuItem value="title">{t("jobDiscoverySortTitle")}</MenuItem>
</TextField> </TextField>
</Box> </Box>
) : null} ) : null}
@@ -86,20 +88,20 @@ export default function JobDiscoveryPage() {
<CardContent> <CardContent>
<Chip label={job.sourceName || job.source.toUpperCase()} size="small" color="primary" variant="outlined" sx={{ mb: 1 }} /> <Chip label={job.sourceName || job.source.toUpperCase()} size="small" color="primary" variant="outlined" sx={{ mb: 1 }} />
<Typography variant="h6" sx={{ fontWeight: 800 }}>{job.title}</Typography> <Typography variant="h6" sx={{ fontWeight: 800 }}>{job.title}</Typography>
<Typography color="text.secondary">{[job.company, job.location || "Location not provided"].filter(Boolean).join(" · ")}</Typography> <Typography color="text.secondary">{[job.company, job.location || t("jobDiscoveryLocationMissing")].filter(Boolean).join(" · ")}</Typography>
<Stack spacing={0.25} sx={{ mt: 1 }}> <Stack spacing={0.25} sx={{ mt: 1 }}>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
{job.acquisitionType === "searched" ? "Searched listing" : "Source type unavailable"} {job.acquisitionType === "searched" ? t("jobDiscoverySearchedListing") : t("jobDiscoverySourceTypeMissing")}
{formatDate(job.retrievedAt) ? ` · Retrieved ${formatDate(job.retrievedAt)}` : ""} {formatDate(job.retrievedAt) ? ` · ${t("jobDiscoveryRetrieved", { date: formatDate(job.retrievedAt) ?? "" })}` : ""}
</Typography> </Typography>
{formatDate(job.modifiedAt) ? <Typography variant="caption" color="text.secondary">Listing updated {formatDate(job.modifiedAt)}</Typography> : null} {formatDate(job.modifiedAt) ? <Typography variant="caption" color="text.secondary">{t("jobDiscoveryListingUpdated", { date: formatDate(job.modifiedAt) ?? "" })}</Typography> : null}
{formatDate(job.deadline) ? <Typography variant="body2" color="text.primary">Application deadline: {formatDate(job.deadline)}</Typography> : null} {formatDate(job.deadline) ? <Typography variant="body2" color="text.primary">{t("jobDiscoveryDeadline", { date: formatDate(job.deadline) ?? "" })}</Typography> : null}
<Typography variant="caption" color="text.secondary">Work arrangement not provided by this source</Typography> <Typography variant="caption" color="text.secondary">{t("jobDiscoveryWorkArrangementMissing")}</Typography>
</Stack> </Stack>
</CardContent> </CardContent>
<CardActions sx={{ mt: "auto" }}> <CardActions sx={{ mt: "auto" }}>
<Button href={job.url} target="_blank" rel="noreferrer">View listing</Button> <Button href={job.url} target="_blank" rel="noreferrer">{t("jobDiscoveryViewListing")}</Button>
<Button startIcon={<AddIcon />} onClick={() => navigate("/jobs?add=" + encodeURIComponent(job.url))}>Save to tracker</Button> <Button startIcon={<AddIcon />} onClick={() => navigate("/jobs?add=" + encodeURIComponent(job.url))}>{t("jobDiscoverySaveTracker")}</Button>
</CardActions> </CardActions>
</Card> </Card>
))} ))}