feat: complete phase 2 UX improvements
This commit is contained in:
@@ -1,68 +1,92 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { Box, Button, IconButton, Paper, Stack, Typography } from "@mui/material";
|
||||
import { Box, Button, LinearProgress, 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 };
|
||||
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 [hasCv, setHasCv] = useState<boolean | null>(null);
|
||||
const [dismissed, setDismissed] = useState(() => window.localStorage.getItem(dismissKey()) === "1");
|
||||
const [status, setStatus] = useState<{ profile: boolean; cv: boolean; email: boolean } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
api.get<MeResponse>("/auth/me")
|
||||
.then((r) => { if (active) setHasCv(Boolean(r.data?.profileCvText?.trim())); })
|
||||
.catch(() => { if (active) setHasCv(false); });
|
||||
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; };
|
||||
}, []);
|
||||
|
||||
const allDone = hasCv === true && hasJobs;
|
||||
if (dismissed || allDone || hasCv === null) return null;
|
||||
|
||||
const dismiss = () => {
|
||||
window.localStorage.setItem(dismissKey(), "1");
|
||||
setDismissed(true);
|
||||
};
|
||||
if (!status) return null;
|
||||
|
||||
const steps = [
|
||||
{ done: hasCv, label: t("onboardingStepCv"), action: () => navigate("/profile"), actionLabel: t("onboardingStepCvAction") },
|
||||
{ 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") },
|
||||
{ done: hasCv === true && hasJobs, label: t("onboardingStepMatch"), action: () => navigate("/jobs"), actionLabel: t("onboardingStepMatchAction") },
|
||||
];
|
||||
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),
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<IconButton size="small" onClick={dismiss} aria-label={t("onboardingDismiss")} sx={{ position: "absolute", top: 8, right: 8 }}>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<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) }}>
|
||||
<Typography sx={{ fontWeight: 900, mb: 0.25 }}>{t("onboardingTitle")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("onboardingBody")}</Typography>
|
||||
<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} 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" }}>
|
||||
@@ -72,7 +96,7 @@ export default function OnboardingChecklist({ hasJobs }: { hasJobs: boolean }) {
|
||||
{step.label}
|
||||
</Typography>
|
||||
</Box>
|
||||
{!step.done ? <Button size="small" variant="text" onClick={step.action}>{step.actionLabel}</Button> : null}
|
||||
{!step.done && step.action ? <Button size="small" variant="text" onClick={step.action}>{step.actionLabel}</Button> : null}
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
Reference in New Issue
Block a user