feat(ux): onboarding checklist, dashboard-first landing (fixed)
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.
This commit is contained in:
@@ -23,6 +23,7 @@ import AutoGraphIcon from "@mui/icons-material/AutoGraph";
|
|||||||
|
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import ViewStateNotice from "./ViewStateNotice";
|
import ViewStateNotice from "./ViewStateNotice";
|
||||||
|
import OnboardingChecklist from "./OnboardingChecklist";
|
||||||
import { getUserKeyFromToken } from "../themePrefs";
|
import { getUserKeyFromToken } from "../themePrefs";
|
||||||
import { useI18n } from "../i18n/I18nProvider";
|
import { useI18n } from "../i18n/I18nProvider";
|
||||||
import { statusLabel } from "../pipeline";
|
import { statusLabel } from "../pipeline";
|
||||||
@@ -287,6 +288,7 @@ export default function DashboardView() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
|
<OnboardingChecklist hasJobs={(stats?.total ?? 0) > 0} />
|
||||||
<SectionCard
|
<SectionCard
|
||||||
sx={{
|
sx={{
|
||||||
backgroundColor: "background.paper",
|
backgroundColor: "background.paper",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -312,6 +312,15 @@ export const translations = {
|
|||||||
cropDialogSave: "Save image",
|
cropDialogSave: "Save image",
|
||||||
dashboardOverviewTitle: "Dashboard overview",
|
dashboardOverviewTitle: "Dashboard overview",
|
||||||
dashboardHeroLabel: "Job search 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",
|
dashboardResponseRate: "{rate}% response rate",
|
||||||
dashboardMonthsShort: "{count} mo",
|
dashboardMonthsShort: "{count} mo",
|
||||||
dashboardAppliedCount: "{count} applied",
|
dashboardAppliedCount: "{count} applied",
|
||||||
@@ -1281,6 +1290,15 @@ export const translations = {
|
|||||||
cropDialogSave: "Lagre bilde",
|
cropDialogSave: "Lagre bilde",
|
||||||
dashboardOverviewTitle: "Dashboard-oversikt",
|
dashboardOverviewTitle: "Dashboard-oversikt",
|
||||||
dashboardHeroLabel: "Oversikt over jobbsøket",
|
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",
|
dashboardResponseRate: "{rate}% svarrate",
|
||||||
dashboardMonthsShort: "{count} md",
|
dashboardMonthsShort: "{count} md",
|
||||||
dashboardAppliedCount: "{count} søkt",
|
dashboardAppliedCount: "{count} søkt",
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export default function LandingPage() {
|
|||||||
let active = true;
|
let active = true;
|
||||||
api
|
api
|
||||||
.get("/auth/me")
|
.get("/auth/me")
|
||||||
.then(() => { if (active) navigate("/jobs", { replace: true }); })
|
.then(() => { if (active) navigate("/dashboard", { replace: true }); })
|
||||||
.catch(() => { if (active) setChecking(false); });
|
.catch(() => { if (active) setChecking(false); });
|
||||||
return () => { active = false; };
|
return () => { active = false; };
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export default function LoginPage() {
|
|||||||
const [rememberMe, setRememberMe] = useState(() => getRememberMePref());
|
const [rememberMe, setRememberMe] = useState(() => getRememberMePref());
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const nextPath = (location?.state?.from as string | undefined) ?? "/jobs";
|
const nextPath = (location?.state?.from as string | undefined) ?? "/dashboard";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api
|
api
|
||||||
|
|||||||
Reference in New Issue
Block a user