feat(onboarding): migrate source-backed setup guidance
This commit is contained in:
@@ -221,7 +221,7 @@ export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboar
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "grid", gap: 3, minWidth: 0, "& p, & h2, & h3": { overflowWrap: "anywhere" } }}>
|
||||
{!reporting ? <OnboardingChecklist hasJobs={hasJobs} /> : null}
|
||||
{!reporting ? <OnboardingChecklist hasJobs={summaryReady ? hasJobs : null} /> : null}
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}>
|
||||
<Typography variant="body2" color="text.secondary">{t(reporting ? "analyticsScope" : "dashboardSnapshotScope")}</Typography>
|
||||
{reporting ? <Button component={RouterLink} to="/dashboard">{t("goToDashboard")}</Button> : prefsButton}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { Box, Button, IconButton, LinearProgress, Paper, Stack, Typography } from "@mui/material";
|
||||
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 { alpha, useTheme } from "@mui/material/styles";
|
||||
|
||||
import { api } from "../api";
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { getUserKeyFromToken } from "../themePrefs";
|
||||
import { PageSection } from "../layout/PageLayout";
|
||||
|
||||
type MeResponse = {
|
||||
email?: string | null;
|
||||
@@ -33,21 +33,32 @@ type CareerProfileResponse = {
|
||||
};
|
||||
type ConnectionStatus = { connected?: boolean };
|
||||
|
||||
export default function OnboardingChecklist({ hasJobs }: { hasJobs: boolean }) {
|
||||
const theme = useTheme();
|
||||
export default function OnboardingChecklist({ hasJobs }: { hasJobs: boolean | null }) {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useI18n();
|
||||
const dismissalKey = `onboardingDismissed:${getUserKeyFromToken()}`;
|
||||
const [dismissed, setDismissed] = useState(() => window.localStorage.getItem(dismissalKey) === "true");
|
||||
// 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 [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 (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)).catch(() => false),
|
||||
api.get<ConnectionStatus>(url).then((r) => Boolean(r.data?.connected)),
|
||||
)),
|
||||
]).then(([me, career, connections]) => {
|
||||
if (!active) return;
|
||||
@@ -68,59 +79,77 @@ export default function OnboardingChecklist({ hasJobs }: { hasJobs: boolean }) {
|
||||
cv: structuredCount > 0 || Boolean(career.data?.cvText?.trim()),
|
||||
email: connections.some(Boolean),
|
||||
});
|
||||
}).catch(() => { if (active) setStatus({ profile: false, cv: false, email: false }); });
|
||||
}).catch((cause: unknown) => {
|
||||
if (active) setLoadError({ cause });
|
||||
}).finally(() => {
|
||||
if (active) setLoading(false);
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, []);
|
||||
}, [dismissed, readAttempt]);
|
||||
|
||||
if (!status || dismissed) return null;
|
||||
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 = [
|
||||
{ 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: 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).length;
|
||||
if (completed === steps.length) return null;
|
||||
const completed = steps.filter((step) => step.done === true).length;
|
||||
if (status && hasJobs !== null && 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>
|
||||
<PageSection surface title={t("onboardingTitle")} description={t("onboardingBody")} actions={
|
||||
<IconButton
|
||||
ref={dismissRef}
|
||||
size="small"
|
||||
aria-label={t("onboardingDismiss")}
|
||||
onClick={() => {
|
||||
window.localStorage.setItem(dismissalKey, "true");
|
||||
try { window.localStorage.setItem(dismissalKey, "true"); }
|
||||
catch { /* This dismissal still applies while the page remains open. */ }
|
||||
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>
|
||||
}>
|
||||
{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="determinate"
|
||||
value={(completed / steps.length) * 100}
|
||||
aria-label={t("checklistProgress", { completed, total: steps.length })}
|
||||
sx={{ my: 1.5, borderRadius: 2 }}
|
||||
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 }}
|
||||
/>
|
||||
<Stack spacing={1}>
|
||||
<Box component="ul" sx={{ display: "grid", gap: 1, listStyle: "none", m: 0, p: 0, minWidth: 0 }}>
|
||||
{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" }}>
|
||||
<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 && step.action ? <Button size="small" variant="text" onClick={step.action}>{step.actionLabel}</Button> : null}
|
||||
{step.done === false ? <Button size="small" variant="text" sx={{ maxWidth: "100%", whiteSpace: "normal", overflowWrap: "anywhere" }} onClick={() => navigate(step.path)}>{step.actionLabel}</Button> : null}
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Box>
|
||||
</> : null}
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user