Files
jobtrackingapp/job-tracker-ui/src/components/OnboardingChecklist.tsx
T
cesnimda 4c384b76ad
CI and Deploy / test (push) Successful in 7m8s
CI and Deploy / deploy (push) Successful in 1m37s
fix(a11y): label onboarding progress
2026-09-01 15:46:30 +02:00

127 lines
5.6 KiB
TypeScript

import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Box, Button, IconButton, LinearProgress, Paper, Stack, Typography } from "@mui/material";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import CloseIcon from "@mui/icons-material/Close";
import RadioButtonUncheckedIcon from "@mui/icons-material/RadioButtonUnchecked";
import { alpha, useTheme } from "@mui/material/styles";
import { api } from "../api";
import { useI18n } from "../i18n/I18nProvider";
import { getUserKeyFromToken } from "../themePrefs";
type MeResponse = {
email?: string | null;
firstName?: string | null;
lastName?: string | null;
displayName?: string | null;
};
type CareerProfileResponse = {
cvText?: string | null;
profile?: {
summary?: string[];
jobs?: unknown[];
education?: unknown[];
projects?: unknown[];
certifications?: unknown[];
languages?: unknown[];
otherSections?: unknown[];
skills?: string[];
interests?: string[];
};
};
type ConnectionStatus = { connected?: boolean };
export default function OnboardingChecklist({ hasJobs }: { hasJobs: boolean }) {
const theme = useTheme();
const navigate = useNavigate();
const { t } = useI18n();
const dismissalKey = `onboardingDismissed:${getUserKeyFromToken()}`;
const [dismissed, setDismissed] = useState(() => window.localStorage.getItem(dismissalKey) === "true");
const [status, setStatus] = useState<{ profile: boolean; cv: boolean; email: boolean } | null>(null);
useEffect(() => {
let active = true;
Promise.all([
api.get<MeResponse>("/auth/me"),
api.get<CareerProfileResponse>("/career/profile"),
Promise.all(["/gmail/status", "/microsoft-graph/status", "/imap/status"].map((url) =>
api.get<ConnectionStatus>(url).then((r) => Boolean(r.data?.connected)).catch(() => false),
)),
]).then(([me, career, connections]) => {
if (!active) return;
const profile = career.data?.profile;
const structuredCount = [
...(profile?.summary ?? []),
...(profile?.jobs ?? []),
...(profile?.education ?? []),
...(profile?.projects ?? []),
...(profile?.certifications ?? []),
...(profile?.languages ?? []),
...(profile?.otherSections ?? []),
...(profile?.skills ?? []),
...(profile?.interests ?? []),
].length;
setStatus({
profile: Boolean(me.data?.displayName?.trim() || me.data?.firstName?.trim() || me.data?.lastName?.trim()),
cv: structuredCount > 0 || Boolean(career.data?.cvText?.trim()),
email: connections.some(Boolean),
});
}).catch(() => { if (active) setStatus({ profile: false, cv: false, email: false }); });
return () => { active = false; };
}, []);
if (!status || dismissed) return null;
const steps = [
{ done: true, label: t("onboardingStepSignup"), actionLabel: "" },
{ done: true, label: t("onboardingStepVerify"), actionLabel: "" },
{ done: status.profile, label: t("onboardingStepProfile"), action: () => navigate("/profile"), actionLabel: t("onboardingStepProfileAction") },
{ done: status.cv, label: t("onboardingStepCv"), action: () => navigate("/career"), actionLabel: t("onboardingStepCvAction") },
{ done: status.email, label: t("onboardingStepEmail"), action: () => navigate("/settings/connected-accounts"), actionLabel: t("onboardingStepEmailAction") },
{ done: hasJobs, label: t("onboardingStepJob"), action: () => navigate("/jobs"), actionLabel: t("onboardingStepJobAction") },
];
const completed = steps.filter((step) => step.done).length;
if (completed === steps.length) return null;
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) }}>
<Box sx={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 1 }}>
<Typography sx={{ fontWeight: 900, mb: 0.25 }}>{t("onboardingTitle")}</Typography>
<IconButton
size="small"
aria-label={t("onboardingDismiss")}
onClick={() => {
window.localStorage.setItem(dismissalKey, "true");
setDismissed(true);
}}
>
<CloseIcon fontSize="small" />
</IconButton>
</Box>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("onboardingBody")}</Typography>
<Typography variant="caption" sx={{ color: "text.secondary" }}>{completed} / {steps.length}</Typography>
<LinearProgress
variant="determinate"
value={(completed / steps.length) * 100}
aria-label={t("checklistProgress", { completed, total: steps.length })}
sx={{ my: 1.5, borderRadius: 2 }}
/>
<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 && step.action ? <Button size="small" variant="text" onClick={step.action}>{step.actionLabel}</Button> : null}
</Box>
))}
</Stack>
</Paper>
);
}