feat(ui): add A1 dashboard and resumable onboarding

This commit is contained in:
cesnimda
2026-11-03 09:45:57 +01:00
parent 5e8a07d13e
commit c7d26d354c
61 changed files with 1522 additions and 23 deletions
@@ -42,12 +42,54 @@ export default function OnboardingChecklist({ hasJobs }: { hasJobs: boolean | nu
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;
@@ -87,8 +129,6 @@ export default function OnboardingChecklist({ hasJobs }: { hasJobs: boolean | nu
return () => { active = false; };
}, [dismissed, readAttempt]);
if (dismissed) return null;
// 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 = [
@@ -98,7 +138,17 @@ export default function OnboardingChecklist({ hasJobs }: { hasJobs: boolean | nu
{ done: hasJobs, label: t("onboardingStepJob"), path: "/jobs", actionLabel: t("onboardingStepJobAction") },
];
const completed = steps.filter((step) => step.done === true).length;
if (status && hasJobs !== null && completed === steps.length) return null;
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={
@@ -109,12 +159,14 @@ export default function OnboardingChecklist({ hasJobs }: { hasJobs: boolean | nu
onClick={() => {
try { window.localStorage.setItem(dismissalKey, "true"); }
catch { /* This dismissal still applies while the page remains open. */ }
setDismissed(true);
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 }} />
@@ -145,11 +197,31 @@ export default function OnboardingChecklist({ hasJobs }: { hasJobs: boolean | nu
{step.label}
</Typography>
</Box>
{step.done === false ? <Button size="small" variant="text" sx={{ maxWidth: "100%", whiteSpace: "normal", overflowWrap: "anywhere" }} onClick={() => navigate(step.path)}>{step.actionLabel}</Button> : null}
{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>
);
}