diff --git a/docs/work-programmes/master-progress.md b/docs/work-programmes/master-progress.md index 53fdd8e..2d88c3b 100644 --- a/docs/work-programmes/master-progress.md +++ b/docs/work-programmes/master-progress.md @@ -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. - 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 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 @@ -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. - 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. +- 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. - Full backend: 712/712 tests passed on .NET 9. - Next optimized production build and TypeScript: passed after the Job Workspace/checklist batch. diff --git a/job-tracker-ui/src/components/EmailProviderConnections.tsx b/job-tracker-ui/src/components/EmailProviderConnections.tsx index 75e3437..78427c2 100644 --- a/job-tracker-ui/src/components/EmailProviderConnections.tsx +++ b/job-tracker-ui/src/components/EmailProviderConnections.tsx @@ -4,6 +4,7 @@ import { Box, Button, Checkbox, Chip, Divider, FormControlLabel, Paper, Stack, T import CheckCircleIcon from "@mui/icons-material/CheckCircle"; import { api, getApiErrorMessage } from "../api"; +import { useI18n } from "../i18n/I18nProvider"; import { useToast } from "../toast"; 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. export default function EmailProviderConnections() { const { toast } = useToast(); + const { t } = useI18n(); const [gmailStatus, setGmailStatus] = useState(null); const [microsoftStatus, setMicrosoftStatus] = useState(null); @@ -59,32 +61,32 @@ export default function EmailProviderConnections() { const data = event.data as { source?: string; status?: string; message?: string }; if (data?.source === "jobtracker-gmail-oauth") { if (data.status === "connected") { - toast(data.message || "Gmail connected.", "success"); + toast(data.message || t("emailConnectionsGmailConnected"), "success"); void loadGmailStatus(); } else { - toast(data.message || "Gmail connection failed.", "error"); + toast(data.message || t("emailConnectionsGmailFailed"), "error"); } } else if (data?.source === "jobtracker-microsoft-oauth") { if (data.status === "connected") { - toast(data.message || "Outlook connected.", "success"); + toast(data.message || t("emailConnectionsOutlookConnected"), "success"); void loadMicrosoftStatus(); } else { - toast(data.message || "Outlook connection failed.", "error"); + toast(data.message || t("emailConnectionsOutlookFailed"), "error"); } } }; window.addEventListener("message", onMessage); return () => window.removeEventListener("message", onMessage); - }, [loadGmailStatus, loadMicrosoftStatus, toast]); + }, [loadGmailStatus, loadMicrosoftStatus, t, toast]); const connectViaPopup = async (connectUrlPath: string, popupName: string, providerLabel: string) => { try { const res = await api.get<{ url: string }>(connectUrlPath); 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) { - 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 { await api.delete(path); await reload(); - toast(`${providerLabel} disconnected.`, "success"); + toast(t("emailConnectionsDisconnected", { provider: providerLabel }), "success"); } catch (error) { - toast(getApiErrorMessage(error, `Failed to disconnect ${providerLabel}.`), "error"); + toast(getApiErrorMessage(error, t("emailConnectionsDisconnectFailed", { provider: providerLabel })), "error"); } }; const connectImap = async () => { if (!imapForm.host.trim() || !imapForm.username.trim() || !imapForm.password) { - toast("Host, username, and password are required.", "error"); + toast(t("emailConnectionsRequired"), "error"); return; } setImapConnecting(true); @@ -108,9 +110,9 @@ export default function EmailProviderConnections() { await api.post("/imap/connect", imapForm); setImapForm((prev) => ({ ...prev, password: "" })); await loadImapStatus(); - toast("IMAP account connected.", "success"); + toast(t("emailConnectionsImapConnected"), "success"); } catch (error) { - toast(getApiErrorMessage(error, "Failed to connect IMAP account."), "error"); + toast(getApiErrorMessage(error, t("emailConnectionsImapFailed")), "error"); } finally { setImapConnecting(false); } @@ -118,9 +120,9 @@ export default function EmailProviderConnections() { return ( - Linked email accounts + {t("emailConnectionsTitle")} - Connect a mailbox so recruiter correspondence can be linked to jobs automatically. + {t("emailConnectionsBody")} @@ -146,7 +148,7 @@ export default function EmailProviderConnections() { void disconnect("/imap/connection", loadImapStatus, "IMAP")} @@ -155,28 +157,28 @@ export default function EmailProviderConnections() { setImapForm((prev) => ({ ...prev, host: e.target.value }))} /> setImapForm((prev) => ({ ...prev, port: Number(e.target.value) || 993 }))} /> setImapForm((prev) => ({ ...prev, username: e.target.value }))} sx={{ gridColumn: "1 / -1" }} /> setImapForm((prev) => ({ ...prev, password: e.target.value }))} @@ -185,7 +187,7 @@ export default function EmailProviderConnections() { setImapForm((prev) => ({ ...prev, useSsl: e.target.checked }))} />} - label="Use SSL/TLS" + label={t("emailConnectionsUseSsl")} /> )} @@ -216,6 +218,7 @@ function ProviderRow({ onConnect?: () => void; onDisconnect: () => void; }) { + const { t } = useI18n(); return ( @@ -226,17 +229,17 @@ function ProviderRow({ icon={} color="success" variant="outlined" - label={address || "Connected"} + label={address || t("emailConnectionsConnected")} sx={{ mt: 0.5 }} /> ) : ( - Not connected + {t("emailConnectionsNotConnected")} )} {connected ? ( - + ) : ( - onConnect && + onConnect && )} ); diff --git a/job-tracker-ui/src/email-provider-connections.test.tsx b/job-tracker-ui/src/email-provider-connections.test.tsx index bad4e76..29f00d8 100644 --- a/job-tracker-ui/src/email-provider-connections.test.tsx +++ b/job-tracker-ui/src/email-provider-connections.test.tsx @@ -4,6 +4,7 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { ToastProvider } from "./toast"; import { api } from "./api"; +import { I18nProvider } from "./i18n/I18nProvider"; import EmailProviderConnections from "./components/EmailProviderConnections"; jest.mock("./api", () => ({ @@ -21,7 +22,7 @@ const mockedApi = api as jest.Mocked; function renderComponent() { return render( - + , ); } @@ -29,6 +30,18 @@ function renderComponent() { describe("EmailProviderConnections", () => { beforeEach(() => { 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 () => { diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 2d022d7..f2cc217 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -83,6 +83,29 @@ export const translations = { languageNorwegianBokmal: "Norsk bokmål", discoverJobs: "Discover jobs", 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 A–Z", + 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", jobDetailsWorkspaceSubtitle: "Manage this application, its documents, timeline, and correspondence.", operations: "Operations", @@ -99,6 +122,33 @@ export const translations = { operationsFailed: "Failed: {category}", gmailReviewQueue: "Gmail review queue", 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", careerWorkspace: "Career Workspace", cvBuilder: "CV Builder", @@ -1951,6 +2001,29 @@ export const translations = { languageNorwegianBokmal: "Norsk bokmål", discoverJobs: "Finn jobber", 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", jobDetailsWorkspaceSubtitle: "Administrer søknaden, dokumentene, tidslinjen og korrespondansen.", operations: "Operasjoner", @@ -1967,6 +2040,33 @@ export const translations = { operationsFailed: "Mislyktes: {category}", gmailReviewQueue: "Gmail-gjennomgang", 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", careerWorkspace: "Karriereområde", cvBuilder: "CV-bygger", diff --git a/job-tracker-ui/src/job-discovery.test.tsx b/job-tracker-ui/src/job-discovery.test.tsx index c537de7..c776933 100644 --- a/job-tracker-ui/src/job-discovery.test.tsx +++ b/job-tracker-ui/src/job-discovery.test.tsx @@ -3,16 +3,22 @@ import "@testing-library/jest-dom"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { api } from "./api"; +import { I18nProvider } from "./i18n/I18nProvider"; import JobDiscoveryPage from "./views/JobDiscoveryPage"; jest.mock("./api", () => ({ api: { get: jest.fn() } })); const mockedApi = api as jest.Mocked; +function renderPage() { + return render(); +} + +beforeEach(() => window.localStorage.clear()); afterEach(() => jest.clearAllMocks()); 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); - render(); + renderPage(); fireEvent.change(screen.getByLabelText("Role or company"), { target: { value: "backend" } }); 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 () => { mockedApi.get.mockResolvedValue({ data: [] } as any); - render(); + renderPage(); expect(screen.getByText("Search recent active vacancies by role, company, or municipality.")).toBeInTheDocument(); 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 () => { mockedApi.get.mockRejectedValueOnce(new Error("offline")).mockResolvedValueOnce({ data: [] } as any); - render(); + renderPage(); fireEvent.change(screen.getByLabelText("Role or company"), { target: { value: " utvikler " } }); 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: "1", title: "Analyst", url: "https://arbeidsplassen.nav.no/1", source: "nav", countryCode: "NO" }, ] } as any); - render(); + renderPage(); fireEvent.click(screen.getByRole("button", { name: "Search NAV" })); 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); 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(); +}); diff --git a/job-tracker-ui/src/views/ConnectedAccountsPage.tsx b/job-tracker-ui/src/views/ConnectedAccountsPage.tsx index d5fed1c..e629719 100644 --- a/job-tracker-ui/src/views/ConnectedAccountsPage.tsx +++ b/job-tracker-ui/src/views/ConnectedAccountsPage.tsx @@ -4,18 +4,20 @@ import { Box, Button, Paper, Typography } from "@mui/material"; import { useNavigate } from "react-router-dom"; import EmailProviderConnections from "../components/EmailProviderConnections"; +import { useI18n } from "../i18n/I18nProvider"; export default function ConnectedAccountsPage() { const navigate = useNavigate(); + const { t } = useI18n(); return ( - Connected accounts + {t("connectedAccounts")} - Connect the inboxes Jobbjakt may use to identify recruiter correspondence. Connecting an inbox never sends a message on your behalf. + {t("connectedAccountsBody")} - + diff --git a/job-tracker-ui/src/views/JobDiscoveryPage.tsx b/job-tracker-ui/src/views/JobDiscoveryPage.tsx index 0edbd5b..22a979a 100644 --- a/job-tracker-ui/src/views/JobDiscoveryPage.tsx +++ b/job-tracker-ui/src/views/JobDiscoveryPage.tsx @@ -4,6 +4,7 @@ import SearchIcon from "@mui/icons-material/Search"; import AddIcon from "@mui/icons-material/Add"; import { useNavigate } from "react-router-dom"; import { api } from "../api"; +import { useI18n } from "../i18n/I18nProvider"; type DiscoveredJob = { id: string; @@ -20,11 +21,11 @@ type DiscoveredJob = { countryCode: string; }; -const formatDate = (value?: string) => value ? new Date(value).toLocaleDateString() : null; type SortOrder = "updated" | "deadline" | "title"; export default function JobDiscoveryPage() { const navigate = useNavigate(); + const { language, t } = useI18n(); const [query, setQuery] = useState(""); const [location, setLocation] = useState(""); const [jobs, setJobs] = useState([]); @@ -39,7 +40,7 @@ export default function JobDiscoveryPage() { try { const response = await api.get("/job-discovery/search", { params: { q: nextQuery || undefined, location: nextLocation || undefined } }); setJobs(response.data ?? []); - } catch { setError("NAV job discovery is temporarily unavailable."); } + } catch { setError(t("jobDiscoveryUnavailable")); } finally { setLoading(false); } }; @@ -57,26 +58,27 @@ export default function JobDiscoveryPage() { } return new Date(right.modifiedAt || 0).getTime() - new Date(left.modifiedAt || 0).getTime(); }), [jobs, sortOrder]); + const formatDate = (value?: string) => value ? new Date(value).toLocaleDateString(language === "nb" ? "nb-NO" : "en") : null; return ( - setQuery(event.target.value)} /> - setLocation(event.target.value)} /> - + setQuery(event.target.value)} /> + setLocation(event.target.value)} /> + - Official Norwegian vacancies from NAV. FINN, Indeed and LinkedIn jobs can still be captured by URL. - {error ? void runSearch(lastSearch.query, lastSearch.location)} disabled={loading}>Retry}>{error} : null} - {!loading && !hasSearched ? Search recent active vacancies by role, company, or municipality. : null} - {!loading && hasSearched && !error && jobs.length === 0 ? No active vacancies matched this search. Try a broader role, company, or municipality. : null} - {loading ? Searching NAV vacancies… : null} + {t("jobDiscoverySourceHelp")} + {error ? void runSearch(lastSearch.query, lastSearch.location)} disabled={loading}>{t("retry")}}>{error} : null} + {!loading && !hasSearched ? {t("jobDiscoveryGuidance")} : null} + {!loading && hasSearched && !error && jobs.length === 0 ? {t("jobDiscoveryNoResults")} : null} + {loading ? {t("jobDiscoverySearching")} : null} {jobs.length > 0 ? ( - {jobs.length} {jobs.length === 1 ? "vacancy" : "vacancies"} found - setSortOrder(event.target.value as SortOrder)} sx={{ minWidth: 190 }}> - Recently updated - Deadline soonest - Title A–Z + {t(jobs.length === 1 ? "jobDiscoveryResultOne" : "jobDiscoveryResultMany", { count: jobs.length })} + setSortOrder(event.target.value as SortOrder)} sx={{ minWidth: 190 }}> + {t("jobDiscoverySortUpdated")} + {t("jobDiscoverySortDeadline")} + {t("jobDiscoverySortTitle")} ) : null} @@ -86,20 +88,20 @@ export default function JobDiscoveryPage() { {job.title} - {[job.company, job.location || "Location not provided"].filter(Boolean).join(" · ")} + {[job.company, job.location || t("jobDiscoveryLocationMissing")].filter(Boolean).join(" · ")} - {job.acquisitionType === "searched" ? "Searched listing" : "Source type unavailable"} - {formatDate(job.retrievedAt) ? ` · Retrieved ${formatDate(job.retrievedAt)}` : ""} + {job.acquisitionType === "searched" ? t("jobDiscoverySearchedListing") : t("jobDiscoverySourceTypeMissing")} + {formatDate(job.retrievedAt) ? ` · ${t("jobDiscoveryRetrieved", { date: formatDate(job.retrievedAt) ?? "" })}` : ""} - {formatDate(job.modifiedAt) ? Listing updated {formatDate(job.modifiedAt)} : null} - {formatDate(job.deadline) ? Application deadline: {formatDate(job.deadline)} : null} - Work arrangement not provided by this source + {formatDate(job.modifiedAt) ? {t("jobDiscoveryListingUpdated", { date: formatDate(job.modifiedAt) ?? "" })} : null} + {formatDate(job.deadline) ? {t("jobDiscoveryDeadline", { date: formatDate(job.deadline) ?? "" })} : null} + {t("jobDiscoveryWorkArrangementMissing")} - - + + ))}