Compare commits

..

8 Commits

Author SHA1 Message Date
cesnimda d5d82cb528 feat(ux): onboarding checklist, dashboard-first landing (fixed)
CI and Deploy / test (pull_request) Successful in 2m5s
CI and Deploy / deploy (pull_request) Has been skipped
Dashboard onboarding checklist: a dismissible card with 3 steps (add
CV, import first job, check match score), each linking straight to
where you'd do it. Auto-hides once both CV and a job exist; otherwise
persists per-user via localStorage until dismissed.

Fixes the actual authenticated-landing redirect to /dashboard: my
earlier commit changed App.tsx's inner Shell route for "/", which
turned out to be dead code -- the outer router claims "/" for
LandingPage first, so Shell's own "/" route is never reached on a
direct hit. The real redirect lives in LandingPage.tsx's post-auth-check
navigate() and LoginPage.tsx's post-login nextPath default; both now
point at /dashboard. Verified live: an authenticated visitor hitting
"/" now lands on Dashboard with the onboarding checklist visible,
confirmed via rendered page text and screenshot.
2026-07-12 04:26:17 +02:00
cesnimda 9615ee3f41 feat(ux): per-view subtitles, correspondence cross-links, mobile kanban, a11y
Continuing the product/UX review's deferred items:

- Every top-level view now gets a one-line subtitle under its title
  (Dashboard/Jobs/Kanban/Reminders/Correspondence/Gmail review) stating
  what that specific view is for, instead of navigation being the only
  signal of what each page does.
- Correspondence inbox and Gmail review queue cross-link to each other
  instead of being two unexplained flat sidebar items -- kept both nav
  entries (renaming/nesting risked breaking muscle memory) but made the
  relationship between them explicit in the UI itself.
- Kanban board switches to a horizontal scroll-snap row on phone-width
  viewports instead of stacking all 5 columns vertically, which meant
  a lot of scrolling to see anything past "Applied".
- Match-score ring gets an aria-label with the actual percentage --
  it was two nested decorative CircularProgress elements with no
  accessible text. (Keyboard-accessible status changes on kanban cards
  were already covered by the existing "..." menu -- no gap there.)
2026-07-12 04:14:37 +02:00
cesnimda 58868fc2b6 feat(ux): first-time onboarding, empty states, and copy fixes
Implements the six "propose first" items from the product/UX review:

- "/" now redirects to /dashboard instead of the empty /jobs table --
  a new user's first screen is now an overview with orientation, not
  a data table with zero rows and four filter dropdowns.
