228 lines
12 KiB
TypeScript
228 lines
12 KiB
TypeScript
import React, { useEffect, useRef, useState } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
|
|
import { Alert, Box, Button, IconButton, LinearProgress, 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 { api, getApiErrorMessage } from "../api";
|
|
import { useI18n } from "../i18n/I18nProvider";
|
|
import { getUserKeyFromToken } from "../themePrefs";
|
|
import { PageSection } from "../layout/PageLayout";
|
|
|
|
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 | null }) {
|
|
const navigate = useNavigate();
|
|
const { t } = useI18n();
|
|
// The session boundary disposes this owner before another account is adopted.
|
|
const [dismissalKey] = useState(() => `onboardingDismissed:${getUserKeyFromToken()}`);
|
|
const [dismissed, setDismissed] = useState(() => {
|
|
try { return window.localStorage.getItem(dismissalKey) === "true"; }
|
|
catch { return false; }
|
|
});
|
|
const skippedKey = dismissalKey.replace('onboardingDismissed:', 'onboardingSkipped:');
|
|
const [skipped, setSkipped] = useState<string[]>(() => {
|
|
try {
|
|
const value: unknown = JSON.parse(localStorage.getItem(skippedKey) ?? '[]');
|
|
return Array.isArray(value) ? ['/profile', '/career', '/settings/connected-accounts', '/jobs'].filter(path => value.includes(path)) : [];
|
|
} catch { return []; }
|
|
});
|
|
const [guideStep, setGuideStep] = useState<number | null>(null);
|
|
const guideTitle = useRef<HTMLHeadingElement>(null);
|
|
const guideEntry = useRef<HTMLButtonElement>(null);
|
|
const resumeEntry = useRef<HTMLButtonElement>(null);
|
|
const returnToEntry = useRef(false);
|
|
const stepActions = useRef<Record<string, HTMLButtonElement | null>>({});
|
|
const pendingStepFocus = useRef<string | null>(null);
|
|
const [status, setStatus] = useState<{ profile: boolean; cv: boolean; email: boolean } | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [loadError, setLoadError] = useState<{ cause: unknown } | null>(null);
|
|
const [readAttempt, setReadAttempt] = useState(0);
|
|
const dismissRef = useRef<HTMLButtonElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (guideStep !== null) guideTitle.current?.focus();
|
|
else if (returnToEntry.current) {
|
|
returnToEntry.current = false;
|
|
(resumeEntry.current ?? guideEntry.current)?.focus();
|
|
}
|
|
}, [guideStep, dismissed]);
|
|
|
|
useEffect(() => {
|
|
if (!pendingStepFocus.current) return;
|
|
(stepActions.current[pendingStepFocus.current] ?? guideEntry.current)?.focus();
|
|
pendingStepFocus.current = null;
|
|
}, [skipped]);
|
|
|
|
const resume = (walkthrough: boolean) => {
|
|
try { localStorage.setItem(dismissalKey, 'false'); } catch { /* Session-only continuation remains available. */ }
|
|
if (!walkthrough) returnToEntry.current = true;
|
|
setDismissed(false); setGuideStep(walkthrough ? 0 : null);
|
|
};
|
|
const skip = (path: string, value: boolean) => {
|
|
const next = skipped.filter(item => item !== path);
|
|
if (value) next.push(path);
|
|
try { localStorage.setItem(skippedKey, JSON.stringify(next)); } catch { /* Keep this session's choice. */ }
|
|
pendingStepFocus.current = path;
|
|
setSkipped(next);
|
|
};
|
|
const closeGuide = () => { returnToEntry.current = true; setGuideStep(null); };
|
|
|
|
useEffect(() => {
|
|
if (dismissed) return;
|
|
let active = true;
|
|
setLoading(true);
|
|
setLoadError(null);
|
|
setStatus(null);
|
|
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)),
|
|
)),
|
|
]).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((cause: unknown) => {
|
|
if (active) setLoadError({ cause });
|
|
}).finally(() => {
|
|
if (active) setLoading(false);
|
|
});
|
|
return () => { active = false; };
|
|
}, [dismissed, readAttempt]);
|
|
|
|
// These four recorded steps are guidance, not a completeness score or proof of
|
|
// verified email, an imported CV, a rendered PDF, or a live mailbox grant.
|
|
const steps = [
|
|
{ done: status?.profile ?? null, label: t("onboardingStepProfile"), path: "/profile", actionLabel: t("onboardingStepProfileAction") },
|
|
{ done: status?.cv ?? null, label: t("onboardingStepCv"), path: "/career", actionLabel: t("onboardingStepCvAction") },
|
|
{ done: status?.email ?? null, label: t("onboardingStepEmail"), path: "/settings/connected-accounts", actionLabel: t("onboardingStepEmailAction") },
|
|
{ done: hasJobs, label: t("onboardingStepJob"), path: "/jobs", actionLabel: t("onboardingStepJobAction") },
|
|
];
|
|
const completed = steps.filter((step) => step.done === true).length;
|
|
const allDone = Boolean(status && hasJobs !== null && completed === steps.length);
|
|
if (dismissed || (allDone && guideStep === null)) return <Box component="section" aria-label={t('onboardingResumeTitle')}
|
|
sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 2, borderTop: '1px solid', borderColor: 'divider', pt: 2 }}>
|
|
<Box sx={{ minWidth: 0 }}><Typography sx={{ fontWeight: 700 }}>{t('onboardingResumeTitle')}</Typography>
|
|
<Typography variant="body2" color="text.secondary">{t('onboardingResumeBody')}</Typography></Box>
|
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
|
{!allDone ? <Button ref={resumeEntry} onClick={() => resume(false)}>{t('onboardingResume')}</Button> : null}
|
|
<Button ref={guideEntry} onClick={() => resume(true)}>{t('onboardingWalkthrough')}</Button>
|
|
</Box>
|
|
</Box>;
|
|
const guideHelp = [t('onboardingGuideProfile'), t('onboardingGuideCareer'), t('onboardingGuideEmail'), t('onboardingGuideJob')];
|
|
|
|
return (
|
|
<PageSection surface title={t("onboardingTitle")} description={t("onboardingBody")} actions={
|
|
<IconButton
|
|
ref={dismissRef}
|
|
size="small"
|
|
aria-label={t("onboardingDismiss")}
|
|
onClick={() => {
|
|
try { window.localStorage.setItem(dismissalKey, "true"); }
|
|
catch { /* This dismissal still applies while the page remains open. */ }
|
|
returnToEntry.current = true;
|
|
setGuideStep(null); setDismissed(true);
|
|
}}
|
|
>
|
|
<CloseIcon fontSize="small" />
|
|
</IconButton>
|
|
}>
|
|
<Button ref={guideEntry} onClick={() => setGuideStep(0)} sx={{ mb: 1 }}>{t('onboardingWalkthrough')}</Button>
|
|
{loading ? <>
|
|
<Typography role="status" variant="body2" color="text.secondary">{t("loading")}</Typography>
|
|
<LinearProgress aria-label={t("loading")} sx={{ mt: 1.5 }} />
|
|
</> : loadError ? (
|
|
<Alert severity="error" sx={{ overflowWrap: "anywhere" }} action={
|
|
<Button color="inherit" onClick={() => {
|
|
// Retry is replaced by loading feedback; keep focus inside this setup section.
|
|
dismissRef.current?.focus();
|
|
setReadAttempt((attempt) => attempt + 1);
|
|
}}>{t("retry")}</Button>
|
|
}>{getApiErrorMessage(loadError.cause, t("onboardingLoadFailed"))}</Alert>
|
|
) : status ? <>
|
|
{hasJobs === null ? (
|
|
<Typography role="status" variant="body2" color="text.secondary">{t("onboardingJobStatusUnknown")}</Typography>
|
|
) : <Typography variant="caption" color="text.secondary">{completed} / {steps.length}</Typography>}
|
|
<LinearProgress
|
|
variant={hasJobs === null ? "indeterminate" : "determinate"}
|
|
value={hasJobs === null ? undefined : (completed / steps.length) * 100}
|
|
aria-label={hasJobs === null ? t("onboardingJobStatusUnknown") : t("checklistProgress", { completed, total: steps.length })}
|
|
sx={{ my: 1.5 }}
|
|
/>
|
|
<Box component="ul" sx={{ display: "grid", gap: 1, listStyle: "none", m: 0, p: 0, minWidth: 0 }}>
|
|
{steps.map((step) => (
|
|
<Box component="li" key={step.path} sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1.5, flexWrap: "wrap", minWidth: 0 }}>
|
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1, minWidth: 0, flex: "1 1 12rem" }}>
|
|
{step.done === null ? null : step.done ? <CheckCircleIcon fontSize="small" color="success" titleAccess={t("checklistProgress", { completed: 1, total: 1 })} sx={{ flexShrink: 0 }} /> : <RadioButtonUncheckedIcon fontSize="small" titleAccess={t("checklistProgress", { completed: 0, total: 1 })} sx={{ color: "text.secondary", flexShrink: 0 }} />}
|
|
<Typography variant="body2" sx={{ fontWeight: step.done ? 400 : 700, color: step.done ? "text.secondary" : "text.primary", textDecoration: step.done ? "line-through" : "none", overflowWrap: "anywhere" }}>
|
|
{step.label}
|
|
</Typography>
|
|
</Box>
|
|
{step.done === false ? <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
|
{skipped.includes(step.path) ? <>
|
|
<Typography variant="caption" sx={{ alignSelf: 'center' }}>{t('onboardingSkipped')}</Typography>
|
|
<Button ref={node => { stepActions.current[step.path] = node; }} aria-label={t('onboardingResumeStep', { name: step.label })} onClick={() => skip(step.path, false)}>{t('onboardingResumeStepAction')}</Button>
|
|
</> : <>
|
|
<Button ref={node => { stepActions.current[step.path] = node; }} size="small" variant="text" sx={{ maxWidth: "100%", whiteSpace: "normal", overflowWrap: "anywhere" }} onClick={() => navigate(step.path)}>{step.actionLabel}</Button>
|
|
<Button aria-label={t('onboardingSkipStep', { name: step.label })} onClick={() => skip(step.path, true)}>{t('onboardingSkipAction')}</Button>
|
|
</>}
|
|
</Box> : null}
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
{skipped.length ? <Typography variant="caption" sx={{ display: 'block', mt: 1 }}>{t('onboardingSkipHelp')}</Typography> : null}
|
|
</> : null}
|
|
{guideStep !== null ? <Box sx={{ mt: 3, pt: 2, borderTop: '1px solid', borderColor: 'divider' }}>
|
|
<PageSection title={steps[guideStep].label} titleRef={guideTitle} level="h3" description={guideHelp[guideStep]}>
|
|
<Typography role="status" variant="body2">{t('onboardingGuideCount', { current: guideStep + 1, total: steps.length })}</Typography>
|
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mt: 1 }}>
|
|
<Button onClick={() => navigate(steps[guideStep].path)}>{steps[guideStep].actionLabel}</Button>
|
|
<Button disabled={guideStep === 0} onClick={() => setGuideStep(guideStep - 1)}>{t('onboardingGuidePrevious')}</Button>
|
|
<Button onClick={() => guideStep === steps.length - 1 ? closeGuide() : setGuideStep(guideStep + 1)}>{guideStep === steps.length - 1 ? t('onboardingGuideFinish') : t('onboardingGuideNext')}</Button>
|
|
<Button onClick={closeGuide}>{t('onboardingGuideClose')}</Button>
|
|
</Box>
|
|
</PageSection>
|
|
</Box> : null}
|
|
</PageSection>
|
|
);
|
|
}
|