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(() => { 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(null); const guideTitle = useRef(null); const guideEntry = useRef(null); const resumeEntry = useRef(null); const returnToEntry = useRef(false); const stepActions = useRef>({}); const pendingStepFocus = useRef(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(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("/auth/me"), api.get("/career/profile"), Promise.all(["/gmail/status", "/microsoft-graph/status", "/imap/status"].map((url) => api.get(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 {t('onboardingResumeTitle')} {t('onboardingResumeBody')} {!allDone ? : null} ; const guideHelp = [t('onboardingGuideProfile'), t('onboardingGuideCareer'), t('onboardingGuideEmail'), t('onboardingGuideJob')]; return ( { try { window.localStorage.setItem(dismissalKey, "true"); } catch { /* This dismissal still applies while the page remains open. */ } returnToEntry.current = true; setGuideStep(null); setDismissed(true); }} > }> {loading ? <> {t("loading")} : loadError ? ( { // Retry is replaced by loading feedback; keep focus inside this setup section. dismissRef.current?.focus(); setReadAttempt((attempt) => attempt + 1); }}>{t("retry")} }>{getApiErrorMessage(loadError.cause, t("onboardingLoadFailed"))} ) : status ? <> {hasJobs === null ? ( {t("onboardingJobStatusUnknown")} ) : {completed} / {steps.length}} {steps.map((step) => ( {step.done === null ? null : step.done ? : } {step.label} {step.done === false ? {skipped.includes(step.path) ? <> {t('onboardingSkipped')} : <> } : null} ))} {skipped.length ? {t('onboardingSkipHelp')} : null} : null} {guideStep !== null ? {t('onboardingGuideCount', { current: guideStep + 1, total: steps.length })} : null} ); }