- Jobs table gets a real first-time empty state (distinct from "no
  results match your filters") pointing at Add Job and the bookmarklet,
  instead of a bare "No jobs found."
- Match Score card and Candidate Fit tab now each get a one-line
  caption explaining what they are and how they differ (deterministic
  keyword coverage vs. AI opinion) -- they previously sat side by side
  with no explanation of why there are two.
- Google sign-in hint now reflects self-serve signup when
  Auth:AllowRegistration is on, instead of always implying you need an
  existing linked account.
- Quick Search button now shows its keyboard shortcut (Ctrl+K / ⌘K)
  inline instead of being undiscoverable.
2026-07-12 04:05:32 +02:00
cesnimda 7dadf8dde4 Merge pull request 'fix(auth): Google Sign-In audience mismatch + remove per-user accent color' (#26) from fix/google-signin-and-theming-cleanup into main
CI and Deploy / test (push) Successful in 2m5s
CI and Deploy / deploy (push) Successful in 1m10s
2026-07-12 03:06:35 +02:00
cesnimda 33d899c243 fix(auth): Google Sign-In audience mismatch + remove per-user accent color
CI and Deploy / test (pull_request) Successful in 2m8s
CI and Deploy / deploy (pull_request) Has been skipped
Root cause of "Google authentication failed": appsettings.Development.json
had Auth:GoogleClientId set to the literal placeholder
"CHANGE_ME_GOOGLE_CLIENT_ID" while the frontend's .env.development had a
real (already-public, already-committed) client ID -- every Google ID
token's audience check failed against the backend's placeholder. Fixed
by setting the same real client ID on both sides (a client ID is a
public identifier, not a secret, safe to commit -- unlike a client
secret). Also enabled Auth:AllowRegistration in dev so the existing
Google-first self-serve-signup path (auto-create on unmatched verified
email, auto-link on matching verified email -- built during Wave 7) is
actually exercisable locally.

Wired the previously-missing Auth__MicrosoftClientId /
NEXT_PUBLIC_MICROSOFT_CLIENT_ID into docker-compose.yml/.env.example
(distinct from the existing MICROSOFT_CLIENT_ID used for Outlook mail
linking) -- Microsoft sign-in was never deployable, a leftover gap from
when it was built. Fixed a stale env-var name in the Microsoft setup
hint copy (still said REACT_APP_*, predates the Next.js migration).

Removed the per-user accent color picker entirely: it was purely
client-side (localStorage + theme.ts), never touched the backend/DB.
theme.ts now hardcodes a single ACCENT constant; themePrefs.ts drops
get/set/clearAccentColor; App.tsx and SettingsView.tsx drop the
accentColor prop threading. Dead accent-related i18n keys removed from
both locales.

Consolidated Settings' "Account" tab (duplicated GoogleAuthCard, which
already lives on the Profile page) into Profile: moved AuthStatusCard
and EmailProviderConnections there alongside the existing Google/
Microsoft auth cards, so identity/account-linking lives in one place.
Settings drops from 5 tabs to 4 and its General tab uses a consistent
SectionCard layout instead of ad-hoc per-card styling.

Verified: dotnet build/test (177/177) and npm build/test (57/57) both
green; confirmed live against a running dev server that /auth/config
now reports googleEnabled with the corrected client ID, Settings has
no accent controls, and Profile shows the consolidated auth section.
2026-07-12 02:43:10 +02:00
cesnimda b2e176940c Merge pull request 'fix(deploy): copy .npmrc before npm ci in frontend Dockerfile' (#25) from fix/docker-npmrc-not-copied into main
CI and Deploy / test (push) Successful in 2m1s
CI and Deploy / deploy (push) Successful in 2m13s
2026-07-12 02:18:47 +02:00
cesnimda 86cdafb3ef fix(deploy): copy .npmrc before npm ci in frontend Dockerfile
CI and Deploy / test (pull_request) Successful in 2m1s
CI and Deploy / deploy (pull_request) Has been skipped
Production deploy has been broken since the Next.js migration merged:
the Dockerfile ran `npm ci` right after COPY package*.json, before the
later `COPY . .` that would bring in .npmrc -- so the legacy-peer-deps
fix for react-scripts' stale TS ^4 peer constraint (added for CI in
dbb1580) never took effect in the actual deploy image, and every
deploy since has failed with the same ERESOLVE error CI hit before
that fix. Copy .npmrc alongside package*.json so npm ci sees it.
2026-07-12 02:15:49 +02:00
cesnimda 0e5845a95a Merge pull request 'feat(ui): circular match-score ring in job workspace' (#24) from feature/ui-rework-match-score-ring into main
CI and Deploy / test (push) Successful in 2m0s
CI and Deploy / deploy (push) Failing after 45s
2026-07-12 02:05:18 +02:00
22 changed files with 324 additions and 270 deletions
+3
View File
@@ -5,6 +5,9 @@ AUTH_JWT_KEY=CHANGE_ME_LONG_RANDOM_SECRET
AUTH_ADMIN_EMAIL=admin@example.com
AUTH_ADMIN_PASSWORD=CHANGE_ME_STRONG_PASSWORD
AUTH_GOOGLE_CLIENT_ID=CHANGE_ME_GOOGLE_CLIENT_ID
# Optional: enables the "Continue with Microsoft" sign-in tab (separate from the
# MICROSOFT_CLIENT_ID below, which is for Outlook mail linking, not sign-in).
AUTH_MICROSOFT_CLIENT_ID=
GOOGLE_GMAIL_CLIENT_SECRET=CHANGE_ME_GOOGLE_OAUTH_CLIENT_SECRET
# Optional. If omitted, the backend uses https://<your-domain>/api/gmail/oauth/callback
GOOGLE_GMAIL_REDIRECT_URI=
+2 -2
View File
@@ -19,14 +19,14 @@
},
"Auth": {
"Require": true,
"AllowRegistration": false,
"AllowRegistration": true,
"JwtKey": "CHANGE_ME_DEV_ONLY_LONG_RANDOM_SECRET",
"JwtIssuer": "JobTrackerApi",
"JwtAudience": "job-tracker-ui",
"JwtExpiresMinutes": 720,
"AdminEmail": "admin@example.com",
"AdminPassword": "CHANGE_ME_STRONG_DEV_PASSWORD",
"GoogleClientId": "CHANGE_ME_GOOGLE_CLIENT_ID",
"GoogleClientId": "723556162227-llqucvpog2esn1dutmtvuul1lv374or6.apps.googleusercontent.com",
"MicrosoftClientId": "CHANGE_ME_MICROSOFT_CLIENT_ID"
},
"App": {
+3 -1
View File
@@ -19,8 +19,9 @@ services:
- Auth__JwtKey=${AUTH_JWT_KEY}
- Auth__AdminEmail=${AUTH_ADMIN_EMAIL}
- Auth__AdminPassword=${AUTH_ADMIN_PASSWORD}
# Optional: allow Google ID-token bearer auth
# Optional: allow Google / Microsoft ID-token bearer auth (sign-in, not mail access)
- Auth__GoogleClientId=${AUTH_GOOGLE_CLIENT_ID}
- Auth__MicrosoftClientId=${AUTH_MICROSOFT_CLIENT_ID}
- Google__GmailClientSecret=${GOOGLE_GMAIL_CLIENT_SECRET}
- Google__GmailRedirectUri=${GOOGLE_GMAIL_REDIRECT_URI}
# Optional: Outlook / Microsoft 365 mail linking via Microsoft Graph
@@ -64,6 +65,7 @@ services:
shm_size: '1gb'
args:
- NEXT_PUBLIC_GOOGLE_CLIENT_ID=${AUTH_GOOGLE_CLIENT_ID}
- NEXT_PUBLIC_MICROSOFT_CLIENT_ID=${AUTH_MICROSOFT_CLIENT_ID}
# Optional override; default in production is `/api`
- NEXT_PUBLIC_API_BASE_URL=${REACT_APP_API_BASE_URL}
ports:
+3 -1
View File
@@ -3,12 +3,14 @@ FROM node:20-alpine AS build
WORKDIR /app
ARG NEXT_PUBLIC_GOOGLE_CLIENT_ID
ARG NEXT_PUBLIC_MICROSOFT_CLIENT_ID
ARG NEXT_PUBLIC_API_BASE_URL
ENV NEXT_PUBLIC_GOOGLE_CLIENT_ID=$NEXT_PUBLIC_GOOGLE_CLIENT_ID
ENV NEXT_PUBLIC_MICROSOFT_CLIENT_ID=$NEXT_PUBLIC_MICROSOFT_CLIENT_ID
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
COPY package*.json ./
COPY package*.json .npmrc ./
RUN npm ci
COPY . .
+31 -14
View File
@@ -36,7 +36,7 @@ import { api } from "./api";
import { resolveCaptureUrl } from "./captureUrl";
import { clearAuthClientState, setAuthUserKey } from "./auth";
import AppShell, { NavItem } from "./layout/AppShell";
import { clearAccentColor, getAccentColor, getThemeModePref, setAccentColor, setThemeModePref, ThemeModePref } from "./themePrefs";
import { getThemeModePref, setThemeModePref, ThemeModePref } from "./themePrefs";
const AddJobModal = lazy(() => import("./components/AddJobModal"));
const KanbanBoard = lazy(() => import("./components/KanbanBoard"));
@@ -100,11 +100,21 @@ 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 <Box sx={{ p: 4 }}><Typography variant="h6">Loading...</Typography></Box>;
}
function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMode, onThemeModeChange, accentColor, onAccentColorChange, onResetAccentColor }: { jobPageSize: 15 | 20 | 25; setJobPageSize: (n: 15 | 20 | 25) => void; jobColumns: JobTableColumns; setJobColumns: (c: JobTableColumns) => void; themeMode: ThemeModePref; onThemeModeChange: (v: ThemeModePref) => void; accentColor: string; onAccentColorChange: (v: string) => void; onResetAccentColor: () => void; }) {
function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMode, onThemeModeChange }: { jobPageSize: 15 | 20 | 25; setJobPageSize: (n: 15 | 20 | 25) => void; jobColumns: JobTableColumns; setJobColumns: (c: JobTableColumns) => void; themeMode: ThemeModePref; onThemeModeChange: (v: ThemeModePref) => void; }) {
const location = useLocation();
const navigate = useNavigate();
const { t } = useI18n();
@@ -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<AuthConfig>("/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 <Navigate to="/" replace state={{ from: path }} />;
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
<IconButton
color="secondary"
size="small"
title={t("quickSearch")}
title={`${t("quickSearch")} (${shortcutHint})`}
onClick={() => setQuickOpen(true)}
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2.5, width: 42, height: 42, flex: "0 0 auto" }}
>
<SearchIcon fontSize="small" />
</IconButton>
) : (
<Button variant="outlined" startIcon={<SearchIcon />} onClick={() => setQuickOpen(true)}>{t("quickSearch")}</Button>
<Button variant="outlined" startIcon={<SearchIcon />} onClick={() => setQuickOpen(true)} sx={{ gap: 0.5 }}>
{t("quickSearch")}
<Box component="span" sx={{ ml: 0.75, px: 0.75, py: 0.125, borderRadius: 1, border: "1px solid", borderColor: "divider", fontSize: 11, fontWeight: 700, color: "text.secondary", lineHeight: 1.6 }}>
{shortcutHint}
</Box>
</Button>
)}
{isJobs ? (
<Button variant="contained" onClick={() => setAddOpen(true)} sx={{ flex: { xs: 1, sm: "0 0 auto" }, minHeight: 42 }}>
@@ -267,6 +286,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
<>
<AppShell
pageTitle={pageTitle}
pageSubtitle={pageSubtitle}
breadcrumbs={breadcrumbs}
pathname={path}
nav={nav}
@@ -284,7 +304,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
>
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path="/" element={<Navigate to="/jobs" replace />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<DashboardView />} />
<Route path="/jobs" element={<JobTable refreshToken={refreshToken} pageSize={jobPageSize} onPageSizeChange={setAndPersistPageSize} columns={jobColumns} onColumnsChange={setAndPersistColumns} mode="jobs" />} />
<Route path="/reminders" element={<RemindersView />} />
@@ -297,7 +317,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
<Route path="/admin/users" element={<AdminUsersPage />} />
<Route path="/admin/system" element={<AdminSystemPage />} />
<Route path="/trash" element={<JobTable refreshToken={refreshToken} pageSize={jobPageSize} onPageSizeChange={setAndPersistPageSize} columns={jobColumns} onColumnsChange={setAndPersistColumns} mode="trash" />} />
<Route path="/settings" element={<SettingsView pageSize={jobPageSize} onPageSizeChange={setAndPersistPageSize} columns={jobColumns} onColumnsChange={setAndPersistColumns} themeMode={themeMode} onThemeModeChange={onThemeModeChange} accentColor={accentColor} onAccentColorChange={onAccentColorChange} onResetAccentColor={onResetAccentColor} />} />
<Route path="/settings" element={<SettingsView pageSize={jobPageSize} onPageSizeChange={setAndPersistPageSize} columns={jobColumns} onColumnsChange={setAndPersistColumns} themeMode={themeMode} onThemeModeChange={onThemeModeChange} />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</Suspense>
@@ -314,19 +334,16 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
export default function App() {
const systemPrefersDark = useMediaQuery("(prefers-color-scheme: dark)", { defaultMatches: true, noSsr: true });
const [themeMode, setThemeMode] = useState<ThemeModePref>(() => getThemeModePref());
const [accentColor, setAccentColorState] = useState<string>(() => getAccentColor());
const effectiveMode: "light" | "dark" = themeMode === "light" ? "light" : themeMode === "dark" ? "dark" : systemPrefersDark ? "dark" : "light";
const theme = useMemo(() => getTheme(effectiveMode, accentColor), [effectiveMode, accentColor]);
const theme = useMemo(() => getTheme(effectiveMode), [effectiveMode]);
useEffect(() => {
const sync = () => { setThemeMode(getThemeModePref()); setAccentColorState(getAccentColor()); };
const sync = () => { setThemeMode(getThemeModePref()); };
window.addEventListener("auth-changed", sync);
return () => window.removeEventListener("auth-changed", sync);
}, []);
const onThemeModeChange = (v: ThemeModePref) => { setThemeModePref(v); setThemeMode(v); };
const onAccentColorChange = (v: string) => { setAccentColor(v); setAccentColorState(getAccentColor()); };
const onResetAccentColor = () => { clearAccentColor(); setAccentColorState(getAccentColor()); };
const [jobPageSize, setJobPageSize] = useState<15 | 20 | 25>(() => {
const raw = window.localStorage.getItem("jobPageSize");
@@ -349,14 +366,14 @@ export default function App() {
{ path: "/login", element: <LoginPage />, errorElement: <RouteErrorPage /> },
{ path: "/forgot-password", element: <ForgotPasswordPage />, errorElement: <RouteErrorPage /> },
{ path: "/reset-password", element: <ResetPasswordPage />, errorElement: <RouteErrorPage /> },
{ path: "/*", element: <Shell jobPageSize={jobPageSize} setJobPageSize={setJobPageSize} jobColumns={jobColumns} setJobColumns={setJobColumns} themeMode={themeMode} onThemeModeChange={onThemeModeChange} accentColor={accentColor} onAccentColorChange={onAccentColorChange} onResetAccentColor={onResetAccentColor} />, errorElement: <RouteErrorPage /> },
], { future: { v7_relativeSplatPath: true } }), [jobColumns, jobPageSize, themeMode, accentColor]);
{ path: "/*", element: <Shell jobPageSize={jobPageSize} setJobPageSize={setJobPageSize} jobColumns={jobColumns} setJobColumns={setJobColumns} themeMode={themeMode} onThemeModeChange={onThemeModeChange} />, errorElement: <RouteErrorPage /> },
], { future: { v7_relativeSplatPath: true } }), [jobColumns, jobPageSize, themeMode]);
return (
<ToastProvider>
<ConfirmProvider>
<PromptProvider>
<CssVarsProvider key={`${effectiveMode}:${accentColor}`} theme={theme as any} defaultMode={effectiveMode} disableTransitionOnChange>
<CssVarsProvider key={effectiveMode} theme={theme as any} defaultMode={effectiveMode} disableTransitionOnChange>
<CssBaseline enableColorScheme />
<I18nProvider>
<RouterProvider router={router} future={{ v7_startTransition: true }} />
@@ -23,6 +23,7 @@ import AutoGraphIcon from "@mui/icons-material/AutoGraph";
import { api } from "../api";
import ViewStateNotice from "./ViewStateNotice";
import OnboardingChecklist from "./OnboardingChecklist";
import { getUserKeyFromToken } from "../themePrefs";
import { useI18n } from "../i18n/I18nProvider";
import { statusLabel } from "../pipeline";
@@ -287,6 +288,7 @@ export default function DashboardView() {
return (
<Box>
<OnboardingChecklist hasJobs={(stats?.total ?? 0) > 0} />
<SectionCard
sx={{
backgroundColor: "background.paper",
@@ -51,6 +51,7 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
const { t } = useI18n();
const [me, setMe] = useState<MeResponse | null>(null);
const [working, setWorking] = useState(false);
const [allowRegistration, setAllowRegistration] = useState(false);
const hostRef = useRef<HTMLDivElement | null>(null);
const clientId = (process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "").trim();
@@ -72,6 +73,9 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
useEffect(() => {
void refreshMe();
api.get<{ allowRegistration: boolean }>("/auth/config").then((res) => {
setAllowRegistration(Boolean(res.data?.allowRegistration));
}).catch(() => setAllowRegistration(false));
}, []);
useEffect(() => {
@@ -156,7 +160,7 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
{!signedIn ? (
<Typography sx={{ color: "text.secondary" }}>
{t("googleSignInHint")}
{allowRegistration ? t("googleSignInHintSelfServe") : t("googleSignInHint")}
</Typography>
) : me?.provider === "local" ? (
<Typography sx={{ color: "text.secondary" }}>
@@ -1132,6 +1132,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
<MatchScoreCard score={matchScore} loading={loadingMatchScore} />
{loadingCandidateFit ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : candidateFit ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2.5 }}>
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: -1 }}>{t("jobDetailsAiFitHint")}</Typography>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
<Box><Typography variant="overline">{t("jobDetailsHowYouMatch")}</Typography><Typography sx={{ whiteSpace: "pre-wrap" }}>{candidateFit.matchSummary}</Typography></Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
@@ -1230,13 +1231,14 @@ function MatchScoreCard({ score, loading }: { score: MatchScore | null; loading:
<Box sx={{ p: 1.75, mb: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
<Box sx={{ display: "flex", gap: 2.5, alignItems: "center", flexWrap: "wrap", mb: 1.5 }}>
{score.hasEnoughSignal ? (
<Box sx={{ position: "relative", width: 92, height: 92, flexShrink: 0 }}>
<CircularProgress variant="determinate" value={100} size={92} thickness={4} sx={{ color: "divider", position: "absolute" }} />
<Box role="img" aria-label={`${t("matchScoreTitle")}: ${score.score}%`} sx={{ position: "relative", width: 92, height: 92, flexShrink: 0 }}>
<CircularProgress variant="determinate" value={100} size={92} thickness={4} aria-hidden="true" sx={{ color: "divider", position: "absolute" }} />
<CircularProgress
variant="determinate"
value={score.score}
size={92}
thickness={4}
aria-hidden="true"
color={color === "inherit" ? "primary" : color}
sx={{ position: "absolute", "& .MuiCircularProgress-circle": { strokeLinecap: "round" } }}
/>
+25 -2
View File
@@ -110,6 +110,19 @@ function parseTags(raw?: string | null): string[] {
}
function EmptyJobsState({ firstTime, onOpenSettings, t }: { firstTime: boolean; onOpenSettings: () => void; t: (key: any) => string }) {
if (!firstTime) {
return <Typography sx={{ py: 2, textAlign: "center", color: "text.secondary" }}>{t("jobTableNoJobsFound")}</Typography>;
}
return (
<Box sx={{ py: 4, textAlign: "center" }}>
<Typography sx={{ fontWeight: 800, mb: 0.5 }}>{t("jobTableEmptyFirstTimeTitle")}</Typography>
<Typography sx={{ color: "text.secondary", mb: 1.5, maxWidth: 440, mx: "auto" }}>{t("jobTableEmptyFirstTimeBody")}</Typography>
<Button variant="text" onClick={onOpenSettings}>{t("jobTableEmptyFirstTimeBookmarklet")}</Button>
</Box>
);
}
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
</Paper>
);
})}
{filteredJobs.length === 0 && !jobsResource.loading && !jobsResource.error ? <Typography sx={{ py: 2, textAlign: "center" }}>{t("jobTableNoJobsFound")}</Typography> : null}
{filteredJobs.length === 0 && !jobsResource.loading && !jobsResource.error ? (
<EmptyJobsState firstTime={isFirstTimeEmpty} onOpenSettings={() => navigate("/settings")} t={t} />
) : null}
</Stack>
) : (
<Box sx={{ overflowX: "auto" }}>
@@ -721,7 +742,9 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
</React.Fragment>
);
})}
{filteredJobs.length === 0 && !jobsResource.loading && !jobsResource.error ? <TableRow><TableCell colSpan={visibleDesktopColumns}><Typography sx={{ py: 2, textAlign: "center" }}>{t("jobTableNoJobsFound")}</Typography></TableCell></TableRow> : null}
{filteredJobs.length === 0 && !jobsResource.loading && !jobsResource.error ? (
<TableRow><TableCell colSpan={visibleDesktopColumns}><EmptyJobsState firstTime={isFirstTimeEmpty} onOpenSettings={() => navigate("/settings")} t={t} /></TableCell></TableRow>
) : null}
</TableBody>
</Table>
</Box>
+14 -1
View File
@@ -101,7 +101,18 @@ export default function KanbanBoard() {
/>
{!jobsResource.loading && !jobsResource.error ? (
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)", xl: "repeat(6, 1fr)" }, gap: 2, alignItems: "start" }}>
<Box
sx={{
display: { xs: "flex", md: "grid" },
gridTemplateColumns: { md: "repeat(3, 1fr)", xl: "repeat(6, 1fr)" },
gap: 2,
alignItems: "start",
overflowX: { xs: "auto", md: "visible" },
scrollSnapType: { xs: "x mandatory", md: "none" },
pb: { xs: 1, md: 0 },
"-webkit-overflow-scrolling": "touch",
}}
>
{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),
@@ -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<boolean | null>(null);
const [dismissed, setDismissed] = useState(() => window.localStorage.getItem(dismissKey()) === "1");
useEffect(() => {
let active = true;
api.get<MeResponse>("/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 (
<Paper
sx={{
p: 2.25,
mb: 2,
borderRadius: 4,
border: "1px solid",
borderColor: alpha(theme.palette.primary.main, 0.25),
background: alpha(theme.palette.primary.main, 0.04),
position: "relative",
}}
>
<IconButton size="small" onClick={dismiss} aria-label={t("onboardingDismiss")} sx={{ position: "absolute", top: 8, right: 8 }}>
<CloseIcon fontSize="small" />
</IconButton>
<Typography sx={{ fontWeight: 900, mb: 0.25 }}>{t("onboardingTitle")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("onboardingBody")}</Typography>
<Stack spacing={1}>
{steps.map((step) => (
<Box key={step.label} sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1.5, flexWrap: "wrap" }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
{step.done ? <CheckCircleIcon fontSize="small" color="success" /> : <RadioButtonUncheckedIcon fontSize="small" sx={{ color: "text.secondary" }} />}
<Typography variant="body2" sx={{ fontWeight: step.done ? 400 : 700, color: step.done ? "text.secondary" : "text.primary", textDecoration: step.done ? "line-through" : "none" }}>
{step.label}
</Typography>
</Box>
{!step.done ? <Button size="small" variant="text" onClick={step.action}>{step.actionLabel}</Button> : null}
</Box>
))}
</Stack>
</Paper>
);
}
+55 -168
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from "react";
import React, { useEffect, useState } from "react";
import {
Box,
@@ -9,11 +9,9 @@ import {
InputLabel,
MenuItem,
Paper,
Popover,
Select,
Tab,
Tabs,
TextField,
Typography,
} from "@mui/material";
@@ -21,12 +19,9 @@ import { useNavigate } from "react-router-dom";
import { JobTableColumns } from "./JobTable";
import ImportExportJobs from "./ImportExportJobs";
import GoogleAuthCard from "./GoogleAuthCard";
import EmailProviderConnections from "./EmailProviderConnections";
import RulesSettingsCard from "./RulesSettingsCard";
import BackupCard from "./BackupCard";
import QuickCaptureCard from "./QuickCaptureCard";
import AuthStatusCard from "./AuthStatusCard";
import { ThemeModePref } from "../themePrefs";
import { useI18n } from "../i18n/I18nProvider";
@@ -37,17 +32,23 @@ interface Props {
onColumnsChange: (next: JobTableColumns) => void;
themeMode: ThemeModePref;
onThemeModeChange: (v: ThemeModePref) => void;
accentColor: string;
onAccentColorChange: (v: string) => void;
onResetAccentColor: () => void;
}
function TabPanel({ value, index, children }: { value: number; index: number; children: React.ReactNode }) {
if (value !== index) return null;
return <Box sx={{ mt: 2 }}>{children}</Box>;
return <Box sx={{ mt: 2.5 }}>{children}</Box>;
}
function SectionCard({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) {
return (
<Paper sx={{ p: 2.5 }}>
<Typography variant="overline" sx={{ color: "text.secondary", fontWeight: 800 }}>{title}</Typography>
{subtitle ? <Typography variant="body2" sx={{ color: "text.secondary", mt: 0.25, mb: 1.5 }}>{subtitle}</Typography> : <Box sx={{ mb: 1.5 }} />}
{children}
</Paper>
);
}
const ACCENTS = ["#6366f1", "#22d3ee", "#2563eb", "#8b5cf6", "#15803d", "#16a34a", "#0f766e", "#f97316"];
const NOTIFICATION_PREFS_KEY = "settings.notificationPrefs";
type NotificationPrefs = {
@@ -88,43 +89,19 @@ export default function SettingsView({
onColumnsChange,
themeMode,
onThemeModeChange,
accentColor,
onAccentColorChange,
onResetAccentColor,
}: Props) {
const navigate = useNavigate();
const [tab, setTab] = useState(0);
const { language, setLanguage, t } = useI18n();
const [accentAnchor, setAccentAnchor] = useState<HTMLElement | null>(null);
const [accentDraft, setAccentDraft] = useState(accentColor);
const [notificationPrefs, setNotificationPrefs] = useState<NotificationPrefs>(() => loadNotificationPrefs());
const accentOk = useMemo(() => /^#[0-9a-fA-F]{6}$/.test(accentColor), [accentColor]);
const accentDraftOk = useMemo(() => /^#[0-9a-fA-F]{6}$/.test(accentDraft), [accentDraft]);
useEffect(() => {
setAccentDraft(accentOk ? accentColor : "#15803d");
}, [accentColor, accentOk]);
useEffect(() => {
window.localStorage.setItem(NOTIFICATION_PREFS_KEY, JSON.stringify(notificationPrefs));
}, [notificationPrefs]);
const applyAccent = () => {
if (!accentDraftOk) return;
onAccentColorChange(accentDraft);
setAccentAnchor(null);
};
const resetAccent = () => {
onResetAccentColor();
setAccentDraft("#15803d");
setAccentAnchor(null);
};
return (
<Paper sx={{ mt: 0, p: 2 }}>
<Typography variant="h5" sx={{ mb: 1, fontWeight: 900 }}>
<Paper sx={{ mt: 0, p: 2.5 }}>
<Typography variant="h5" sx={{ mb: 0.5, fontWeight: 900 }}>
{t("settingsTitle")}
</Typography>
<Typography sx={{ color: "text.secondary", mb: 2 }}>
@@ -135,130 +112,48 @@ export default function SettingsView({
<Tab label={t("settingsTabGeneral")} />
<Tab label={t("settingsTabFollowUps")} />
<Tab label={t("settingsTabNotifications")} />
<Tab label={t("settingsTabAccount")} />
<Tab label={t("settingsTabBackup")} />
</Tabs>
<TabPanel value={tab} index={0}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
<Paper sx={{ p: 2 }}>
<Typography sx={{ fontWeight: 950, mb: 1 }}>{t("settingsAppearance")}</Typography>
<FormControl fullWidth sx={{ mb: 2 }}>
<InputLabel id="theme-mode-label">{t("settingsTheme")}</InputLabel>
<Select
labelId="theme-mode-label"
value={themeMode}
label={t("settingsTheme")}
onChange={(e) => onThemeModeChange(e.target.value as ThemeModePref)}
>
<MenuItem value="system">{t("settingsThemeSystem")}</MenuItem>
<MenuItem value="dark">{t("settingsThemeDark")}</MenuItem>
<MenuItem value="light">{t("settingsThemeLight")}</MenuItem>
</Select>
</FormControl>
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
<Box>
<Typography variant="caption" sx={{ mb: 0.75, display: "block" }}>{t("settingsAccent")}</Typography>
<Button
variant="outlined"
onClick={(e) => setAccentAnchor(e.currentTarget)}
sx={{ gap: 1.25, justifyContent: "flex-start", minWidth: 180 }}
<Box sx={{ display: "grid", gap: 2.5 }}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2.5 }}>
<SectionCard title={t("settingsAppearance")}>
<FormControl fullWidth>
<InputLabel id="theme-mode-label">{t("settingsTheme")}</InputLabel>
<Select
labelId="theme-mode-label"
value={themeMode}
label={t("settingsTheme")}
onChange={(e) => onThemeModeChange(e.target.value as ThemeModePref)}
>
<Box sx={{ width: 20, height: 20, borderRadius: 999, bgcolor: accentOk ? accentColor : "#15803d", border: "1px solid", borderColor: "divider" }} />
{accentOk ? accentColor.toUpperCase() : "#15803D"}
</Button>
</Box>
<Button variant="outlined" onClick={resetAccent}>
{t("settingsReset")}
</Button>
</Box>
<MenuItem value="system">{t("settingsThemeSystem")}</MenuItem>
<MenuItem value="dark">{t("settingsThemeDark")}</MenuItem>
<MenuItem value="light">{t("settingsThemeLight")}</MenuItem>
</Select>
</FormControl>
</SectionCard>
<Popover
open={Boolean(accentAnchor)}
anchorEl={accentAnchor}
onClose={() => setAccentAnchor(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
>
<Box sx={{ p: 2, width: 280, display: "grid", gap: 1.5 }}>
<Typography sx={{ fontWeight: 900 }}>{t("settingsAccent")}</Typography>
<input
aria-label={t("settingsAccent")}
type="color"
value={accentDraftOk ? accentDraft : "#15803d"}
onChange={(e) => setAccentDraft(e.target.value)}
style={{ width: "100%", height: 52, border: "none", background: "transparent", padding: 0, cursor: "pointer" }}
/>
<TextField
label={t("settingsAccent")}
value={accentDraft}
onChange={(e) => setAccentDraft(e.target.value)}
error={!accentDraftOk}
helperText={accentDraftOk ? t("settingsAccentHelp") : t("settingsAccentInvalid")}
fullWidth
/>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
{ACCENTS.map((c) => (
<button
key={c}
type="button"
onClick={() => setAccentDraft(c)}
title={c}
aria-label={`${t("settingsAccent")} ${c}`}
style={{
width: 28,
height: 28,
borderRadius: 999,
border: c.toLowerCase() === accentDraft.toLowerCase() ? "2px solid rgba(15,23,42,0.9)" : "1px solid rgba(148,163,184,0.35)",
background: c,
cursor: "pointer",
}}
/>
))}
</Box>
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 1 }}>
<Button variant="text" onClick={() => setAccentAnchor(null)}>{t("cancel")}</Button>
<Button variant="contained" onClick={applyAccent} disabled={!accentDraftOk}>{t("save")}</Button>
</Box>
</Box>
</Popover>
<SectionCard title={t("settingsLanguageTitle")} subtitle={t("settingsLanguageBody")}>
<FormControl fullWidth>
<InputLabel id="language-label">{t("settingsPreferredLanguage")}</InputLabel>
<Select
labelId="language-label"
value={language}
label={t("settingsPreferredLanguage")}
onChange={(e) => setLanguage(e.target.value as "en" | "no")}
>
<MenuItem value="en">{t("settingsEnglish")}</MenuItem>
<MenuItem value="no">{t("settingsNorwegian")}</MenuItem>
</Select>
</FormControl>
</SectionCard>
</Box>
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 1 }}>
{t("settingsSavedPerUser")}
</Typography>
</Paper>
<Paper sx={{ p: 2 }}>
<Typography sx={{ fontWeight: 950, mb: 1 }}>{t("settingsLanguageTitle")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 2 }}>
{t("settingsLanguageBody")}
</Typography>
<FormControl fullWidth sx={{ mb: 2 }}>
<InputLabel id="language-label">{t("settingsPreferredLanguage")}</InputLabel>
<Select
labelId="language-label"
value={language}
label={t("settingsPreferredLanguage")}
onChange={(e) => setLanguage(e.target.value as "en" | "no")}
>
<MenuItem value="en">{t("settingsEnglish")}</MenuItem>
<MenuItem value="no">{t("settingsNorwegian")}</MenuItem>
</Select>
</FormControl>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{t("settingsMorePagesSoon")}
</Typography>
</Paper>
<Paper sx={{ p: 2, gridColumn: { xs: "1 / -1", md: "1 / -1" } }}>
<Typography sx={{ fontWeight: 950, mb: 1 }}>{t("settingsJobs")}</Typography>
<Box sx={{ display: "flex", gap: 3, flexWrap: "wrap" }}>
<SectionCard title={t("settingsJobs")}>
<Box sx={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
<Box sx={{ minWidth: 240 }}>
<Typography variant="h6" sx={{ mb: 1 }}>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 700 }}>
{t("settingsPagination")}
</Typography>
<FormControl fullWidth>
@@ -277,7 +172,7 @@ export default function SettingsView({
</Box>
<Box sx={{ minWidth: 240 }}>
<Typography variant="h6" sx={{ mb: 1 }}>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 700 }}>
{t("settingsColumns")}
</Typography>
{(
@@ -297,8 +192,10 @@ export default function SettingsView({
</Box>
</Box>
<ImportExportJobs />
</Paper>
<Box sx={{ mt: 2 }}>
<ImportExportJobs />
</Box>
</SectionCard>
<QuickCaptureCard />
</Box>
@@ -309,9 +206,7 @@ export default function SettingsView({
</TabPanel>
<TabPanel value={tab} index={2}>
<Paper sx={{ p: 2 }}>
<Typography sx={{ fontWeight: 950, mb: 0.5 }}>{t("settingsNotificationsTitle")}</Typography>
<Typography sx={{ color: "text.secondary", mb: 2 }}>{t("settingsNotificationsBody")}</Typography>
<SectionCard title={t("settingsNotificationsTitle")} subtitle={t("settingsNotificationsBody")}>
<Box sx={{ display: "grid", gap: 1 }}>
<FormControlLabel
control={<Checkbox checked={notificationPrefs.emailFollowUpReminders} onChange={(e) => setNotificationPrefs((prev) => ({ ...prev, emailFollowUpReminders: e.target.checked }))} />}
@@ -333,18 +228,10 @@ export default function SettingsView({
<Button variant="outlined" onClick={() => navigate("/reminders")}>{t("settingsOpenReminderInbox")}</Button>
<Button variant="text" onClick={() => navigate("/admin/system")}>{t("settingsCheckSystemStatus")}</Button>
</Box>
</Paper>
</SectionCard>
</TabPanel>
<TabPanel value={tab} index={3}>
<AuthStatusCard />
<GoogleAuthCard />
<Box sx={{ mt: 2 }}>
<EmailProviderConnections />
</Box>
</TabPanel>
<TabPanel value={tab} index={4}>
<BackupCard />
</TabPanel>
</Paper>
+44 -18
View File
@@ -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",
@@ -128,22 +134,17 @@ export const translations = {
settingsTabGeneral: "General",
settingsTabFollowUps: "Follow-ups",
settingsTabNotifications: "Notifications",
settingsTabAccount: "Account",
settingsTabBackup: "Backup",
settingsAppearance: "Appearance",
settingsTheme: "Theme",
settingsThemeSystem: "System",
settingsThemeDark: "Dark",
settingsThemeLight: "Light",
settingsAccent: "Accent",
settingsReset: "Reset",
settingsSavedPerUser: "Saved per user on this browser.",
settingsLanguageTitle: "Language and localization",
settingsLanguageBody: "Set your preferred app language. This is also the language used when deciding whether imported job descriptions should show translated text.",
settingsPreferredLanguage: "Preferred language",
settingsEnglish: "English",
settingsNorwegian: "Norwegian Bokmål",
settingsMorePagesSoon: "More pages will be moved onto this translation system as the UI cleanup continues.",
settingsJobs: "Jobs",
settingsPagination: "Pagination",
settingsRowsPerPage: "Rows per page",
@@ -167,8 +168,6 @@ export const translations = {
settingsNotificationsFollowUpReminders: "Email reminders for follow-ups",
settingsNotificationsGhostedJobs: "Email alerts for ghosted jobs",
settingsNotificationsInAppReminders: "Highlight reminders in the app",
settingsAccentHelp: "Drag in the color picker, then save when it looks right.",
settingsAccentInvalid: "Use a full hex color like #15803D.",
settingsCheckSystemStatus: "Check system status",
profileTitle: "Profile",
profileHeadlinePlaceholder: "Add a short headline to personalize your account view.",
@@ -313,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",
@@ -613,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",
@@ -629,7 +638,7 @@ export const translations = {
googleUnlinked: "Google account unlinked.",
googleUnlinkFailed: "Failed to unlink Google account.",
microsoftAccountTitle: "Microsoft account",
microsoftSetupHint: "Set `REACT_APP_MICROSOFT_CLIENT_ID` in your UI environment to enable Microsoft sign-in and account linking.",
microsoftSetupHint: "Set `NEXT_PUBLIC_MICROSOFT_CLIENT_ID` in your UI environment to enable Microsoft sign-in and account linking.",
microsoftLinked: "Linked",
microsoftAvailableToLink: "Available to link",
microsoftLinkedDate: "Linked {date}",
@@ -757,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.",
@@ -901,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",
@@ -909,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.",
@@ -983,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",
@@ -1093,22 +1112,17 @@ export const translations = {
settingsTabGeneral: "Generelt",
settingsTabFollowUps: "Oppfølging",
settingsTabNotifications: "Varsler",
settingsTabAccount: "Konto",
settingsTabBackup: "Sikkerhetskopi",
settingsAppearance: "Utseende",
settingsTheme: "Tema",
settingsThemeSystem: "System",
settingsThemeDark: "Mørkt",
settingsThemeLight: "Lyst",
settingsAccent: "Aksent",
settingsReset: "Tilbakestill",
settingsSavedPerUser: "Lagres per bruker i denne nettleseren.",
settingsLanguageTitle: "Språk og lokalisering",
settingsLanguageBody: "Velg foretrukket språk i appen. Dette brukes også når appen avgjør om importerte stillingsbeskrivelser skal vise oversatt tekst.",
settingsPreferredLanguage: "Foretrukket språk",
settingsEnglish: "Engelsk",
settingsNorwegian: "Norsk Bokmål",
settingsMorePagesSoon: "Flere sider flyttes til dette oversettelsessystemet etter hvert som UI-oppryddingen fortsetter.",
settingsJobs: "Jobber",
settingsPagination: "Paginering",
settingsRowsPerPage: "Rader per side",
@@ -1132,8 +1146,6 @@ export const translations = {
settingsNotificationsFollowUpReminders: "E-postpåminnelser for oppfølginger",
settingsNotificationsGhostedJobs: "E-postvarsler for ghostede jobber",
settingsNotificationsInAppReminders: "Fremhev påminnelser i appen",
settingsAccentHelp: "Dra i fargevelgeren, og lagre når den ser riktig ut.",
settingsAccentInvalid: "Bruk en full hex-farge som #15803D.",
settingsCheckSystemStatus: "Sjekk systemstatus",
profileTitle: "Profil",
profileHeadlinePlaceholder: "Legg til en kort overskrift for å gjøre kontovisningen mer personlig.",
@@ -1278,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",
@@ -1578,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",
@@ -1594,7 +1616,7 @@ export const translations = {
googleUnlinked: "Google-konto koblet fra.",
googleUnlinkFailed: "Kunne ikke koble fra Google-kontoen.",
microsoftAccountTitle: "Microsoft-konto",
microsoftSetupHint: "Sett `REACT_APP_MICROSOFT_CLIENT_ID` i UI-miljøet ditt for å aktivere Microsoft-innlogging og kontokobling.",
microsoftSetupHint: "Sett `NEXT_PUBLIC_MICROSOFT_CLIENT_ID` i UI-miljøet ditt for å aktivere Microsoft-innlogging og kontokobling.",
microsoftLinked: "Koblet",
microsoftAvailableToLink: "Tilgjengelig for kobling",
microsoftLinkedDate: "Koblet {date}",
@@ -1722,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.",
@@ -1866,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",
@@ -1874,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å.",
+7
View File
@@ -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({
<Typography variant="h5" sx={{ fontWeight: 600, overflowWrap: "anywhere" }}>
{pageTitle}
</Typography>
{pageSubtitle ? (
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.25, overflowWrap: "anywhere" }}>
{pageSubtitle}
</Typography>
) : null}
</Box>
</Box>
+21 -34
View File
@@ -1,6 +1,6 @@
import React from 'react';
import '@testing-library/jest-dom';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import SettingsView from './components/SettingsView';
@@ -21,35 +21,27 @@ jest.mock('./api', () => ({
}));
jest.mock('./components/ImportExportJobs', () => () => <div>Import Export Stub</div>);
jest.mock('./components/GoogleAuthCard', () => () => <div>Google Auth Stub</div>);
jest.mock('./components/BackupCard', () => () => <div>Backup Stub</div>);
jest.mock('./components/AuthStatusCard', () => () => <div>Auth Status Stub</div>);
const mockedApi = api as jest.Mocked<typeof api>;
function renderView(onAccentColorChange = jest.fn()) {
return {
onAccentColorChange,
...render(
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<ToastProvider>
<I18nProvider>
<SettingsView
pageSize={20}
onPageSizeChange={jest.fn()}
columns={{ status: true, dateApplied: true, daysSince: true, jobUrl: false }}
onColumnsChange={jest.fn()}
themeMode="dark"
onThemeModeChange={jest.fn()}
accentColor="#15803d"
onAccentColorChange={onAccentColorChange}
onResetAccentColor={jest.fn()}
/>
</I18nProvider>
</ToastProvider>
</MemoryRouter>,
),
};
function renderView() {
return render(
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<ToastProvider>
<I18nProvider>
<SettingsView
pageSize={20}
onPageSizeChange={jest.fn()}
columns={{ status: true, dateApplied: true, daysSince: true, jobUrl: false }}
onColumnsChange={jest.fn()}
themeMode="dark"
onThemeModeChange={jest.fn()}
/>
</I18nProvider>
</ToastProvider>
</MemoryRouter>,
);
}
beforeEach(() => {
@@ -76,15 +68,10 @@ afterEach(() => {
jest.clearAllMocks();
});
test('settings view uses one follow-up section, one notification section, and staged accent apply', async () => {
const { onAccentColorChange } = renderView();
test('settings view has no accent picker and uses one follow-up section, one notification section', async () => {
renderView();
fireEvent.click(screen.getByRole('button', { name: /#15803D/i }));
const accentInput = (await screen.findAllByLabelText('Accent'))[1] as HTMLInputElement;
fireEvent.change(accentInput, { target: { value: '#2563eb' } });
expect(onAccentColorChange).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
expect(onAccentColorChange).toHaveBeenCalledWith('#2563eb');
expect(screen.queryByText(/accent/i)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: /follow-ups/i }));
expect(await screen.findByText(/follow-up rules by scenario/i)).toBeInTheDocument();
+13 -9
View File
@@ -2,6 +2,10 @@ import { alpha, createTheme, darken, lighten } from "@mui/material/styles";
type PaletteLike = Record<string, any>;
// Single global brand accent -- matches the dark sidebar/landing page indigo used throughout
// the app. Not user-configurable; see jobbjakt-nextjs-migration memory / UI rework notes.
const ACCENT = "#6366F1";
function buildPrimary(main: string) {
return {
lighter: lighten(main, 0.82),
@@ -12,7 +16,7 @@ function buildPrimary(main: string) {
};
}
function buildLightPalette(accentColor: string): PaletteLike {
function buildLightPalette(): PaletteLike {
const textPrimary = "#1B1B1F";
const textSecondary = "#46464F";
@@ -24,7 +28,7 @@ function buildLightPalette(accentColor: string): PaletteLike {
const disabledBackground = "#E4E1E6";
return {
primary: buildPrimary(accentColor || "#6366F1"),
primary: buildPrimary(ACCENT),
secondary: {
lighter: "#E0E0FF",
light: "#C3C4E4",
@@ -82,14 +86,14 @@ function buildLightPalette(accentColor: string): PaletteLike {
// from the product mockups; cards/inputs (paper) sit above it.
background: { default: "#F4F6FB", paper: background },
action: {
hover: alpha(accentColor || "#6366F1", 0.05),
hover: alpha(ACCENT, 0.05),
disabled: alpha(disabled, 0.6),
disabledBackground: alpha(disabledBackground, 0.9),
},
};
}
function buildDarkPalette(accentColor: string): PaletteLike {
function buildDarkPalette(): PaletteLike {
const bg = "#0B0B0E";
const paper = "#111116";
const divider = alpha("#FFFFFF", 0.10);
@@ -101,7 +105,7 @@ function buildDarkPalette(accentColor: string): PaletteLike {
const disabledBackground = alpha("#FFFFFF", 0.08);
return {
primary: buildPrimary(accentColor || "#6366F1"),
primary: buildPrimary(ACCENT),
secondary: {
lighter: alpha(secondaryMain, 0.22),
light: alpha(secondaryMain, 0.14),
@@ -157,7 +161,7 @@ function buildDarkPalette(accentColor: string): PaletteLike {
divider,
background: { default: bg, paper },
action: {
hover: alpha(accentColor || "#6366F1", 0.16),
hover: alpha(ACCENT, 0.16),
disabled: alpha("#FFFFFF", 0.5),
disabledBackground,
},
@@ -196,9 +200,9 @@ function buildTypography() {
};
}
export const getTheme = (_mode: "light" | "dark", accentColor: string) => {
const lightPalette = buildLightPalette(accentColor);
const darkPalette = buildDarkPalette(accentColor);
export const getTheme = (_mode: "light" | "dark") => {
const lightPalette = buildLightPalette();
const darkPalette = buildDarkPalette();
const theme = createTheme({
breakpoints: {
-14
View File
@@ -19,17 +19,3 @@ export function getThemeModePref(): ThemeModePref {
export function setThemeModePref(v: ThemeModePref) {
window.localStorage.setItem(k("themeMode"), v);
}
export function getAccentColor(): string {
const raw = window.localStorage.getItem(k("accentColor"));
if (raw && /^#[0-9a-fA-F]{6}$/.test(raw)) return raw;
return "#6366f1";
}
export function setAccentColor(v: string) {
if (v && /^#[0-9a-fA-F]{6}$/.test(v)) window.localStorage.setItem(k("accentColor"), v);
}
export function clearAccentColor() {
window.localStorage.removeItem(k("accentColor"));
}
@@ -82,10 +82,11 @@ export default function CorrespondenceInboxPage() {
Cross-job view of imported correspondence and Gmail-linked history.
</Typography>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
<Chip icon={<MailOutlineIcon />} label={`${items.length} items`} variant="outlined" />
<Chip label={`${filteredSummary.linked} linked`} variant="outlined" color={filteredSummary.linked > 0 ? "success" : "default"} />
<Chip label={`${filteredSummary.inbound} inbound`} variant="outlined" />
<Button variant="outlined" size="small" onClick={() => navigate("/correspondence/review")}>Review Gmail queue</Button>
</Box>
</Box>
@@ -138,6 +138,7 @@ export default function GmailReviewPage() {
<Button variant="outlined" onClick={() => void load()} disabled={loading || syncing}>
{loading ? "Loading..." : "Refresh"}
</Button>
<Button variant="text" onClick={() => navigate("/correspondence")}>Back to inbox</Button>
</Box>
</Box>
+1 -1
View File
@@ -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]);
+1 -1
View File
@@ -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
+6
View File
@@ -10,6 +10,8 @@ import ZoomInOutlinedIcon from "@mui/icons-material/ZoomInOutlined";
import { api, getApiErrorMessage } from "../api";
import GoogleAuthCard from "../components/GoogleAuthCard";
import MicrosoftAuthCard from "../components/MicrosoftAuthCard";
import AuthStatusCard from "../components/AuthStatusCard";
import EmailProviderConnections from "../components/EmailProviderConnections";
import CropImageDialog from "../components/CropImageDialog";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
@@ -562,8 +564,12 @@ export default function ProfilePage() {
</Box>
</Box>
<AuthStatusCard />
<GoogleAuthCard />
<MicrosoftAuthCard />
<Box sx={{ mt: 2 }}>
<EmailProviderConnections />
</Box>
<Box sx={{ mt: 3, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
<Box sx={{ gridColumn: "1 / -1" }}>