fix(i18n): localize discovery and accounts
This commit is contained in:
@@ -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 () => {
|
||||||
|
|||||||
@@ -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 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",
|
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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 A–Z</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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user