diff --git a/job-tracker-ui/src/App.tsx b/job-tracker-ui/src/App.tsx index 855155d..bb8e0cd 100644 --- a/job-tracker-ui/src/App.tsx +++ b/job-tracker-ui/src/App.tsx @@ -100,6 +100,16 @@ function titleFor(path: string, t: (k: any) => string): string { return t("appTitle"); } +function subtitleFor(path: string, t: (k: any) => string): string | undefined { + if (path === "/dashboard") return t("dashboardPageSubtitle"); + if (path.startsWith("/jobs")) return t("jobsPageSubtitle"); + if (path.startsWith("/kanban")) return t("kanbanPageSubtitle"); + if (path.startsWith("/reminders")) return t("remindersPageSubtitle"); + if (path.startsWith("/correspondence/review")) return t("gmailReviewPageSubtitle"); + if (path.startsWith("/correspondence")) return t("correspondencePageSubtitle"); + return undefined; +} + function PageLoader() { return Loading...; } @@ -123,6 +133,9 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo const path = location.pathname; const isJobs = path.startsWith("/jobs"); + const shortcutHint = useMemo(() => ( + typeof navigator !== "undefined" && /Mac|iPhone|iPod|iPad/.test(navigator.platform) ? "⌘K" : "Ctrl+K" + ), []); useEffect(() => { api.get("/auth/config").then((r) => setRequireAuth(Boolean(r.data?.requireAuth))).catch(() => setRequireAuth(false)); @@ -206,6 +219,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo if (requireAuth && !me) return ; const pageTitle = titleFor(path, t); + const pageSubtitle = subtitleFor(path, t); const breadcrumbs = breadcrumbsFor(path, t); const setAndPersistPageSize = (n: 15 | 20 | 25) => { setJobPageSize(n); window.localStorage.setItem("jobPageSize", String(n)); }; const setAndPersistColumns = (next: JobTableColumns) => { setJobColumns(next); window.localStorage.setItem("jobColumns", JSON.stringify(next)); }; @@ -246,14 +260,19 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo setQuickOpen(true)} sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2.5, width: 42, height: 42, flex: "0 0 auto" }} > ) : ( - + )} {isJobs ? ( + + ); +} + function generateOverview(job: JobApplication): string { if (job.fullSummary) return job.fullSummary; if (job.shortSummary) return job.shortSummary; @@ -220,6 +233,12 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col return jobs.filter((job) => needsWorkflowWork(job)); }, [jobs, readinessFilter]); + // Distinguishes "you have zero jobs, period" from "no results match your filters" so the + // empty state can actually help a first-time user instead of just saying "nothing here". + const noFiltersActive = !debouncedSearch.trim() && statusFilter === "All" && companyFilterId === "All" + && !debouncedLocation.trim() && !needsFollowUpOnly && readinessFilter === "all"; + const isFirstTimeEmpty = mode === "jobs" && total === 0 && noFiltersActive; + const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]); const selectedAllOnPage = filteredJobs.length > 0 && filteredJobs.every((job) => selectedIdSet.has(job.id)); @@ -629,7 +648,9 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col ); })} - {filteredJobs.length === 0 && !jobsResource.loading && !jobsResource.error ? {t("jobTableNoJobsFound")} : null} + {filteredJobs.length === 0 && !jobsResource.loading && !jobsResource.error ? ( + navigate("/settings")} t={t} /> + ) : null} ) : ( @@ -721,7 +742,9 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col ); })} - {filteredJobs.length === 0 && !jobsResource.loading && !jobsResource.error ? {t("jobTableNoJobsFound")} : null} + {filteredJobs.length === 0 && !jobsResource.loading && !jobsResource.error ? ( + navigate("/settings")} t={t} /> + ) : null} diff --git a/job-tracker-ui/src/components/KanbanBoard.tsx b/job-tracker-ui/src/components/KanbanBoard.tsx index 8ff8628..81d0164 100644 --- a/job-tracker-ui/src/components/KanbanBoard.tsx +++ b/job-tracker-ui/src/components/KanbanBoard.tsx @@ -101,7 +101,18 @@ export default function KanbanBoard() { /> {!jobsResource.loading && !jobsResource.error ? ( - + {STATUSES.map((status) => { const c = toneColor(theme, status); const list = groups.get(status) ?? []; @@ -114,6 +125,8 @@ export default function KanbanBoard() { p: 1.5, borderRadius: 3, minHeight: 220, + flex: { xs: "0 0 85vw", md: "none" }, + scrollSnapAlign: { xs: "start", md: "none" }, border: "1px solid", borderColor: "divider", background: theme.palette.mode === "dark" ? alpha(theme.palette.common.white, 0.02) : alpha(theme.palette.text.primary, 0.015), diff --git a/job-tracker-ui/src/components/OnboardingChecklist.tsx b/job-tracker-ui/src/components/OnboardingChecklist.tsx new file mode 100644 index 0000000..6f7f57a --- /dev/null +++ b/job-tracker-ui/src/components/OnboardingChecklist.tsx @@ -0,0 +1,81 @@ +import React, { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { Box, Button, IconButton, Paper, Stack, Typography } from "@mui/material"; +import CheckCircleIcon from "@mui/icons-material/CheckCircle"; +import RadioButtonUncheckedIcon from "@mui/icons-material/RadioButtonUnchecked"; +import CloseIcon from "@mui/icons-material/Close"; +import { alpha, useTheme } from "@mui/material/styles"; + +import { api } from "../api"; +import { getUserKeyFromToken } from "../themePrefs"; +import { useI18n } from "../i18n/I18nProvider"; + +function dismissKey() { + return `onboardingChecklistDismissed:${getUserKeyFromToken()}`; +} + +type MeResponse = { profileCvText?: string | null }; + +export default function OnboardingChecklist({ hasJobs }: { hasJobs: boolean }) { + const theme = useTheme(); + const navigate = useNavigate(); + const { t } = useI18n(); + const [hasCv, setHasCv] = useState(null); + const [dismissed, setDismissed] = useState(() => window.localStorage.getItem(dismissKey()) === "1"); + + useEffect(() => { + let active = true; + api.get("/auth/me") + .then((r) => { if (active) setHasCv(Boolean(r.data?.profileCvText?.trim())); }) + .catch(() => { if (active) setHasCv(false); }); + return () => { active = false; }; + }, []); + + const allDone = hasCv === true && hasJobs; + if (dismissed || allDone || hasCv === null) return null; + + const dismiss = () => { + window.localStorage.setItem(dismissKey(), "1"); + setDismissed(true); + }; + + const steps = [ + { done: hasCv, label: t("onboardingStepCv"), action: () => navigate("/profile"), actionLabel: t("onboardingStepCvAction") }, + { done: hasJobs, label: t("onboardingStepJob"), action: () => navigate("/jobs"), actionLabel: t("onboardingStepJobAction") }, + { done: hasCv === true && hasJobs, label: t("onboardingStepMatch"), action: () => navigate("/jobs"), actionLabel: t("onboardingStepMatchAction") }, + ]; + + return ( + + + + + {t("onboardingTitle")} + {t("onboardingBody")} + + {steps.map((step) => ( + + + {step.done ? : } + + {step.label} + + + {!step.done ? : null} + + ))} + + + ); +} diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 84c091f..e43bf39 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -18,6 +18,12 @@ export const translations = { home: "Home", analytics: "Analytics", overview: "Overview", + dashboardPageSubtitle: "Your search at a glance — response rate, funnel, and what needs attention.", + jobsPageSubtitle: "Filter, search, and manage every application in one table.", + kanbanPageSubtitle: "Drag a card between stages to update its status.", + remindersPageSubtitle: "Everything due for follow-up, soonest first.", + correspondencePageSubtitle: "Every message linked to a job, in one inbox.", + gmailReviewPageSubtitle: "Review Gmail threads before linking them to a job.", account: "Account", profile: "Profile", admin: "Admin", @@ -306,6 +312,15 @@ export const translations = { cropDialogSave: "Save image", dashboardOverviewTitle: "Dashboard overview", dashboardHeroLabel: "Job search overview", + onboardingTitle: "Get set up", + onboardingBody: "A few steps to get the most out of Jobbjakt.", + onboardingDismiss: "Dismiss", + onboardingStepCv: "Add your CV", + onboardingStepCvAction: "Add CV", + onboardingStepJob: "Import your first job", + onboardingStepJobAction: "Add job", + onboardingStepMatch: "Check your CV match score on a job", + onboardingStepMatchAction: "Open jobs", dashboardResponseRate: "{rate}% response rate", dashboardMonthsShort: "{count} mo", dashboardAppliedCount: "{count} applied", @@ -606,6 +621,7 @@ export const translations = { googleAvailableToLink: "Available to link", googleLinkedDate: "Linked {date}", googleSignInHint: "Sign in with a Google account that has already been linked to your Jobbjakt user.", + googleSignInHintSelfServe: "Continue with Google. New here? We'll create your account automatically.", continueWithGoogle: "Continue with Google", signInWithGoogle: "Sign in with Google", linkWithGoogle: "Link with Google", @@ -750,6 +766,9 @@ export const translations = { jobTableOverview: "Overview", jobTableNoSummaryYet: "No summary yet.", jobTableNoJobsFound: "No jobs found.", + jobTableEmptyFirstTimeTitle: "No jobs yet — let's fix that.", + jobTableEmptyFirstTimeBody: "Click \"Add job\" above to add one manually, or paste a job posting URL. There's also a one-click bookmarklet that captures a posting straight from the page you're viewing.", + jobTableEmptyFirstTimeBookmarklet: "Set up the bookmarklet", jobTableSetStatus: "Set {status}", editJobTitle: "Edit job", editJobIntro: "Update job details, timeline status, documents, and notes from one editing workspace.", @@ -894,6 +913,7 @@ export const translations = { jobDetailsFollowUpSent: "Follow-up sent and logged.", jobDetailsFollowUpSendFailed: "Failed to send follow-up.", jobDetailsHowYouMatch: "How you match", + jobDetailsAiFitHint: "AI opinion — strengths, gaps, and a tailored pitch based on your CV and this posting.", matchScoreTitle: "Match score", matchScoreLoading: "Scoring your CV against this role…", matchScoreBand_Strong: "Strong match", @@ -902,7 +922,7 @@ export const translations = { matchScoreBand_Unknown: "Not enough signal", matchScoreKeywordsCovered: "{matched}/{total} keywords", matchScoreNoSignal: "Add more CV detail or a fuller job description to get a reliable score.", - matchScoreDeterministicHint: "Deterministic keyword coverage — no AI, so the score is stable and repeatable.", + matchScoreDeterministicHint: "Deterministic keyword coverage — no AI, so the score is stable and repeatable. For a written opinion on strengths and gaps, see the AI section below.", matchScoreMatched: "Matched keywords", matchScoreMissing: "Missing keywords", matchScoreNoneYet: "No matches found yet.", @@ -976,6 +996,12 @@ export const translations = { home: "Hjem", analytics: "Analyse", overview: "Oversikt", + dashboardPageSubtitle: "Søket ditt i korte trekk — svarrate, trakt og hva som trenger oppmerksomhet.", + jobsPageSubtitle: "Filtrer, søk og administrer alle søknader i én tabell.", + kanbanPageSubtitle: "Dra et kort mellom stadier for å oppdatere status.", + remindersPageSubtitle: "Alt som trenger oppfølging, snarest først.", + correspondencePageSubtitle: "Alle meldinger koblet til en jobb, i én innboks.", + gmailReviewPageSubtitle: "Se gjennom Gmail-tråder før du kobler dem til en jobb.", account: "Konto", profile: "Profil", admin: "Admin", @@ -1264,6 +1290,15 @@ export const translations = { cropDialogSave: "Lagre bilde", dashboardOverviewTitle: "Dashboard-oversikt", dashboardHeroLabel: "Oversikt over jobbsøket", + onboardingTitle: "Kom i gang", + onboardingBody: "Noen få steg for å få mest mulig ut av Jobbjakt.", + onboardingDismiss: "Lukk", + onboardingStepCv: "Legg til CV-en din", + onboardingStepCvAction: "Legg til CV", + onboardingStepJob: "Importer din første jobb", + onboardingStepJobAction: "Legg til jobb", + onboardingStepMatch: "Sjekk CV-matchscoren på en jobb", + onboardingStepMatchAction: "Åpne jobber", dashboardResponseRate: "{rate}% svarrate", dashboardMonthsShort: "{count} md", dashboardAppliedCount: "{count} søkt", @@ -1564,6 +1599,7 @@ export const translations = { googleAvailableToLink: "Tilgjengelig for kobling", googleLinkedDate: "Koblet {date}", googleSignInHint: "Logg inn med en Google-konto som allerede er koblet til Jobbjakt-brukeren din.", + googleSignInHintSelfServe: "Fortsett med Google. Ny her? Vi oppretter kontoen din automatisk.", continueWithGoogle: "Fortsett med Google", signInWithGoogle: "Logg inn med Google", linkWithGoogle: "Koble til med Google", @@ -1708,6 +1744,9 @@ export const translations = { jobTableOverview: "Oversikt", jobTableNoSummaryYet: "Ingen oppsummering ennå.", jobTableNoJobsFound: "Ingen jobber funnet.", + jobTableEmptyFirstTimeTitle: "Ingen jobber ennå — la oss fikse det.", + jobTableEmptyFirstTimeBody: "Klikk \"Legg til jobb\" over for å legge til en manuelt, eller lim inn en lenke til en stillingsannonse. Det finnes også et bokmerke som fanger en annonse rett fra siden du ser på.", + jobTableEmptyFirstTimeBookmarklet: "Sett opp bokmerket", jobTableSetStatus: "Sett {status}", editJobTitle: "Rediger jobb", editJobIntro: "Oppdater jobbdetaljer, status i tidslinjen, dokumenter og notater fra ett redigeringsområde.", @@ -1852,6 +1891,7 @@ export const translations = { jobDetailsFollowUpSent: "Oppfølging sendt og loggført.", jobDetailsFollowUpSendFailed: "Kunne ikke sende oppfølging.", jobDetailsHowYouMatch: "Slik matcher du", + jobDetailsAiFitHint: "AI-vurdering — styrker, svakheter og et skreddersydd pitch basert på CV-en din og denne annonsen.", matchScoreTitle: "Match-score", matchScoreLoading: "Vurderer CV-en mot denne stillingen…", matchScoreBand_Strong: "Sterk match", @@ -1860,7 +1900,7 @@ export const translations = { matchScoreBand_Unknown: "For lite grunnlag", matchScoreKeywordsCovered: "{matched}/{total} nøkkelord", matchScoreNoSignal: "Legg til mer CV-innhold eller en fyldigere stillingstekst for en pålitelig score.", - matchScoreDeterministicHint: "Deterministisk nøkkelorddekning — ingen AI, så scoren er stabil og repeterbar.", + matchScoreDeterministicHint: "Deterministisk nøkkelorddekning — ingen AI, så scoren er stabil og repeterbar. For en skriftlig vurdering av styrker og svakheter, se AI-seksjonen under.", matchScoreMatched: "Treff på nøkkelord", matchScoreMissing: "Manglende nøkkelord", matchScoreNoneYet: "Ingen treff ennå.", diff --git a/job-tracker-ui/src/layout/AppShell.tsx b/job-tracker-ui/src/layout/AppShell.tsx index ca6be3c..24ed3c4 100644 --- a/job-tracker-ui/src/layout/AppShell.tsx +++ b/job-tracker-ui/src/layout/AppShell.tsx @@ -59,6 +59,7 @@ const SIDEBAR_SELECTED_ICON = "#a5b4fc"; export default function AppShell({ pageTitle, + pageSubtitle, breadcrumbs, pathname, nav, @@ -76,6 +77,7 @@ export default function AppShell({ children, }: { pageTitle: string; + pageSubtitle?: string; breadcrumbs: string[]; pathname: string; nav: NavItem[]; @@ -481,6 +483,11 @@ export default function AppShell({ {pageTitle} + {pageSubtitle ? ( + + {pageSubtitle} + + ) : null} diff --git a/job-tracker-ui/src/views/CorrespondenceInboxPage.tsx b/job-tracker-ui/src/views/CorrespondenceInboxPage.tsx index 2aed368..85d6b9f 100644 --- a/job-tracker-ui/src/views/CorrespondenceInboxPage.tsx +++ b/job-tracker-ui/src/views/CorrespondenceInboxPage.tsx @@ -82,10 +82,11 @@ export default function CorrespondenceInboxPage() { Cross-job view of imported correspondence and Gmail-linked history. - + } label={`${items.length} items`} variant="outlined" /> 0 ? "success" : "default"} /> + diff --git a/job-tracker-ui/src/views/GmailReviewPage.tsx b/job-tracker-ui/src/views/GmailReviewPage.tsx index ea6fc33..4b058e9 100644 --- a/job-tracker-ui/src/views/GmailReviewPage.tsx +++ b/job-tracker-ui/src/views/GmailReviewPage.tsx @@ -138,6 +138,7 @@ export default function GmailReviewPage() { + diff --git a/job-tracker-ui/src/views/LandingPage.tsx b/job-tracker-ui/src/views/LandingPage.tsx index 7029a26..34a4ccb 100644 --- a/job-tracker-ui/src/views/LandingPage.tsx +++ b/job-tracker-ui/src/views/LandingPage.tsx @@ -50,7 +50,7 @@ export default function LandingPage() { let active = true; api .get("/auth/me") - .then(() => { if (active) navigate("/jobs", { replace: true }); }) + .then(() => { if (active) navigate("/dashboard", { replace: true }); }) .catch(() => { if (active) setChecking(false); }); return () => { active = false; }; }, [navigate]); diff --git a/job-tracker-ui/src/views/LoginPage.tsx b/job-tracker-ui/src/views/LoginPage.tsx index ebab1a2..6c2817e 100644 --- a/job-tracker-ui/src/views/LoginPage.tsx +++ b/job-tracker-ui/src/views/LoginPage.tsx @@ -33,7 +33,7 @@ export default function LoginPage() { const [rememberMe, setRememberMe] = useState(() => getRememberMePref()); const [loading, setLoading] = useState(false); - const nextPath = (location?.state?.from as string | undefined) ?? "/jobs"; + const nextPath = (location?.state?.from as string | undefined) ?? "/dashboard"; useEffect(() => { api