feat: complete phase 2 UX improvements
This commit is contained in:
@@ -4,6 +4,7 @@ import {
|
||||
Alert, Box, Button, Chip, Divider, IconButton, MenuItem, Paper, Skeleton, Stack, TextField,
|
||||
Tooltip, Typography,
|
||||
} from "@mui/material";
|
||||
import RichTextField from "./RichTextField";
|
||||
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
||||
import RestoreIcon from "@mui/icons-material/Restore";
|
||||
|
||||
@@ -290,14 +291,12 @@ export function ApplicationCoverLetterSection({ jobId }: { jobId: number }) {
|
||||
error={error}
|
||||
>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
multiline
|
||||
<RichTextField
|
||||
minRows={12}
|
||||
fullWidth
|
||||
label="Cover letter"
|
||||
value={text}
|
||||
disabled={busy}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onChange={setDraft}
|
||||
placeholder="Write it yourself, start from the template, or generate a draft with the AI panel below."
|
||||
/>
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ import { emptyTailoredCvDraft, joinLines, normalizeTailoredCvDraft, splitLines }
|
||||
import Correspondence from "./Correspondence";
|
||||
import Attachments from "./Attachments";
|
||||
import AiWorkspacePanel from "./AiWorkspacePanel";
|
||||
import JobInsightTabs from "./JobInsightTabs";
|
||||
import { DraftCard, ListCard, MatchScoreCard, PaperRow, SectionChips, TwoColumnSection, WorkspaceDraftCard } from "./JobDetailsPanels";
|
||||
import JobFlowBar from "./JobFlowBar";
|
||||
import GradientButton from "./GradientButton";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
@@ -1189,92 +1191,23 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{tab === 5 && (
|
||||
<Box>
|
||||
<MatchScoreCard score={matchScore} loading={loadingMatchScore} />
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end", my: 1.5 }}>
|
||||
<Button size="small" variant="outlined" disabled={loadingCandidateFit} onClick={regenerateCandidateFit}>
|
||||
{loadingCandidateFit ? "Regenerating..." : "Regenerate"}
|
||||
</Button>
|
||||
</Box>
|
||||
{loadingCandidateFit ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : candidateFit ? (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2.5 }}>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: -1 }}>{t("jobDetailsAiFitHint")}</Typography>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
||||
<Box><Typography variant="overline">{t("jobDetailsHowYouMatch")}</Typography><Typography sx={{ whiteSpace: "pre-wrap" }}>{candidateFit.matchSummary}</Typography></Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
||||
<Chip label={t("jobDetailsMatchPercent", { count: candidateFit.matchScore })} color={candidateFit.matchScore >= 75 ? "success" : candidateFit.matchScore >= 55 ? "warning" : "default"} size="small" />
|
||||
{fitLevel ? <Chip label={fitLevel.label} color={fitLevel.color} size="small" /> : null}
|
||||
</Box>
|
||||
</Box>
|
||||
<DraftCard title={t("jobDetailsTailoredPitch")} content={candidateFit.tailoredPitch} />
|
||||
<SectionChips title={t("jobDetailsStrongMatches")} items={candidateFit.strengths} color="success" />
|
||||
<SectionChips title={t("jobDetailsPossibleGaps")} items={candidateFit.gaps} color="warning" outlined />
|
||||
<TwoColumnSection leftTitle={t("jobDetailsWhatToMention")} leftItems={candidateFit.mention} rightTitle={t("jobDetailsWhatNotToOverstate")} rightItems={candidateFit.avoid} />
|
||||
<TwoColumnSection leftTitle={t("jobDetailsImproveCv")} leftItems={candidateFit.cvImprovements} rightTitle={t("jobDetailsMissingKeywords")} rightItems={candidateFit.missingKeywords} />
|
||||
<TwoColumnSection leftTitle={t("jobDetailsTabInterviewPrep")} leftItems={candidateFit.interviewPrep} rightTitle={t("jobDetailsCvGuidance")} rightItems={candidateFit.guidance.cv} />
|
||||
<TwoColumnSection leftTitle={t("jobDetailsCoverLetterGuidance")} leftItems={candidateFit.guidance.coverLetter} rightTitle={t("jobDetailsRecruiterMessageGuidance")} rightItems={candidateFit.guidance.recruiterMessage} />
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
||||
<DraftCard title={t("jobDetailsCoverLetterDraft")} content={candidateFit.coverLetterDraft ?? t("jobDetailsNoDraftAvailableYet")} />
|
||||
<DraftCard title={t("jobDetailsRecruiterMessageDraft")} content={candidateFit.recruiterMessageDraft ?? t("jobDetailsNoDraftAvailableYet")} />
|
||||
</Box>
|
||||
</Box>
|
||||
) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsCandidateFitEmpty")}</Typography>}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{tab === 6 && (
|
||||
<Box>
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end", mb: 1.5 }}>
|
||||
<Button size="small" variant="outlined" disabled={loadingFocusPlan} onClick={regenerateFocusPlan}>
|
||||
{loadingFocusPlan ? "Regenerating..." : "Regenerate"}
|
||||
</Button>
|
||||
</Box>
|
||||
{loadingFocusPlan ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : focusPlan ? (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<DraftCard title={t("jobDetailsFocusSummary")} content={focusPlan.strategicSummary} />
|
||||
<TwoColumnSection leftTitle={t("jobDetailsImmediatePriorities")} leftItems={focusPlan.immediatePriorities} rightTitle={t("jobDetailsProofPoints")} rightItems={focusPlan.proofPointsToLeadWith} />
|
||||
<TwoColumnSection leftTitle={t("jobDetailsCvBulletIdeas")} leftItems={focusPlan.cvBulletIdeas} rightTitle={t("jobDetailsCoverLetterAngles")} rightItems={focusPlan.coverLetterAngles} />
|
||||
<ListCard title={t("jobDetailsFollowUpApproach")} items={focusPlan.followUpApproach} />
|
||||
</Box>
|
||||
) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNoFocusPlan")}</Typography>}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{tab === 7 && (
|
||||
<Box>
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end", mb: 1.5 }}>
|
||||
<Button size="small" variant="outlined" disabled={loadingInterviewPrep} onClick={regenerateInterviewPrep}>
|
||||
{loadingInterviewPrep ? "Regenerating..." : "Regenerate"}
|
||||
</Button>
|
||||
</Box>
|
||||
{loadingInterviewPrep ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : interviewPrep ? (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<DraftCard title={t("jobDetailsInterviewPrepBrief")} content={interviewPrep.summary} />
|
||||
<TwoColumnSection leftTitle={t("jobDetailsTalkingPoints")} leftItems={interviewPrep.talkingPoints} rightTitle={t("jobDetailsLikelyQuestions")} rightItems={interviewPrep.likelyQuestions} />
|
||||
<ListCard title={t("jobDetailsWeakSpots")} items={interviewPrep.weakSpots} />
|
||||
</Box>
|
||||
) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNoInterviewPrep")}</Typography>}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{tab === 8 && (
|
||||
<Box>
|
||||
{loadingReadiness ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : readiness ? (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
||||
<Typography variant="h6">{t("jobDetailsApplicationReadiness")}</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
<Chip label={t("jobDetailsReadyPercent", { count: readiness.score })} color={readiness.score >= 80 ? "success" : readiness.score >= 60 ? "warning" : "default"} />
|
||||
<Chip label={readiness.level} variant="outlined" />
|
||||
</Box>
|
||||
</Box>
|
||||
<TwoColumnSection leftTitle={t("jobDetailsCompleted")} leftItems={readiness.completed} rightTitle={t("jobDetailsStillMissing")} rightItems={readiness.missing} />
|
||||
<ListCard title={t("jobDetailsSmartReminders")} items={readiness.reminders} />
|
||||
</Box>
|
||||
) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNoReadiness")}</Typography>}
|
||||
</Box>
|
||||
)}
|
||||
<JobInsightTabs
|
||||
tab={tab}
|
||||
matchScore={matchScore}
|
||||
loadingMatchScore={loadingMatchScore}
|
||||
candidateFit={candidateFit}
|
||||
loadingCandidateFit={loadingCandidateFit}
|
||||
regenerateCandidateFit={regenerateCandidateFit}
|
||||
fitLevel={fitLevel}
|
||||
focusPlan={focusPlan}
|
||||
loadingFocusPlan={loadingFocusPlan}
|
||||
regenerateFocusPlan={regenerateFocusPlan}
|
||||
interviewPrep={interviewPrep}
|
||||
loadingInterviewPrep={loadingInterviewPrep}
|
||||
regenerateInterviewPrep={regenerateInterviewPrep}
|
||||
readiness={readiness}
|
||||
loadingReadiness={loadingReadiness}
|
||||
/>
|
||||
|
||||
{tab === 9 && isAdmin && (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||
@@ -1287,177 +1220,3 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function MatchScoreCard({ score, loading }: { score: MatchScore | null; loading: boolean }) {
|
||||
const { t } = useI18n();
|
||||
|
||||
if (loading && !score) {
|
||||
return (
|
||||
<Box sx={{ p: 1.5, mb: 2, borderRadius: 3, boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", display: "flex", alignItems: "center", gap: 1.5 }}>
|
||||
<CircularProgress size={18} />
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("matchScoreLoading")}</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!score) return null;
|
||||
|
||||
const color: "success" | "warning" | "error" | "inherit" =
|
||||
!score.hasEnoughSignal ? "inherit" : score.score >= 75 ? "success" : score.score >= 50 ? "warning" : "error";
|
||||
const bandLabel = t(`matchScoreBand_${score.band}` as any) || score.band;
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 1.75, mb: 2, borderRadius: 3, boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", backgroundColor: "background.default" }}>
|
||||
<Box sx={{ display: "flex", gap: 2.5, alignItems: "center", flexWrap: "wrap", mb: 1.5 }}>
|
||||
{score.hasEnoughSignal ? (
|
||||
<Box role="img" aria-label={`${t("matchScoreTitle")}: ${score.score}%`} sx={{ position: "relative", width: 92, height: 92, flexShrink: 0 }}>
|
||||
<CircularProgress variant="determinate" value={100} size={92} thickness={4} aria-hidden="true" sx={{ color: "divider", position: "absolute" }} />
|
||||
<CircularProgress
|
||||
variant="determinate"
|
||||
value={score.score}
|
||||
size={92}
|
||||
thickness={4}
|
||||
aria-hidden="true"
|
||||
color={color === "inherit" ? "primary" : color}
|
||||
sx={{ position: "absolute", "& .MuiCircularProgress-circle": { strokeLinecap: "round" } }}
|
||||
/>
|
||||
<Box sx={{ position: "absolute", inset: 0, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center" }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 800, fontVariantNumeric: "tabular-nums", lineHeight: 1 }}>{score.score}%</Typography>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>{t("matchScoreTitle")}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="h4" sx={{ fontWeight: 800 }}>—</Typography>
|
||||
)}
|
||||
<Box sx={{ flex: 1, minWidth: 200 }}>
|
||||
{!score.hasEnoughSignal ? <Typography variant="body2" sx={{ color: "text.secondary", mb: 1 }}>{t("matchScoreNoSignal")}</Typography> : null}
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
||||
<Chip size="small" color={color === "inherit" ? "default" : color} label={bandLabel} />
|
||||
<Chip size="small" variant="outlined" label={t("matchScoreKeywordsCovered", { matched: score.matchedCount, total: score.totalKeywords })} />
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 1 }}>{t("matchScoreDeterministicHint")}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
|
||||
<Box>
|
||||
<Typography variant="overline">{t("matchScoreMatched")}</Typography>
|
||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.5 }}>
|
||||
{score.matchedKeywords.length ? score.matchedKeywords.map((k) => <Chip key={k} label={k} color="success" size="small" />) : <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("matchScoreNoneYet")}</Typography>}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="overline">{t("matchScoreMissing")}</Typography>
|
||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.5 }}>
|
||||
{score.missingKeywords.length ? score.missingKeywords.map((k) => <Chip key={k} label={k} color="warning" variant="outlined" size="small" />) : <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("matchScoreAllCovered")}</Typography>}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
{score.sectionCoverage.length ? (
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
<Typography variant="overline">{t("matchScoreSectionCoverage")}</Typography>
|
||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.5 }}>
|
||||
{score.sectionCoverage.map((s) => <Chip key={s.section} size="small" variant="outlined" label={`${s.section}: ${s.matched}/${s.total}`} />)}
|
||||
</Box>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionChips({ title, items, color, outlined }: { title: string; items: string[]; color: "success" | "warning"; outlined?: boolean }) {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
||||
<Typography variant="overline">{title}</Typography>
|
||||
<Button size="small" variant="outlined" onClick={() => copyLines(items)}>{t("jobDetailsCopy")}</Button>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 0.5 }}>
|
||||
{items.length ? items.map((item) => <Chip key={item} label={item} color={color} variant={outlined ? "outlined" : "filled"} size="small" />) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNothingHighlighted")}</Typography>}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function TwoColumnSection({ leftTitle, leftItems, rightTitle, rightItems }: { leftTitle: string; leftItems: string[]; rightTitle: string; rightItems: string[] }) {
|
||||
return (
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
||||
<ListCard title={leftTitle} items={leftItems} />
|
||||
<ListCard title={rightTitle} items={rightItems} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ListCard({ title, items, subtitle }: { title: string; items: string[]; subtitle?: string }) {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 1.5, borderRadius: 3, boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", backgroundColor: "background.default" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
|
||||
<Box>
|
||||
<Typography variant="overline">{title}</Typography>
|
||||
{subtitle ? <Typography variant="caption" sx={{ display: "block", color: "text.secondary" }}>{subtitle}</Typography> : null}
|
||||
</Box>
|
||||
<Button size="small" variant="outlined" onClick={() => copyLines(items)}>{t("jobDetailsCopy")}</Button>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.75 }}>
|
||||
{items.length ? items.map((item, index) => <Typography key={`${title}-${index}-${item}`} sx={{ color: "text.primary" }}>• {item}</Typography>) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNothingHighlighted")}</Typography>}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceDraftCard({ title, value, onChange, statusLabel, statusColor }: { title: string; value: string; onChange: (value: string) => void; statusLabel: string; statusColor: "default" | "success" | "warning" }) {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 1.5, borderRadius: 3, boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", backgroundColor: "background.default" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
|
||||
<Typography variant="overline">{title}</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1, alignItems: "center", flexWrap: "wrap" }}>
|
||||
<Chip size="small" color={statusColor} label={statusLabel} />
|
||||
<Button size="small" variant="outlined" onClick={() => navigator.clipboard.writeText(value)}>{t("jobDetailsCopy")}</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
<TextField value={value} onChange={(e) => onChange(e.target.value)} multiline minRows={7} fullWidth />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function DraftCard({ title, content, onSave, saving }: { title: string; content: string; onSave?: (content: string) => Promise<void> | void; saving?: boolean }) {
|
||||
const { t } = useI18n();
|
||||
const [value, setValue] = React.useState(content);
|
||||
|
||||
React.useEffect(() => {
|
||||
setValue(content);
|
||||
}, [content]);
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 1.5, borderRadius: 3, boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", backgroundColor: "background.default" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
|
||||
<Typography variant="overline">{title}</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
<Button size="small" variant="outlined" onClick={() => navigator.clipboard.writeText(value)}>{t("jobDetailsCopy")}</Button>
|
||||
{onSave ? <Button size="small" variant="contained" disabled={saving} onClick={() => onSave(value)}>{saving ? t("jobDetailsSaving") : t("save")}</Button> : null}
|
||||
</Box>
|
||||
</Box>
|
||||
<TextField value={value} onChange={(e) => setValue(e.target.value)} multiline minRows={6} fullWidth />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function PaperRow({ type, oldValue, newValue, at, note }: { type: string; oldValue?: string; newValue?: string; at: string; note?: string }) {
|
||||
return (
|
||||
<Box sx={{ border: "1px solid rgba(15,23,42,0.08)", borderRadius: 2, p: 1.25, background: "rgba(255,255,255,0.6)" }}>
|
||||
<Typography sx={{ fontWeight: 900, lineHeight: 1.25 }}>
|
||||
{type}
|
||||
{oldValue || newValue ? <span style={{ fontWeight: 700, opacity: 0.7 }}>{" "}({oldValue ?? ""} {oldValue || newValue ? "->" : ""} {newValue ?? ""})</span> : null}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
{at ? new Date(at).toLocaleString() : ""}
|
||||
{note ? ` - ${note}` : ""}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import React from "react";
|
||||
import { Box, Button, Chip, CircularProgress, TextField, Typography } from "@mui/material";
|
||||
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { MatchScore } from "../types";
|
||||
|
||||
function copyLines(items: string[]) {
|
||||
void navigator.clipboard.writeText(items.join("\n"));
|
||||
}
|
||||
|
||||
export function MatchScoreCard({ score, loading }: { score: MatchScore | null; loading: boolean }) {
|
||||
const { t } = useI18n();
|
||||
|
||||
if (loading && !score) {
|
||||
return (
|
||||
<Box sx={{ p: 1.5, mb: 2, borderRadius: 3, boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", display: "flex", alignItems: "center", gap: 1.5 }}>
|
||||
<CircularProgress size={18} />
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("matchScoreLoading")}</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!score) return null;
|
||||
|
||||
const color: "success" | "warning" | "error" | "inherit" =
|
||||
!score.hasEnoughSignal ? "inherit" : score.score >= 75 ? "success" : score.score >= 50 ? "warning" : "error";
|
||||
const bandLabel = t(`matchScoreBand_${score.band}` as any) || score.band;
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 1.75, mb: 2, borderRadius: 3, boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", backgroundColor: "background.default" }}>
|
||||
<Box sx={{ display: "flex", gap: 2.5, alignItems: "center", flexWrap: "wrap", mb: 1.5 }}>
|
||||
{score.hasEnoughSignal ? (
|
||||
<Box role="img" aria-label={`${t("matchScoreTitle")}: ${score.score}%`} sx={{ position: "relative", width: 92, height: 92, flexShrink: 0 }}>
|
||||
<CircularProgress variant="determinate" value={100} size={92} thickness={4} aria-hidden="true" sx={{ color: "divider", position: "absolute" }} />
|
||||
<CircularProgress
|
||||
variant="determinate"
|
||||
value={score.score}
|
||||
size={92}
|
||||
thickness={4}
|
||||
aria-hidden="true"
|
||||
color={color === "inherit" ? "primary" : color}
|
||||
sx={{ position: "absolute", "& .MuiCircularProgress-circle": { strokeLinecap: "round" } }}
|
||||
/>
|
||||
<Box sx={{ position: "absolute", inset: 0, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center" }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 800, fontVariantNumeric: "tabular-nums", lineHeight: 1 }}>{score.score}%</Typography>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>{t("matchScoreTitle")}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="h4" sx={{ fontWeight: 800 }}>—</Typography>
|
||||
)}
|
||||
<Box sx={{ flex: 1, minWidth: 200 }}>
|
||||
{!score.hasEnoughSignal ? <Typography variant="body2" sx={{ color: "text.secondary", mb: 1 }}>{t("matchScoreNoSignal")}</Typography> : null}
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
||||
<Chip size="small" color={color === "inherit" ? "default" : color} label={bandLabel} />
|
||||
<Chip size="small" variant="outlined" label={t("matchScoreKeywordsCovered", { matched: score.matchedCount, total: score.totalKeywords })} />
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 1 }}>{t("matchScoreDeterministicHint")}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
|
||||
<Box>
|
||||
<Typography variant="overline">{t("matchScoreMatched")}</Typography>
|
||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.5 }}>
|
||||
{score.matchedKeywords.length ? score.matchedKeywords.map((k) => <Chip key={k} label={k} color="success" size="small" />) : <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("matchScoreNoneYet")}</Typography>}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="overline">{t("matchScoreMissing")}</Typography>
|
||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.5 }}>
|
||||
{score.missingKeywords.length ? score.missingKeywords.map((k) => <Chip key={k} label={k} color="warning" variant="outlined" size="small" />) : <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("matchScoreAllCovered")}</Typography>}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
{score.sectionCoverage.length ? (
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
<Typography variant="overline">{t("matchScoreSectionCoverage")}</Typography>
|
||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.5 }}>
|
||||
{score.sectionCoverage.map((s) => <Chip key={s.section} size="small" variant="outlined" label={`${s.section}: ${s.matched}/${s.total}`} />)}
|
||||
</Box>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function SectionChips({ title, items, color, outlined }: { title: string; items: string[]; color: "success" | "warning"; outlined?: boolean }) {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
||||
<Typography variant="overline">{title}</Typography>
|
||||
<Button size="small" variant="outlined" onClick={() => copyLines(items)}>{t("jobDetailsCopy")}</Button>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 0.5 }}>
|
||||
{items.length ? items.map((item) => <Chip key={item} label={item} color={color} variant={outlined ? "outlined" : "filled"} size="small" />) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNothingHighlighted")}</Typography>}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function TwoColumnSection({ leftTitle, leftItems, rightTitle, rightItems }: { leftTitle: string; leftItems: string[]; rightTitle: string; rightItems: string[] }) {
|
||||
return (
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
||||
<ListCard title={leftTitle} items={leftItems} />
|
||||
<ListCard title={rightTitle} items={rightItems} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function ListCard({ title, items, subtitle }: { title: string; items: string[]; subtitle?: string }) {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 1.5, borderRadius: 3, boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", backgroundColor: "background.default" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
|
||||
<Box>
|
||||
<Typography variant="overline">{title}</Typography>
|
||||
{subtitle ? <Typography variant="caption" sx={{ display: "block", color: "text.secondary" }}>{subtitle}</Typography> : null}
|
||||
</Box>
|
||||
<Button size="small" variant="outlined" onClick={() => copyLines(items)}>{t("jobDetailsCopy")}</Button>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.75 }}>
|
||||
{items.length ? items.map((item, index) => <Typography key={`${title}-${index}-${item}`} sx={{ color: "text.primary" }}>• {item}</Typography>) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNothingHighlighted")}</Typography>}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkspaceDraftCard({ title, value, onChange, statusLabel, statusColor }: { title: string; value: string; onChange: (value: string) => void; statusLabel: string; statusColor: "default" | "success" | "warning" }) {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 1.5, borderRadius: 3, boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", backgroundColor: "background.default" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
|
||||
<Typography variant="overline">{title}</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1, alignItems: "center", flexWrap: "wrap" }}>
|
||||
<Chip size="small" color={statusColor} label={statusLabel} />
|
||||
<Button size="small" variant="outlined" onClick={() => navigator.clipboard.writeText(value)}>{t("jobDetailsCopy")}</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
<TextField value={value} onChange={(e) => onChange(e.target.value)} multiline minRows={7} fullWidth />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function DraftCard({ title, content, onSave, saving }: { title: string; content: string; onSave?: (content: string) => Promise<void> | void; saving?: boolean }) {
|
||||
const { t } = useI18n();
|
||||
const [value, setValue] = React.useState(content);
|
||||
|
||||
React.useEffect(() => {
|
||||
setValue(content);
|
||||
}, [content]);
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 1.5, borderRadius: 3, boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", backgroundColor: "background.default" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
|
||||
<Typography variant="overline">{title}</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
<Button size="small" variant="outlined" onClick={() => navigator.clipboard.writeText(value)}>{t("jobDetailsCopy")}</Button>
|
||||
{onSave ? <Button size="small" variant="contained" disabled={saving} onClick={() => onSave(value)}>{saving ? t("jobDetailsSaving") : t("save")}</Button> : null}
|
||||
</Box>
|
||||
</Box>
|
||||
<TextField value={value} onChange={(e) => setValue(e.target.value)} multiline minRows={6} fullWidth />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function PaperRow({ type, oldValue, newValue, at, note }: { type: string; oldValue?: string; newValue?: string; at: string; note?: string }) {
|
||||
return (
|
||||
<Box sx={{ border: "1px solid rgba(15,23,42,0.08)", borderRadius: 2, p: 1.25, background: "rgba(255,255,255,0.6)" }}>
|
||||
<Typography sx={{ fontWeight: 900, lineHeight: 1.25 }}>
|
||||
{type}
|
||||
{oldValue || newValue ? <span style={{ fontWeight: 700, opacity: 0.7 }}>{" "}({oldValue ?? ""} {oldValue || newValue ? "->" : ""} {newValue ?? ""})</span> : null}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
{at ? new Date(at).toLocaleString() : ""}
|
||||
{note ? ` - ${note}` : ""}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import React from "react";
|
||||
import { Box, Button, Chip, CircularProgress, Typography } from "@mui/material";
|
||||
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { CandidateFit, FocusPlanResponse, InterviewPrepResponse, MatchScore, ReadinessResponse } from "../types";
|
||||
import { DraftCard, ListCard, MatchScoreCard, SectionChips, TwoColumnSection } from "./JobDetailsPanels";
|
||||
|
||||
type Props = {
|
||||
tab: number;
|
||||
matchScore: MatchScore | null;
|
||||
loadingMatchScore: boolean;
|
||||
candidateFit: CandidateFit | null;
|
||||
loadingCandidateFit: boolean;
|
||||
regenerateCandidateFit: () => void;
|
||||
fitLevel: { label: string; color: "success" | "warning" | "default" } | null;
|
||||
focusPlan: FocusPlanResponse | null;
|
||||
loadingFocusPlan: boolean;
|
||||
regenerateFocusPlan: () => void;
|
||||
interviewPrep: InterviewPrepResponse | null;
|
||||
loadingInterviewPrep: boolean;
|
||||
regenerateInterviewPrep: () => void;
|
||||
readiness: ReadinessResponse | null;
|
||||
loadingReadiness: boolean;
|
||||
};
|
||||
|
||||
export default function JobInsightTabs(props: Props) {
|
||||
const { t } = useI18n();
|
||||
const { tab, matchScore, loadingMatchScore, candidateFit, loadingCandidateFit, regenerateCandidateFit, fitLevel, focusPlan, loadingFocusPlan, regenerateFocusPlan, interviewPrep, loadingInterviewPrep, regenerateInterviewPrep, readiness, loadingReadiness } = props;
|
||||
return <>
|
||||
{tab === 5 && (
|
||||
<Box>
|
||||
<MatchScoreCard score={matchScore} loading={loadingMatchScore} />
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end", my: 1.5 }}>
|
||||
<Button size="small" variant="outlined" disabled={loadingCandidateFit} onClick={regenerateCandidateFit}>
|
||||
{loadingCandidateFit ? "Regenerating..." : "Regenerate"}
|
||||
</Button>
|
||||
</Box>
|
||||
{loadingCandidateFit ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : candidateFit ? (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2.5 }}>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: -1 }}>{t("jobDetailsAiFitHint")}</Typography>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
||||
<Box><Typography variant="overline">{t("jobDetailsHowYouMatch")}</Typography><Typography sx={{ whiteSpace: "pre-wrap" }}>{candidateFit.matchSummary}</Typography></Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
||||
<Chip label={t("jobDetailsMatchPercent", { count: candidateFit.matchScore })} color={candidateFit.matchScore >= 75 ? "success" : candidateFit.matchScore >= 55 ? "warning" : "default"} size="small" />
|
||||
{fitLevel ? <Chip label={fitLevel.label} color={fitLevel.color} size="small" /> : null}
|
||||
</Box>
|
||||
</Box>
|
||||
<DraftCard title={t("jobDetailsTailoredPitch")} content={candidateFit.tailoredPitch} />
|
||||
<SectionChips title={t("jobDetailsStrongMatches")} items={candidateFit.strengths} color="success" />
|
||||
<SectionChips title={t("jobDetailsPossibleGaps")} items={candidateFit.gaps} color="warning" outlined />
|
||||
<TwoColumnSection leftTitle={t("jobDetailsWhatToMention")} leftItems={candidateFit.mention} rightTitle={t("jobDetailsWhatNotToOverstate")} rightItems={candidateFit.avoid} />
|
||||
<TwoColumnSection leftTitle={t("jobDetailsImproveCv")} leftItems={candidateFit.cvImprovements} rightTitle={t("jobDetailsMissingKeywords")} rightItems={candidateFit.missingKeywords} />
|
||||
<TwoColumnSection leftTitle={t("jobDetailsTabInterviewPrep")} leftItems={candidateFit.interviewPrep} rightTitle={t("jobDetailsCvGuidance")} rightItems={candidateFit.guidance.cv} />
|
||||
<TwoColumnSection leftTitle={t("jobDetailsCoverLetterGuidance")} leftItems={candidateFit.guidance.coverLetter} rightTitle={t("jobDetailsRecruiterMessageGuidance")} rightItems={candidateFit.guidance.recruiterMessage} />
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
||||
<DraftCard title={t("jobDetailsCoverLetterDraft")} content={candidateFit.coverLetterDraft ?? t("jobDetailsNoDraftAvailableYet")} />
|
||||
<DraftCard title={t("jobDetailsRecruiterMessageDraft")} content={candidateFit.recruiterMessageDraft ?? t("jobDetailsNoDraftAvailableYet")} />
|
||||
</Box>
|
||||
</Box>
|
||||
) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsCandidateFitEmpty")}</Typography>}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{tab === 6 && (
|
||||
<Box>
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end", mb: 1.5 }}>
|
||||
<Button size="small" variant="outlined" disabled={loadingFocusPlan} onClick={regenerateFocusPlan}>
|
||||
{loadingFocusPlan ? "Regenerating..." : "Regenerate"}
|
||||
</Button>
|
||||
</Box>
|
||||
{loadingFocusPlan ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : focusPlan ? (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<DraftCard title={t("jobDetailsFocusSummary")} content={focusPlan.strategicSummary} />
|
||||
<TwoColumnSection leftTitle={t("jobDetailsImmediatePriorities")} leftItems={focusPlan.immediatePriorities} rightTitle={t("jobDetailsProofPoints")} rightItems={focusPlan.proofPointsToLeadWith} />
|
||||
<TwoColumnSection leftTitle={t("jobDetailsCvBulletIdeas")} leftItems={focusPlan.cvBulletIdeas} rightTitle={t("jobDetailsCoverLetterAngles")} rightItems={focusPlan.coverLetterAngles} />
|
||||
<ListCard title={t("jobDetailsFollowUpApproach")} items={focusPlan.followUpApproach} />
|
||||
</Box>
|
||||
) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNoFocusPlan")}</Typography>}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{tab === 7 && (
|
||||
<Box>
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end", mb: 1.5 }}>
|
||||
<Button size="small" variant="outlined" disabled={loadingInterviewPrep} onClick={regenerateInterviewPrep}>
|
||||
{loadingInterviewPrep ? "Regenerating..." : "Regenerate"}
|
||||
</Button>
|
||||
</Box>
|
||||
{loadingInterviewPrep ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : interviewPrep ? (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<DraftCard title={t("jobDetailsInterviewPrepBrief")} content={interviewPrep.summary} />
|
||||
<TwoColumnSection leftTitle={t("jobDetailsTalkingPoints")} leftItems={interviewPrep.talkingPoints} rightTitle={t("jobDetailsLikelyQuestions")} rightItems={interviewPrep.likelyQuestions} />
|
||||
<ListCard title={t("jobDetailsWeakSpots")} items={interviewPrep.weakSpots} />
|
||||
</Box>
|
||||
) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNoInterviewPrep")}</Typography>}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{tab === 8 && (
|
||||
<Box>
|
||||
{loadingReadiness ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : readiness ? (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
||||
<Typography variant="h6">{t("jobDetailsApplicationReadiness")}</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
<Chip label={t("jobDetailsReadyPercent", { count: readiness.score })} color={readiness.score >= 80 ? "success" : readiness.score >= 60 ? "warning" : "default"} />
|
||||
<Chip label={readiness.level} variant="outlined" />
|
||||
</Box>
|
||||
</Box>
|
||||
<TwoColumnSection leftTitle={t("jobDetailsCompleted")} leftItems={readiness.completed} rightTitle={t("jobDetailsStillMissing")} rightItems={readiness.missing} />
|
||||
<ListCard title={t("jobDetailsSmartReminders")} items={readiness.reminders} />
|
||||
</Box>
|
||||
) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNoReadiness")}</Typography>}
|
||||
</Box>
|
||||
)}
|
||||
</>;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -19,6 +19,7 @@ export default function RichTextField({
|
||||
placeholder,
|
||||
minRows = 2,
|
||||
ariaLabel,
|
||||
disabled = false,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
@@ -26,6 +27,7 @@ export default function RichTextField({
|
||||
placeholder?: string;
|
||||
minRows?: number;
|
||||
ariaLabel?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const ref = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
@@ -42,7 +44,7 @@ export default function RichTextField({
|
||||
|
||||
const btn = (title: string, icon: React.ReactNode, before: string, after: string, ph: string) => (
|
||||
<Tooltip title={title}>
|
||||
<IconButton size="small" aria-label={title} onMouseDown={(e) => e.preventDefault()} onClick={() => apply(before, after, ph)}>
|
||||
<IconButton size="small" disabled={disabled} aria-label={title} onMouseDown={(e) => e.preventDefault()} onClick={() => apply(before, after, ph)}>
|
||||
{icon}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
@@ -65,6 +67,7 @@ export default function RichTextField({
|
||||
fullWidth
|
||||
size="small"
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
slotProps={{ htmlInput: { "aria-label": ariaLabel ?? label } }}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user