feat(i18n): localize public landing

This commit is contained in:
cesnimda
2026-08-29 22:30:08 +02:00
parent e9f8653d19
commit 464ba7f4d9
6 changed files with 217 additions and 54 deletions
+3
View File
@@ -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 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. - 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. - 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 ### 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. - 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. - 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. - 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. - 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.
@@ -5,6 +5,7 @@ import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import { api } from "./api"; import { api } from "./api";
import LandingPage from "./views/LandingPage"; import LandingPage from "./views/LandingPage";
import { I18nProvider } from "./i18n/I18nProvider";
jest.mock("./api", () => ({ jest.mock("./api", () => ({
api: { api: {
@@ -33,7 +34,7 @@ describe("LandingPage forwards deep-link intent to /login", () => {
render( render(
<MemoryRouter initialEntries={[{ pathname: "/", state: { from: "/jobs/42" } }]}> <MemoryRouter initialEntries={[{ pathname: "/", state: { from: "/jobs/42" } }]}>
<Routes> <Routes>
<Route path="/" element={<LandingPage />} /> <Route path="/" element={<I18nProvider><LandingPage /></I18nProvider>} />
<Route path="/login" element={<LoginStub />} /> <Route path="/login" element={<LoginStub />} />
</Routes> </Routes>
</MemoryRouter>, </MemoryRouter>,
+136
View File
@@ -182,6 +182,74 @@ 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.",
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", jobDiscoveryRoleCompany: "Role or company",
jobDiscoveryMunicipality: "Municipality", jobDiscoveryMunicipality: "Municipality",
jobDiscoverySearchNav: "Search NAV", jobDiscoverySearchNav: "Search NAV",
@@ -2199,6 +2267,74 @@ 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.",
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", jobDiscoveryRoleCompany: "Rolle eller bedrift",
jobDiscoveryMunicipality: "Kommune", jobDiscoveryMunicipality: "Kommune",
jobDiscoverySearchNav: "Søk i NAV", jobDiscoverySearchNav: "Søk i NAV",
+12 -2
View File
@@ -4,6 +4,7 @@ import { fireEvent, render, screen } 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 LandingPage from "./views/LandingPage"; import LandingPage from "./views/LandingPage";
const mockNavigate = jest.fn(); const mockNavigate = jest.fn();
@@ -12,17 +13,18 @@ jest.mock("react-router-dom", () => ({
useNavigate: () => mockNavigate, useNavigate: () => mockNavigate,
})); }));
jest.mock("./api", () => ({ api: { get: jest.fn() } })); jest.mock("./api", () => ({ api: { get: jest.fn(), put: jest.fn().mockResolvedValue({ data: {} }) } }));
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
window.localStorage.clear();
(api.get as jest.Mock).mockRejectedValue({ response: { status: 401 } }); (api.get as jest.Mock).mockRejectedValue({ response: { status: 401 } });
}); });
test("shows exactly Free and Pro without invented commercial terms", async () => { test("shows exactly Free and Pro without invented commercial terms", async () => {
render( render(
<MemoryRouter> <MemoryRouter>
<LandingPage /> <I18nProvider><LandingPage /></I18nProvider>
</MemoryRouter>, </MemoryRouter>,
); );
@@ -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" })); fireEvent.click(screen.getByRole("button", { name: "Sign in to view Pro" }));
expect(mockNavigate).toHaveBeenCalledWith("/login", { state: { from: "/settings" } }); expect(mockNavigate).toHaveBeenCalledWith("/login", { state: { from: "/settings" } });
}); });
test("switches the public landing page to Norwegian Bokmål", async () => {
render(<MemoryRouter><I18nProvider><LandingPage /></I18nProvider></MemoryRouter>);
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();
});
+1 -1
View File
@@ -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 ( return (
<ButtonGroup size="small" variant="outlined" aria-label="Application language" sx={{ flex: "0 0 auto", "& .MuiButton-root": { minWidth: 36, px: 0.6, fontWeight: 800 } }}> <ButtonGroup size="small" variant="outlined" aria-label="Application language" sx={{ flex: "0 0 auto", "& .MuiButton-root": { minWidth: 36, px: 0.6, fontWeight: 800 } }}>
<Button aria-label="English" aria-pressed={language === "en"} variant={language === "en" ? "contained" : "outlined"} onClick={() => setLanguage("en")}>EN</Button> <Button aria-label="English" aria-pressed={language === "en"} variant={language === "en" ? "contained" : "outlined"} onClick={() => setLanguage("en")}>EN</Button>
+61 -48
View File
@@ -14,30 +14,34 @@ import InsightsIcon from "@mui/icons-material/InsightsOutlined";
import { api } from "../api"; import { api } from "../api";
import GradientButton from "../components/GradientButton"; import GradientButton from "../components/GradientButton";
import { PUBLIC_PLANS, PublicPlanId } from "../planCatalog"; 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_DARK = "#0b1020";
const BRAND_PANEL = "#111a33"; const BRAND_PANEL = "#111a33";
const FEATURES: { icon: React.ReactNode; title: string; body: string }[] = [ const FEATURES: { icon: React.ReactNode; title: TranslationKey; body: TranslationKey }[] = [
{ icon: <DashboardIcon />, title: "Centralized pipeline", body: "Track every application across Applied, Waiting, Interview, Offer, Rejected and Ghosted — drag to update." }, { icon: <DashboardIcon />, title: "landingFeaturePipeline", body: "landingFeaturePipelineBody" },
{ icon: <AlarmIcon />, title: "Smart follow-ups", body: "Reminders surface what needs attention next; Pro can help draft a response for you to review." }, { icon: <AlarmIcon />, title: "landingFeatureFollowups", body: "landingFeatureFollowupsBody" },
{ icon: <MatchIcon />, title: "Honest CV match", body: "A deterministic keyword-coverage score with matched vs missing skills — not an opaque black box." }, { icon: <MatchIcon />, title: "landingFeatureMatch", body: "landingFeatureMatchBody" },
{ icon: <MailIcon />, title: "Email correspondence", body: "Keep linked Gmail and Microsoft messages in the context of the right application." }, { icon: <MailIcon />, title: "landingFeatureEmail", body: "landingFeatureEmailBody" },
{ icon: <AttachIcon />, title: "Attachments & docs", body: "Keep resumes, cover letters and portfolios versioned per application, right where you need them." }, { icon: <AttachIcon />, title: "landingFeatureDocuments", body: "landingFeatureDocumentsBody" },
{ icon: <InsightsIcon />, title: "Dashboard & insights", body: "Response rates, funnel, time-in-stage and skill demand across your whole search." }, { icon: <InsightsIcon />, title: "landingFeatureInsights", body: "landingFeatureInsightsBody" },
]; ];
const STEPS: { n: number; title: string; body: string }[] = [ const STEPS: { n: number; title: TranslationKey; body: TranslationKey }[] = [
{ n: 1, title: "Import", body: "Paste a job URL or use the bookmarklet — we parse the role into structured fields." }, { n: 1, title: "landingStepImport", body: "landingStepImportBody" },
{ n: 2, title: "Match", body: "See how your CV covers the role: matched keywords and the gaps to close." }, { n: 2, title: "landingStepMatch", body: "landingStepMatchBody" },
{ 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: 3, title: "landingStepTailor", body: "landingStepTailorBody" },
{ n: 4, title: "Track", body: "Move it through the pipeline; documents, notes and history stay attached." }, { n: 4, title: "landingStepTrack", body: "landingStepTrackBody" },
{ n: 5, title: "Follow up", body: "Linked email threads and reminders keep momentum with grounded replies." }, { n: 5, title: "landingStepFollowUp", body: "landingStepFollowUpBody" },
{ n: 6, title: "Analyze", body: "See what's working — response rate, funnel and time-in-stage — and focus your effort." }, { n: 6, title: "landingStepAnalyze", body: "landingStepAnalyzeBody" },
]; ];
export default function LandingPage() { export default function LandingPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { language, setLanguage, t } = useI18n();
const location = useLocation() as { state?: { from?: string } }; const location = useLocation() as { state?: { from?: string } };
const [checking, setChecking] = useState(true); const [checking, setChecking] = useState(true);
@@ -66,7 +70,7 @@ export default function LandingPage() {
if (checking) { if (checking) {
return ( return (
<Box sx={{ minHeight: "100vh", display: "grid", placeItems: "center", bgcolor: BRAND_DARK }}> <Box sx={{ minHeight: "100vh", display: "grid", placeItems: "center", bgcolor: BRAND_DARK }}>
<Typography sx={{ color: "#94a3b8" }}>Loading</Typography> <Typography sx={{ color: "#94a3b8" }}>{t("loading")}</Typography>
</Box> </Box>
); );
} }
@@ -76,6 +80,13 @@ export default function LandingPage() {
WebkitBackgroundClip: "text", WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent", WebkitTextFillColor: "transparent",
} as const; } 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 ( return (
<Box sx={{ bgcolor: "background.default" }}> <Box sx={{ bgcolor: "background.default" }}>
@@ -87,10 +98,13 @@ export default function LandingPage() {
<Box sx={{ width: 30, height: 30, borderRadius: "8px", background: "linear-gradient(135deg,#6366f1,#22d3ee)", display: "grid", placeItems: "center", color: BRAND_DARK, fontWeight: 900 }}></Box> <Box sx={{ width: 30, height: 30, borderRadius: "8px", background: "linear-gradient(135deg,#6366f1,#22d3ee)", display: "grid", placeItems: "center", color: BRAND_DARK, fontWeight: 900 }}></Box>
<Typography sx={{ color: "#fff", fontWeight: 800, fontSize: 20 }}>Jobbjakt</Typography> <Typography sx={{ color: "#fff", fontWeight: 800, fontSize: 20 }}>Jobbjakt</Typography>
</Stack> </Stack>
<Stack direction="row" alignItems="center" spacing={1}>
<StackLanguageToggle language={language} setLanguage={setLanguage} />
<GradientButton onClick={goToLogin} sx={{ color: "#0b1020", fontWeight: 700 }}> <GradientButton onClick={goToLogin} sx={{ color: "#0b1020", fontWeight: 700 }}>
Sign in {t("landingSignIn")}
</GradientButton> </GradientButton>
</Stack> </Stack>
</Stack>
</Container> </Container>
</Box> </Box>
@@ -99,21 +113,20 @@ export default function LandingPage() {
<Container maxWidth="lg"> <Container maxWidth="lg">
<Box sx={{ maxWidth: 760 }}> <Box sx={{ maxWidth: 760 }}>
<Box sx={{ display: "inline-block", px: 1.5, py: 0.5, borderRadius: 999, bgcolor: alpha("#ffffff", 0.08), color: "#a5b4fc", fontSize: 13, fontWeight: 600, letterSpacing: 0.5, mb: 3 }}> <Box sx={{ display: "inline-block", px: 1.5, py: 0.5, borderRadius: 999, bgcolor: alpha("#ffffff", 0.08), color: "#a5b4fc", fontSize: 13, fontWeight: 600, letterSpacing: 0.5, mb: 3 }}>
AI-ASSISTED JOB SEARCH WORKSPACE {t("landingEyebrow")}
</Box> </Box>
<Typography component="h1" sx={{ fontWeight: 800, fontSize: { xs: 40, md: 60 }, lineHeight: 1.05, mb: 2 }}> <Typography component="h1" sx={{ fontWeight: 800, fontSize: { xs: 40, md: 60 }, lineHeight: 1.05, mb: 2 }}>
Run your job search without losing <Box component="span" sx={gradientText}>the thread</Box>. {t("landingHeroBefore")} <Box component="span" sx={gradientText}>{t("landingHeroAccent")}</Box>.
</Typography> </Typography>
<Typography sx={{ color: "#94a3b8", fontSize: { xs: 17, md: 20 }, mb: 4 }}> <Typography sx={{ color: "#94a3b8", fontSize: { xs: 17, md: 20 }, mb: 4 }}>
Import a role, tailor your CV, track every application, and keep recruiter correspondence tied to the {t("landingHeroBody")}
right job all in one focused workspace. Assistive, never autonomous: you approve every draft.
</Typography> </Typography>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}> <Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<GradientButton size="large" onClick={goToLogin} sx={{ color: "#0b1020", fontWeight: 800, px: 4, py: 1.25 }}> <GradientButton size="large" onClick={goToLogin} sx={{ color: "#0b1020", fontWeight: 800, px: 4, py: 1.25 }}>
Get started {t("landingGetStarted")}
</GradientButton> </GradientButton>
<Button size="large" variant="outlined" href="#features" sx={{ color: "#e2e8f0", borderColor: alpha("#ffffff", 0.25), px: 3, py: 1.25 }}> <Button size="large" variant="outlined" href="#features" sx={{ color: "#e2e8f0", borderColor: alpha("#ffffff", 0.25), px: 3, py: 1.25 }}>
See features {t("landingSeeFeatures")}
</Button> </Button>
</Stack> </Stack>
<Typography sx={{ color: "#64748b", fontSize: 14, mt: 3 }}> <Typography sx={{ color: "#64748b", fontSize: 14, mt: 3 }}>
@@ -126,38 +139,38 @@ export default function LandingPage() {
{/* Product preview */} {/* Product preview */}
<Container maxWidth="lg" sx={{ py: { xs: 6, md: 9 } }}> <Container maxWidth="lg" sx={{ py: { xs: 6, md: 9 } }}>
<Box sx={{ textAlign: "center", mb: 5 }}> <Box sx={{ textAlign: "center", mb: 5 }}>
<Typography sx={{ color: "primary.main", fontWeight: 700, letterSpacing: 1, fontSize: 13 }}>SEE IT IN ACTION</Typography> <Typography sx={{ color: "primary.main", fontWeight: 700, letterSpacing: 1, fontSize: 13 }}>{t("landingPreviewEyebrow")}</Typography>
<Typography component="h2" sx={{ fontWeight: 800, fontSize: { xs: 26, md: 34 }, mt: 1 }}>Your whole search, at a glance</Typography> <Typography component="h2" sx={{ fontWeight: 800, fontSize: { xs: 26, md: 34 }, mt: 1 }}>{t("landingPreviewTitle")}</Typography>
</Box> </Box>
<Box sx={{ borderRadius: 3, overflow: "hidden", border: "1px solid", borderColor: "divider", boxShadow: 10, mb: 3, bgcolor: "background.paper" }}> <Box sx={{ borderRadius: 3, overflow: "hidden", border: "1px solid", borderColor: "divider", boxShadow: 10, mb: 3, bgcolor: "background.paper" }}>
<Box component="img" src="/mockups/dashboard.svg" alt="Jobbjakt dashboard — KPIs, funnel, response trend and follow-ups" sx={{ width: "100%", display: "block" }} /> <Box component="img" src="/mockups/dashboard.svg" alt={t("landingDashboardAlt")} sx={{ width: "100%", display: "block" }} />
</Box> </Box>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 3 }}> <Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 3 }}>
<Box sx={{ borderRadius: 3, overflow: "hidden", border: "1px solid", borderColor: "divider", boxShadow: 6, bgcolor: "background.paper" }}> <Box sx={{ borderRadius: 3, overflow: "hidden", border: "1px solid", borderColor: "divider", boxShadow: 6, bgcolor: "background.paper" }}>
<Box component="img" src="/mockups/pipeline.svg" alt="Drag-and-drop pipeline board" sx={{ width: "100%", display: "block" }} /> <Box component="img" src="/mockups/pipeline.svg" alt={t("landingPipelineAlt")} sx={{ width: "100%", display: "block" }} />
</Box> </Box>
<Box sx={{ borderRadius: 3, overflow: "hidden", border: "1px solid", borderColor: "divider", boxShadow: 6, bgcolor: "background.paper" }}> <Box sx={{ borderRadius: 3, overflow: "hidden", border: "1px solid", borderColor: "divider", boxShadow: 6, bgcolor: "background.paper" }}>
<Box component="img" src="/mockups/workspace.svg" alt="Per-job workspace with match score, correspondence and attachments" sx={{ width: "100%", display: "block" }} /> <Box component="img" src="/mockups/workspace.svg" alt={t("landingWorkspaceAlt")} sx={{ width: "100%", display: "block" }} />
</Box> </Box>
</Box> </Box>
<Typography sx={{ textAlign: "center", color: "text.secondary", fontSize: 13, mt: 2 }}>Interface preview.</Typography> <Typography sx={{ textAlign: "center", color: "text.secondary", fontSize: 13, mt: 2 }}>{t("landingPreviewCaption")}</Typography>
</Container> </Container>
{/* Features */} {/* Features */}
<Container id="features" maxWidth="lg" sx={{ py: { xs: 7, md: 10 } }}> <Container id="features" maxWidth="lg" sx={{ py: { xs: 7, md: 10 } }}>
<Box sx={{ textAlign: "center", mb: 6 }}> <Box sx={{ textAlign: "center", mb: 6 }}>
<Typography sx={{ color: "primary.main", fontWeight: 700, letterSpacing: 1, fontSize: 13 }}>WHAT IT DOES</Typography> <Typography sx={{ color: "primary.main", fontWeight: 700, letterSpacing: 1, fontSize: 13 }}>{t("landingFeaturesEyebrow")}</Typography>
<Typography component="h2" sx={{ fontWeight: 800, fontSize: { xs: 28, md: 38 }, mt: 1 }}>One workspace for the whole search</Typography> <Typography component="h2" sx={{ fontWeight: 800, fontSize: { xs: 28, md: 38 }, mt: 1 }}>{t("landingFeaturesTitle")}</Typography>
<Typography sx={{ color: "text.secondary", fontSize: 18, mt: 1.5 }}> <Typography sx={{ color: "text.secondary", fontSize: 18, mt: 1.5 }}>
Everything from a single import to the final offer no more spreadsheets and scattered inboxes. {t("landingFeaturesBody")}
</Typography> </Typography>
</Box> </Box>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr", md: "1fr 1fr 1fr" }, gap: 3 }}> <Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr", md: "1fr 1fr 1fr" }, gap: 3 }}>
{FEATURES.map((f) => ( {FEATURES.map((f) => (
<Box key={f.title} sx={{ p: 3, borderRadius: 3, border: "1px solid", borderColor: "divider", bgcolor: "background.paper", transition: "box-shadow .2s, transform .2s", "&:hover": { boxShadow: 6, transform: "translateY(-2px)" } }}> <Box key={f.title} sx={{ p: 3, borderRadius: 3, border: "1px solid", borderColor: "divider", bgcolor: "background.paper", transition: "box-shadow .2s, transform .2s", "&:hover": { boxShadow: 6, transform: "translateY(-2px)" } }}>
<Box sx={{ width: 48, height: 48, borderRadius: 2.5, display: "grid", placeItems: "center", bgcolor: alpha("#6366f1", 0.12), color: "primary.main", mb: 2 }}>{f.icon}</Box> <Box sx={{ width: 48, height: 48, borderRadius: 2.5, display: "grid", placeItems: "center", bgcolor: alpha("#6366f1", 0.12), color: "primary.main", mb: 2 }}>{f.icon}</Box>
<Typography sx={{ fontWeight: 700, fontSize: 19, mb: 0.75 }}>{f.title}</Typography> <Typography sx={{ fontWeight: 700, fontSize: 19, mb: 0.75 }}>{t(f.title)}</Typography>
<Typography sx={{ color: "text.secondary", fontSize: 15 }}>{f.body}</Typography> <Typography sx={{ color: "text.secondary", fontSize: 15 }}>{t(f.body)}</Typography>
</Box> </Box>
))} ))}
</Box> </Box>
@@ -167,15 +180,15 @@ export default function LandingPage() {
<Box sx={{ background: `linear-gradient(180deg, ${BRAND_DARK}, ${BRAND_PANEL})`, color: "#fff", py: { xs: 7, md: 10 } }}> <Box sx={{ background: `linear-gradient(180deg, ${BRAND_DARK}, ${BRAND_PANEL})`, color: "#fff", py: { xs: 7, md: 10 } }}>
<Container maxWidth="lg"> <Container maxWidth="lg">
<Box sx={{ textAlign: "center", mb: 6 }}> <Box sx={{ textAlign: "center", mb: 6 }}>
<Typography sx={{ color: "#a5b4fc", fontWeight: 700, letterSpacing: 1, fontSize: 13 }}>HOW IT WORKS</Typography> <Typography sx={{ color: "#a5b4fc", fontWeight: 700, letterSpacing: 1, fontSize: 13 }}>{t("landingHowEyebrow")}</Typography>
<Typography component="h2" sx={{ fontWeight: 800, fontSize: { xs: 28, md: 38 }, mt: 1 }}>From a link to an offer</Typography> <Typography component="h2" sx={{ fontWeight: 800, fontSize: { xs: 28, md: 38 }, mt: 1 }}>{t("landingHowTitle")}</Typography>
</Box> </Box>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr", md: "1fr 1fr 1fr" }, gap: 3 }}> <Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr", md: "1fr 1fr 1fr" }, gap: 3 }}>
{STEPS.map((s) => ( {STEPS.map((s) => (
<Box key={s.n} sx={{ p: 3, borderRadius: 3, border: `1px solid ${alpha("#ffffff", 0.1)}`, bgcolor: alpha("#ffffff", 0.03) }}> <Box key={s.n} sx={{ p: 3, borderRadius: 3, border: `1px solid ${alpha("#ffffff", 0.1)}`, bgcolor: alpha("#ffffff", 0.03) }}>
<Box sx={{ width: 40, height: 40, borderRadius: 999, display: "grid", placeItems: "center", background: "linear-gradient(135deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 900, mb: 1.5 }}>{s.n}</Box> <Box sx={{ width: 40, height: 40, borderRadius: 999, display: "grid", placeItems: "center", background: "linear-gradient(135deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 900, mb: 1.5 }}>{s.n}</Box>
<Typography sx={{ fontWeight: 700, fontSize: 18, mb: 0.5 }}>{s.title}</Typography> <Typography sx={{ fontWeight: 700, fontSize: 18, mb: 0.5 }}>{t(s.title)}</Typography>
<Typography sx={{ color: "#94a3b8", fontSize: 15 }}>{s.body}</Typography> <Typography sx={{ color: "#94a3b8", fontSize: 15 }}>{t(s.body)}</Typography>
</Box> </Box>
))} ))}
</Box> </Box>
@@ -185,14 +198,14 @@ export default function LandingPage() {
{/* Plans */} {/* Plans */}
<Container id="plans" maxWidth="lg" sx={{ py: { xs: 7, md: 10 } }}> <Container id="plans" maxWidth="lg" sx={{ py: { xs: 7, md: 10 } }}>
<Box sx={{ textAlign: "center", mb: 6 }}> <Box sx={{ textAlign: "center", mb: 6 }}>
<Typography sx={{ color: "primary.main", fontWeight: 700, letterSpacing: 1, fontSize: 13 }}>PLANS</Typography> <Typography sx={{ color: "primary.main", fontWeight: 700, letterSpacing: 1, fontSize: 13 }}>{t("landingPlansEyebrow")}</Typography>
<Typography component="h2" sx={{ fontWeight: 800, fontSize: { xs: 28, md: 38 }, mt: 1 }}>Two clear plans</Typography> <Typography component="h2" sx={{ fontWeight: 800, fontSize: { xs: 28, md: 38 }, mt: 1 }}>{t("landingPlansTitle")}</Typography>
<Typography sx={{ color: "text.secondary", fontSize: 18, mt: 1.5 }}> <Typography sx={{ color: "text.secondary", fontSize: 18, mt: 1.5 }}>
Free covers the core job search. Pro adds AI assistance and Pro CV themes. {t("landingPlansBody")}
</Typography> </Typography>
</Box> </Box>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(2, minmax(0, 1fr))" }, gap: 3, alignItems: "stretch", maxWidth: 900, mx: "auto" }}> <Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(2, minmax(0, 1fr))" }, gap: 3, alignItems: "stretch", maxWidth: 900, mx: "auto" }}>
{PUBLIC_PLANS.map((plan) => ( {plans.map((plan) => (
<Box <Box
component="section" component="section"
aria-labelledby={`plan-${plan.id}`} aria-labelledby={`plan-${plan.id}`}
@@ -211,7 +224,7 @@ export default function LandingPage() {
> >
{plan.id === "pro" && ( {plan.id === "pro" && (
<Box sx={{ position: "absolute", top: -13, left: 24, px: 1.5, py: 0.5, borderRadius: 999, background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontSize: 12, fontWeight: 800 }}> <Box sx={{ position: "absolute", top: -13, left: 24, px: 1.5, py: 0.5, borderRadius: 999, background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontSize: 12, fontWeight: 800 }}>
AI ASSISTANCE {t("landingAiAssistance")}
</Box> </Box>
)} )}
<Typography id={`plan-${plan.id}`} component="h3" sx={{ fontWeight: 800, fontSize: 24 }}>{plan.name}</Typography> <Typography id={`plan-${plan.id}`} component="h3" sx={{ fontWeight: 800, fontSize: 24 }}>{plan.name}</Typography>
@@ -237,7 +250,7 @@ export default function LandingPage() {
))} ))}
</Box> </Box>
<Typography sx={{ textAlign: "center", color: "text.secondary", fontSize: 13, mt: 3 }}> <Typography sx={{ textAlign: "center", color: "text.secondary", fontSize: 13, mt: 3 }}>
Commercial terms appear only in configured checkout. Your applications, documents and exports remain accessible if your plan changes. {t("landingCommercialTerms")}
</Typography> </Typography>
</Container> </Container>
@@ -245,11 +258,11 @@ export default function LandingPage() {
<Container maxWidth="lg" sx={{ py: { xs: 7, md: 10 } }}> <Container maxWidth="lg" sx={{ py: { xs: 7, md: 10 } }}>
<Box sx={{ borderRadius: 4, p: { xs: 4, md: 6 }, background: "linear-gradient(120deg,#0f172a,#1e293b)", color: "#fff", display: "flex", flexDirection: { xs: "column", md: "row" }, alignItems: { md: "center" }, justifyContent: "space-between", gap: 3 }}> <Box sx={{ borderRadius: 4, p: { xs: 4, md: 6 }, background: "linear-gradient(120deg,#0f172a,#1e293b)", color: "#fff", display: "flex", flexDirection: { xs: "column", md: "row" }, alignItems: { md: "center" }, justifyContent: "space-between", gap: 3 }}>
<Box> <Box>
<Typography sx={{ fontWeight: 800, fontSize: { xs: 24, md: 30 }, mb: 1 }}>Ready to organize your search?</Typography> <Typography sx={{ fontWeight: 800, fontSize: { xs: 24, md: 30 }, mb: 1 }}>{t("landingReadyTitle")}</Typography>
<Typography sx={{ color: "#94a3b8", fontSize: 17 }}>Sign in to start tracking applications, tailoring CVs, and following up with intent.</Typography> <Typography sx={{ color: "#94a3b8", fontSize: 17 }}>{t("landingReadyBody")}</Typography>
</Box> </Box>
<GradientButton size="large" onClick={goToLogin} sx={{ color: "#0b1020", fontWeight: 800, px: 4, py: 1.25, whiteSpace: "nowrap" }}> <GradientButton size="large" onClick={goToLogin} sx={{ color: "#0b1020", fontWeight: 800, px: 4, py: 1.25, whiteSpace: "nowrap" }}>
Sign in {t("landingSignInArrow")}
</GradientButton> </GradientButton>
</Box> </Box>
</Container> </Container>
@@ -258,8 +271,8 @@ export default function LandingPage() {
<Box sx={{ borderTop: "1px solid", borderColor: "divider", py: 4 }}> <Box sx={{ borderTop: "1px solid", borderColor: "divider", py: 4 }}>
<Container maxWidth="lg"> <Container maxWidth="lg">
<Stack direction={{ xs: "column", sm: "row" }} justifyContent="space-between" alignItems="center" spacing={1}> <Stack direction={{ xs: "column", sm: "row" }} justifyContent="space-between" alignItems="center" spacing={1}>
<Typography sx={{ color: "text.secondary", fontSize: 14 }}>© {new Date().getFullYear()} Jobbjakt a focused workspace for the modern job search.</Typography> <Typography sx={{ color: "text.secondary", fontSize: 14 }}>{t("landingFooter", { year: new Date().getFullYear() })}</Typography>
<Button variant="text" onClick={goToLogin} sx={{ fontWeight: 700 }}>Sign in</Button> <Button variant="text" onClick={goToLogin} sx={{ fontWeight: 700 }}>{t("landingSignIn")}</Button>
</Stack> </Stack>
</Container> </Container>
</Box> </Box>