From 464ba7f4d938804e86c2472b18c1bcc086b9786a Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sat, 29 Aug 2026 22:30:08 +0200 Subject: [PATCH] feat(i18n): localize public landing --- docs/work-programmes/master-progress.md | 3 + .../src/LandingPage.authRedirect.test.tsx | 3 +- job-tracker-ui/src/i18n/translations.ts | 136 ++++++++++++++++++ job-tracker-ui/src/landing-page.test.tsx | 14 +- job-tracker-ui/src/layout/AppShell.tsx | 2 +- job-tracker-ui/src/views/LandingPage.tsx | 113 ++++++++------- 6 files changed, 217 insertions(+), 54 deletions(-) diff --git a/docs/work-programmes/master-progress.md b/docs/work-programmes/master-progress.md index 29c8ce4..1fecc42 100644 --- a/docs/work-programmes/master-progress.md +++ b/docs/work-programmes/master-progress.md @@ -32,6 +32,8 @@ Updated: 2026-08-29 - 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. - Localized the consolidated Job Email hub across provider status, search/filter controls, saved drafts, compose/reply flows, destructive confirmations, send safety states, message metadata, pagination, and failure handling. Persisted subjects, bodies, company names, roles, addresses, and provider names remain unchanged. - Corrected the Job Email Direction and Link State controls so their visible labels are programmatically associated with the selects, and removed the remaining mojibake separators/loading text from the workflow. +- Localized the complete public landing experience—hero, previews, feature cards, workflow, plan capabilities, CTAs, accessibility text, and footer—while retaining the server-aligned Free/Pro plan structure and commercial-terms safeguards. +- Reused the shared EN/NO language control on the unauthenticated landing page, so language can be changed before sign-in and persists through the existing locale mechanism. ### In progress @@ -82,6 +84,7 @@ Updated: 2026-08-29 - Job Email focused verification: 16/16 passed, including a Bokmål regression that preserves correspondence content; TypeScript passed. - Full frontend after the Job Email localization batch: 62 suites, 263/263 tests passed. - Next optimized production build and integrated TypeScript check passed after the Job Email localization batch. +- Public landing focused verification: 2 suites, 3/3 passed, including live switching to Bokmål and preservation of protected-route sign-in intent; 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/LandingPage.authRedirect.test.tsx b/job-tracker-ui/src/LandingPage.authRedirect.test.tsx index ed707f2..2eb3ec9 100644 --- a/job-tracker-ui/src/LandingPage.authRedirect.test.tsx +++ b/job-tracker-ui/src/LandingPage.authRedirect.test.tsx @@ -5,6 +5,7 @@ import userEvent from "@testing-library/user-event"; import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; import { api } from "./api"; import LandingPage from "./views/LandingPage"; +import { I18nProvider } from "./i18n/I18nProvider"; jest.mock("./api", () => ({ api: { @@ -33,7 +34,7 @@ describe("LandingPage forwards deep-link intent to /login", () => { render( - } /> + } /> } /> , diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 4343a20..58037a7 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -182,6 +182,74 @@ export const translations = { languageNorwegianBokmal: "Norsk bokmål", discoverJobs: "Discover jobs", discoverJobsSubtitle: "Search official job-board feeds and save opportunities to your tracker.", + landingSignIn: "Sign in", + landingSignInArrow: "Sign in →", + landingEyebrow: "AI-ASSISTED JOB SEARCH WORKSPACE", + landingHeroBefore: "Run your job search without losing", + landingHeroAccent: "the thread", + landingHeroBody: "Import a role, tailor your CV, track every application, and keep recruiter correspondence tied to the right job — all in one focused workspace. Assistive, never autonomous: you approve every draft.", + landingGetStarted: "Get started", + landingSeeFeatures: "See features", + landingPreviewEyebrow: "SEE IT IN ACTION", + landingPreviewTitle: "Your whole search, at a glance", + landingDashboardAlt: "Jobbjakt dashboard — KPIs, funnel, response trend and follow-ups", + landingPipelineAlt: "Drag-and-drop pipeline board", + landingWorkspaceAlt: "Per-job workspace with match score, correspondence and attachments", + landingPreviewCaption: "Interface preview.", + landingFeaturesEyebrow: "WHAT IT DOES", + landingFeaturesTitle: "One workspace for the whole search", + landingFeaturesBody: "Everything from a single import to the final offer — no more spreadsheets and scattered inboxes.", + landingFeaturePipeline: "Centralized pipeline", + landingFeaturePipelineBody: "Track every application across Applied, Waiting, Interview, Offer, Rejected and Ghosted — drag to update.", + landingFeatureFollowups: "Smart follow-ups", + landingFeatureFollowupsBody: "Reminders surface what needs attention next; Pro can help draft a response for you to review.", + landingFeatureMatch: "Honest CV match", + landingFeatureMatchBody: "A deterministic keyword-coverage score with matched vs missing skills — not an opaque black box.", + landingFeatureEmail: "Email correspondence", + landingFeatureEmailBody: "Keep linked Gmail and Microsoft messages in the context of the right application.", + landingFeatureDocuments: "Attachments & docs", + landingFeatureDocumentsBody: "Keep resumes, cover letters and portfolios versioned per application, right where you need them.", + landingFeatureInsights: "Dashboard & insights", + landingFeatureInsightsBody: "Response rates, funnel, time-in-stage and skill demand across your whole search.", + landingHowEyebrow: "HOW IT WORKS", + landingHowTitle: "From a link to an offer", + landingStepImport: "Import", + landingStepImportBody: "Paste a job URL or use the bookmarklet — we parse the role into structured fields.", + landingStepMatch: "Match", + landingStepMatchBody: "See how your CV covers the role: matched keywords and the gaps to close.", + landingStepTailor: "Tailor", + landingStepTailorBody: "With Pro, AI can draft a tailored CV and cover letter — you review every word before it goes out.", + landingStepTrack: "Track", + landingStepTrackBody: "Move it through the pipeline; documents, notes and history stay attached.", + landingStepFollowUp: "Follow up", + landingStepFollowUpBody: "Linked email threads and reminders keep momentum with grounded replies.", + landingStepAnalyze: "Analyze", + landingStepAnalyzeBody: "See what's working — response rate, funnel and time-in-stage — and focus your effort.", + landingPlansEyebrow: "PLANS", + landingPlansTitle: "Two clear plans", + landingPlansBody: "Free covers the core job search. Pro adds AI assistance and Pro CV themes.", + landingAiAssistance: "AI ASSISTANCE", + landingPlanFree: "Free", + landingPlanFreeSummary: "Core job-search organisation without AI generation.", + landingPlanFreeFeature1: "Track applications in table and Kanban views", + landingPlanFreeFeature2: "Keep notes, correspondence and documents with each job", + landingPlanFreeFeature3: "Edit your Career Profile and CVs manually", + landingPlanFreeFeature4: "Use the deterministic CV-to-job match score", + landingPlanFreeFeature5: "Export and retain your own data", + landingPlanFreeCta: "Create a Free account", + landingPlanPro: "Pro", + landingPlanProSummary: "AI assistance and Pro CV themes, with you in control.", + landingPlanProFeature1: "Everything in Free", + landingPlanProFeature2: "AI-assisted CV and cover-letter drafting", + landingPlanProFeature3: "AI job analysis and application strategy", + landingPlanProFeature4: "AI interview and follow-up preparation", + landingPlanProFeature5: "AI-assisted Career Profile import and rewriting", + landingPlanProFeature6: "Pro CV themes", + landingPlanProCta: "Sign in to view Pro", + landingCommercialTerms: "Commercial terms appear only in configured checkout. Your applications, documents and exports remain accessible if your plan changes.", + landingReadyTitle: "Ready to organize your search?", + landingReadyBody: "Sign in to start tracking applications, tailoring CVs, and following up with intent.", + landingFooter: "© {year} Jobbjakt — a focused workspace for the modern job search.", jobDiscoveryRoleCompany: "Role or company", jobDiscoveryMunicipality: "Municipality", jobDiscoverySearchNav: "Search NAV", @@ -2199,6 +2267,74 @@ export const translations = { languageNorwegianBokmal: "Norsk bokmål", discoverJobs: "Finn jobber", discoverJobsSubtitle: "Søk i offisielle jobbportaler og lagre muligheter i oversikten din.", + landingSignIn: "Logg inn", + landingSignInArrow: "Logg inn →", + landingEyebrow: "AI-STØTTET ARBEIDSOMRÅDE FOR JOBBSØK", + landingHeroBefore: "Hold oversikten gjennom hele", + landingHeroAccent: "jobbsøket", + landingHeroBody: "Importer en stilling, tilpass CV-en, følg hver søknad og hold korrespondanse med rekrutterere knyttet til riktig jobb — alt i ett fokusert arbeidsområde. Assisterende, aldri autonomt: du godkjenner hvert utkast.", + landingGetStarted: "Kom i gang", + landingSeeFeatures: "Se funksjoner", + landingPreviewEyebrow: "SE DET I BRUK", + landingPreviewTitle: "Hele jobbsøket med ett blikk", + landingDashboardAlt: "Jobbjakt-oversikt med nøkkeltall, trakt, svartrend og oppfølginger", + landingPipelineAlt: "Dra-og-slipp-tavle for søknadsprosessen", + landingWorkspaceAlt: "Arbeidsområde per jobb med samsvar, korrespondanse og vedlegg", + landingPreviewCaption: "Forhåndsvisning av grensesnittet.", + landingFeaturesEyebrow: "DETTE FÅR DU", + landingFeaturesTitle: "Ett arbeidsområde for hele jobbsøket", + landingFeaturesBody: "Alt fra første import til endelig tilbud — uten regneark og spredte innbokser.", + landingFeaturePipeline: "Samlet søknadsprosess", + landingFeaturePipelineBody: "Følg alle søknader gjennom Søkt, Venter, Intervju, Tilbud, Avslått og Ingen svar — dra for å oppdatere.", + landingFeatureFollowups: "Smarte oppfølginger", + landingFeatureFollowupsBody: "Påminnelser viser hva som trenger oppmerksomhet; Pro kan hjelpe med et svarutkast du gjennomgår.", + landingFeatureMatch: "Ærlig CV-samsvar", + landingFeatureMatchBody: "En deterministisk dekning av nøkkelord med samsvarende og manglende ferdigheter — ikke en ugjennomsiktig svart boks.", + landingFeatureEmail: "E-postkorrespondanse", + landingFeatureEmailBody: "Behold tilknyttede Gmail- og Microsoft-meldinger i sammenheng med riktig søknad.", + landingFeatureDocuments: "Vedlegg og dokumenter", + landingFeatureDocumentsBody: "Behold CV-er, søknadsbrev og porteføljer versjonert per søknad, der du trenger dem.", + landingFeatureInsights: "Oversikt og innsikt", + landingFeatureInsightsBody: "Svarprosent, trakt, tid i status og etterspurte ferdigheter gjennom hele jobbsøket.", + landingHowEyebrow: "SLIK FUNGERER DET", + landingHowTitle: "Fra lenke til tilbud", + landingStepImport: "Importer", + landingStepImportBody: "Lim inn en stillingslenke eller bruk bokmerket — vi leser rollen inn i strukturerte felt.", + landingStepMatch: "Sammenlign", + landingStepMatchBody: "Se hvordan CV-en dekker rollen: samsvarende nøkkelord og hullene du bør lukke.", + landingStepTailor: "Tilpass", + landingStepTailorBody: "Med Pro kan AI lage utkast til tilpasset CV og søknadsbrev — du gjennomgår hvert ord før det sendes.", + landingStepTrack: "Følg opp", + landingStepTrackBody: "Flytt søknaden gjennom prosessen; dokumenter, notater og historikk forblir tilknyttet.", + landingStepFollowUp: "Hold kontakten", + landingStepFollowUpBody: "Tilknyttede e-posttråder og påminnelser holder fremdriften med faktabaserte svar.", + landingStepAnalyze: "Analyser", + landingStepAnalyzeBody: "Se hva som virker — svarprosent, trakt og tid i status — og fokuser innsatsen.", + landingPlansEyebrow: "ABONNEMENTER", + landingPlansTitle: "To tydelige alternativer", + landingPlansBody: "Free dekker det grunnleggende jobbsøket. Pro legger til AI-støtte og Pro-temaer for CV.", + landingAiAssistance: "AI-STØTTE", + landingPlanFree: "Free", + landingPlanFreeSummary: "Organiser jobbsøket uten AI-generering.", + landingPlanFreeFeature1: "Følg søknader i tabell- og Kanban-visning", + landingPlanFreeFeature2: "Behold notater, korrespondanse og dokumenter med hver jobb", + landingPlanFreeFeature3: "Rediger karriereprofilen og CV-er manuelt", + landingPlanFreeFeature4: "Bruk det deterministiske samsvaret mellom CV og jobb", + landingPlanFreeFeature5: "Eksporter og behold dine egne data", + landingPlanFreeCta: "Opprett en Free-konto", + landingPlanPro: "Pro", + landingPlanProSummary: "AI-støtte og Pro-temaer for CV, med deg i kontroll.", + landingPlanProFeature1: "Alt i Free", + landingPlanProFeature2: "AI-støttede utkast til CV og søknadsbrev", + landingPlanProFeature3: "AI-analyse av jobber og søknadsstrategi", + landingPlanProFeature4: "AI-støtte til intervju og oppfølging", + landingPlanProFeature5: "AI-støttet import og omskriving av karriereprofil", + landingPlanProFeature6: "Pro-temaer for CV", + landingPlanProCta: "Logg inn for å se Pro", + landingCommercialTerms: "Kommersielle vilkår vises bare i konfigurert betaling. Søknader, dokumenter og eksporter forblir tilgjengelige hvis abonnementet endres.", + landingReadyTitle: "Klar til å organisere jobbsøket?", + landingReadyBody: "Logg inn for å følge søknader, tilpasse CV-er og jobbe målrettet med oppfølging.", + landingFooter: "© {year} Jobbjakt — et fokusert arbeidsområde for moderne jobbsøk.", jobDiscoveryRoleCompany: "Rolle eller bedrift", jobDiscoveryMunicipality: "Kommune", jobDiscoverySearchNav: "Søk i NAV", diff --git a/job-tracker-ui/src/landing-page.test.tsx b/job-tracker-ui/src/landing-page.test.tsx index 12bac2b..3a255ad 100644 --- a/job-tracker-ui/src/landing-page.test.tsx +++ b/job-tracker-ui/src/landing-page.test.tsx @@ -4,6 +4,7 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { api } from "./api"; +import { I18nProvider } from "./i18n/I18nProvider"; import LandingPage from "./views/LandingPage"; const mockNavigate = jest.fn(); @@ -12,17 +13,18 @@ jest.mock("react-router-dom", () => ({ useNavigate: () => mockNavigate, })); -jest.mock("./api", () => ({ api: { get: jest.fn() } })); +jest.mock("./api", () => ({ api: { get: jest.fn(), put: jest.fn().mockResolvedValue({ data: {} }) } })); beforeEach(() => { jest.clearAllMocks(); + window.localStorage.clear(); (api.get as jest.Mock).mockRejectedValue({ response: { status: 401 } }); }); test("shows exactly Free and Pro without invented commercial terms", async () => { render( - + , ); @@ -37,3 +39,11 @@ test("shows exactly Free and Pro without invented commercial terms", async () => fireEvent.click(screen.getByRole("button", { name: "Sign in to view Pro" })); expect(mockNavigate).toHaveBeenCalledWith("/login", { state: { from: "/settings" } }); }); + +test("switches the public landing page to Norwegian Bokmål", async () => { + render(); + + fireEvent.click(await screen.findByRole("button", { name: "Norsk" })); + expect(await screen.findByRole("heading", { name: "To tydelige alternativer" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Opprett en Free-konto" })).toBeInTheDocument(); +}); diff --git a/job-tracker-ui/src/layout/AppShell.tsx b/job-tracker-ui/src/layout/AppShell.tsx index 2cf0b73..25490a6 100644 --- a/job-tracker-ui/src/layout/AppShell.tsx +++ b/job-tracker-ui/src/layout/AppShell.tsx @@ -556,7 +556,7 @@ export default function AppShell({ ); } -function StackLanguageToggle({ language, setLanguage }: { language: "en" | "nb"; setLanguage: (language: "en" | "nb") => void }) { +export function StackLanguageToggle({ language, setLanguage }: { language: "en" | "nb"; setLanguage: (language: "en" | "nb") => void }) { return ( diff --git a/job-tracker-ui/src/views/LandingPage.tsx b/job-tracker-ui/src/views/LandingPage.tsx index 19d87e3..e8bd173 100644 --- a/job-tracker-ui/src/views/LandingPage.tsx +++ b/job-tracker-ui/src/views/LandingPage.tsx @@ -14,30 +14,34 @@ import InsightsIcon from "@mui/icons-material/InsightsOutlined"; import { api } from "../api"; import GradientButton from "../components/GradientButton"; import { PUBLIC_PLANS, PublicPlanId } from "../planCatalog"; +import { useI18n } from "../i18n/I18nProvider"; +import type { TranslationKey } from "../i18n/translations"; +import { StackLanguageToggle } from "../layout/AppShell"; const BRAND_DARK = "#0b1020"; const BRAND_PANEL = "#111a33"; -const FEATURES: { icon: React.ReactNode; title: string; body: string }[] = [ - { icon: , title: "Centralized pipeline", body: "Track every application across Applied, Waiting, Interview, Offer, Rejected and Ghosted — drag to update." }, - { icon: , title: "Smart follow-ups", body: "Reminders surface what needs attention next; Pro can help draft a response for you to review." }, - { icon: , title: "Honest CV match", body: "A deterministic keyword-coverage score with matched vs missing skills — not an opaque black box." }, - { icon: , title: "Email correspondence", body: "Keep linked Gmail and Microsoft messages in the context of the right application." }, - { icon: , title: "Attachments & docs", body: "Keep resumes, cover letters and portfolios versioned per application, right where you need them." }, - { icon: , title: "Dashboard & insights", body: "Response rates, funnel, time-in-stage and skill demand across your whole search." }, +const FEATURES: { icon: React.ReactNode; title: TranslationKey; body: TranslationKey }[] = [ + { icon: , title: "landingFeaturePipeline", body: "landingFeaturePipelineBody" }, + { icon: , title: "landingFeatureFollowups", body: "landingFeatureFollowupsBody" }, + { icon: , title: "landingFeatureMatch", body: "landingFeatureMatchBody" }, + { icon: , title: "landingFeatureEmail", body: "landingFeatureEmailBody" }, + { icon: , title: "landingFeatureDocuments", body: "landingFeatureDocumentsBody" }, + { icon: , title: "landingFeatureInsights", body: "landingFeatureInsightsBody" }, ]; -const STEPS: { n: number; title: string; body: string }[] = [ - { n: 1, title: "Import", body: "Paste a job URL or use the bookmarklet — we parse the role into structured fields." }, - { n: 2, title: "Match", body: "See how your CV covers the role: matched keywords and the gaps to close." }, - { n: 3, title: "Tailor", body: "With Pro, AI can draft a tailored CV and cover letter — you review every word before it goes out." }, - { n: 4, title: "Track", body: "Move it through the pipeline; documents, notes and history stay attached." }, - { n: 5, title: "Follow up", body: "Linked email threads and reminders keep momentum with grounded replies." }, - { n: 6, title: "Analyze", body: "See what's working — response rate, funnel and time-in-stage — and focus your effort." }, +const STEPS: { n: number; title: TranslationKey; body: TranslationKey }[] = [ + { n: 1, title: "landingStepImport", body: "landingStepImportBody" }, + { n: 2, title: "landingStepMatch", body: "landingStepMatchBody" }, + { n: 3, title: "landingStepTailor", body: "landingStepTailorBody" }, + { n: 4, title: "landingStepTrack", body: "landingStepTrackBody" }, + { n: 5, title: "landingStepFollowUp", body: "landingStepFollowUpBody" }, + { n: 6, title: "landingStepAnalyze", body: "landingStepAnalyzeBody" }, ]; export default function LandingPage() { const navigate = useNavigate(); + const { language, setLanguage, t } = useI18n(); const location = useLocation() as { state?: { from?: string } }; const [checking, setChecking] = useState(true); @@ -66,7 +70,7 @@ export default function LandingPage() { if (checking) { return ( - Loading… + {t("loading")} ); } @@ -76,6 +80,13 @@ export default function LandingPage() { WebkitBackgroundClip: "text", WebkitTextFillColor: "transparent", } as const; + const plans = PUBLIC_PLANS.map((plan) => ({ + ...plan, + name: t(plan.id === "free" ? "landingPlanFree" : "landingPlanPro"), + summary: t(plan.id === "free" ? "landingPlanFreeSummary" : "landingPlanProSummary"), + features: plan.features.map((_, index) => t(`landingPlan${plan.id === "free" ? "Free" : "Pro"}Feature${index + 1}` as TranslationKey)), + cta: t(plan.id === "free" ? "landingPlanFreeCta" : "landingPlanProCta"), + })); return ( @@ -87,9 +98,12 @@ export default function LandingPage() { Jobbjakt - - Sign in - + + + + {t("landingSignIn")} + + @@ -99,21 +113,20 @@ export default function LandingPage() { - AI-ASSISTED JOB SEARCH WORKSPACE + {t("landingEyebrow")} - Run your job search without losing the thread. + {t("landingHeroBefore")} {t("landingHeroAccent")}. - Import a role, tailor your CV, track every application, and keep recruiter correspondence tied to the - right job — all in one focused workspace. Assistive, never autonomous: you approve every draft. + {t("landingHeroBody")} - Get started + {t("landingGetStarted")} @@ -126,38 +139,38 @@ export default function LandingPage() { {/* Product preview */} - SEE IT IN ACTION - Your whole search, at a glance + {t("landingPreviewEyebrow")} + {t("landingPreviewTitle")} - + - + - + - Interface preview. + {t("landingPreviewCaption")} {/* Features */} - WHAT IT DOES - One workspace for the whole search + {t("landingFeaturesEyebrow")} + {t("landingFeaturesTitle")} - Everything from a single import to the final offer — no more spreadsheets and scattered inboxes. + {t("landingFeaturesBody")} {FEATURES.map((f) => ( {f.icon} - {f.title} - {f.body} + {t(f.title)} + {t(f.body)} ))} @@ -167,15 +180,15 @@ export default function LandingPage() { - HOW IT WORKS - From a link to an offer + {t("landingHowEyebrow")} + {t("landingHowTitle")} {STEPS.map((s) => ( {s.n} - {s.title} - {s.body} + {t(s.title)} + {t(s.body)} ))} @@ -185,14 +198,14 @@ export default function LandingPage() { {/* Plans */} - PLANS - Two clear plans + {t("landingPlansEyebrow")} + {t("landingPlansTitle")} - Free covers the core job search. Pro adds AI assistance and Pro CV themes. + {t("landingPlansBody")} - {PUBLIC_PLANS.map((plan) => ( + {plans.map((plan) => ( {plan.id === "pro" && ( - AI ASSISTANCE + {t("landingAiAssistance")} )} {plan.name} @@ -237,7 +250,7 @@ export default function LandingPage() { ))} - Commercial terms appear only in configured checkout. Your applications, documents and exports remain accessible if your plan changes. + {t("landingCommercialTerms")} @@ -245,11 +258,11 @@ export default function LandingPage() { - Ready to organize your search? - Sign in to start tracking applications, tailoring CVs, and following up with intent. + {t("landingReadyTitle")} + {t("landingReadyBody")} - Sign in → + {t("landingSignInArrow")} @@ -258,8 +271,8 @@ export default function LandingPage() { - © {new Date().getFullYear()} Jobbjakt — a focused workspace for the modern job search. - + {t("landingFooter", { year: new Date().getFullYear() })} +