import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Accordion, AccordionDetails, AccordionSummary, Alert, Avatar, Box, Button, Chip, Dialog, DialogContent, DialogTitle, Divider, FormControl, IconButton, InputLabel, LinearProgress, MenuItem, Paper, Select, TextField, Typography } from "@mui/material"; import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import PhotoCameraOutlinedIcon from "@mui/icons-material/PhotoCameraOutlined"; import ZoomInOutlinedIcon from "@mui/icons-material/ZoomInOutlined"; import { api, getApiErrorMessage } from "../api"; import GoogleAuthCard from "../components/GoogleAuthCard"; import MicrosoftAuthCard from "../components/MicrosoftAuthCard"; import AuthStatusCard from "../components/AuthStatusCard"; import TwoFactorSettingsCard from "../components/TwoFactorSettingsCard"; import SessionsSettingsCard from "../components/SessionsSettingsCard"; import CropImageDialog from "../components/CropImageDialog"; import { useToast } from "../toast"; import { useI18n } from "../i18n/I18nProvider"; import { emptyStructuredCv, getStructuredCvFieldMetadata, joinLines, normalizeStructuredCv, parseStructuredCvJson, splitLines, StructuredCvFieldMetadata, StructuredCvProfile, } from "../profileCv"; import { JobApplication } from "../types"; type CvSectionOption = "" | "Professional Summary" | "Core Skills" | "Experience Highlights" | "Selected Achievements" | "Projects"; type CvSectionStyle = "ats-minimal" | "harvard" | "auckland" | "edinburgh" | "monarch" | "fjord"; type CvBuilderTone = "Concise and direct" | "Executive and polished" | "Technical and detailed" | "Warm and people-focused"; type CvBuilderLanguage = "English" | "Norwegian" | "Spanish" | "French" | "German"; type ExtractionRun = { id: number; trigger: string; status: string; artifactFileName?: string; startedAtUtc: string; completedAtUtc?: string; appliedAtUtc?: string; parserVersion: string; normalizerVersion: string; llmPromptVersion: string; errorMessage?: string; }; type QueuedCvRunResponse = { queued: boolean; extractionRunId: number; status: string; }; type JobListResponse = { items: JobApplication[]; total: number; page: number; pageSize: number; }; type RewriteTemplateOption = { id: CvSectionStyle; title: string; eyebrow: string; accent: string; blurb: string; sampleHeading: string; sampleMeta: string; sampleBullets: string[]; }; type CvBuilderPreview = { templateId: CvSectionStyle; html: string; suggestedFileName: string; fullText: string; rewrittenText: string; structuredCv: StructuredCvProfile; sectionName?: string | null; targetRole?: string | null; jobApplicationId?: number | null; }; type PdfCarouselItem = { templateId: CvSectionStyle; title: string; fileName: string; pdfUrl?: string; status: "loading" | "ready" | "error"; error?: string; }; type RewriteRequestPayload = { sectionName: string | null; style: CvSectionStyle; templateId: CvSectionStyle; targetRole: string | null; jobApplicationId: number | null; sourceText: string | null; promptBackground: string | null; tone: string | null; language: string | null; }; type MeResponse = { provider?: "local" | "google" | "external"; id?: string; email?: string; userName?: string; firstName?: string; lastName?: string; displayName?: string; profileCvText?: string; profileCvStructureJson?: string; avatarImageDataUrl?: string; roles?: string[]; googleLink?: { linked: boolean; email?: string | null; linkedAt?: string | null; } | null; }; const CV_UPLOAD_ACCEPT = ".pdf,.docx,.txt,.md,image/png,image/jpeg,image/webp,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,text/plain,text/markdown"; const AVATAR_UPLOAD_ACCEPT = "image/png,image/jpeg,image/webp"; const REWRITE_TEMPLATES: RewriteTemplateOption[] = [ { id: "ats-minimal", title: "ATS Minimal", eyebrow: "Scanner-friendly", accent: "#0f172a", blurb: "Compact, direct, and easy for screening systems to parse.", sampleHeading: "Senior Backend Engineer", sampleMeta: "Acme Systems · Oslo · 2021 - Present", sampleBullets: ["Built API workflows with measurable delivery outcomes.", "Kept skills and achievements easy to scan."] }, { id: "harvard", title: "Harvard", eyebrow: "Traditional", accent: "#7f1d1d", blurb: "Formal hierarchy and restrained tone for conservative hiring flows.", sampleHeading: "Professional Summary", sampleMeta: "Clear structure · precise dates · credible language", sampleBullets: ["Emphasizes polished summaries.", "Works well for broad professional roles."] }, { id: "auckland", title: "Auckland", eyebrow: "Modern sidebar", accent: "#0f766e", blurb: "Sharper highlights with a more contemporary, design-forward rhythm.", sampleHeading: "Selected Impact", sampleMeta: "Focused strengths · compact highlights", sampleBullets: ["Pulls skills into stronger highlight clusters.", "Good when you want a fresher feel."] }, { id: "edinburgh", title: "Edinburgh", eyebrow: "Editorial", accent: "#5b21b6", blurb: "More personality and stronger section contrast without losing clarity.", sampleHeading: "Experience Highlights", sampleMeta: "Premium spacing · stronger visual voice", sampleBullets: ["Useful when the CV should feel more distinctive.", "Still keeps wording grounded and factual."] }, { id: "monarch", title: "Monarch", eyebrow: "Executive", accent: "#7c2d12", blurb: "High-contrast premium presentation for leadership-heavy applications.", sampleHeading: "Executive Profile", sampleMeta: "Leadership clarity · premium hierarchy", sampleBullets: ["Adds more top-level summary emphasis.", "Well suited to senior strategic roles."] }, { id: "fjord", title: "Fjord", eyebrow: "Technical", accent: "#0f4c5c", blurb: "Calm, high-density layout for engineering resumes and project-heavy CVs.", sampleHeading: "Projects & Systems", sampleMeta: "Technical depth · practical readability", sampleBullets: ["Gives projects and skills more weight.", "Better for technical detail without chaos."] }, ]; function initialsFrom(values: Array) { const joined = values.map((x) => (x ?? "").trim()).filter(Boolean); if (joined.length === 0) return "?"; if (joined.length === 1) { const parts = joined[0].split(/[\s@._-]+/).filter(Boolean); if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); return (parts[0][0] + parts[1][0]).toUpperCase(); } return (joined[0][0] + joined[1][0]).toUpperCase(); } function confidenceTone(confidence?: number) { if (typeof confidence !== "number") return { label: "Review", color: "default" as const }; if (confidence >= 0.8) return { label: `High ${Math.round(confidence * 100)}%`, color: "success" as const }; if (confidence >= 0.65) return { label: `Medium ${Math.round(confidence * 100)}%`, color: "warning" as const }; return { label: `Low ${Math.round(confidence * 100)}%`, color: "error" as const }; } function FieldReviewNote({ metadata }: { metadata?: StructuredCvFieldMetadata }) { if (!metadata) return null; const tone = confidenceTone(metadata.confidence); return ( {metadata.method ? : null} {metadata.sourceBlockId ? : null} {metadata.reviewState ? : null} {metadata.sourceSnippet ? ( {metadata.sourceSnippet} ) : null} ); } // ProfilePage backs /profile: account identity + security + preferences. The master career // profile lives in CareerProfilePage (/career). Split in Phase 2.2. Saves only identity fields // (partial update), never the master profile. export default function ProfilePage() { // Retained so the shared JSX reads identically; hardcoded for /profile. const careerOnly = false; const { toast } = useToast(); const { t } = useI18n(); const avatarInputRef = useRef(null); const [me, setMe] = useState(null); const [loading, setLoading] = useState(false); const [loadError, setLoadError] = useState(null); const [uploadingAvatar, setUploadingAvatar] = useState(false); const [avatarFile, setAvatarFile] = useState(null); const [cropOpen, setCropOpen] = useState(false); const [email, setEmail] = useState(""); const [userName, setUserName] = useState(""); const [firstName, setFirstName] = useState(""); const [lastName, setLastName] = useState(""); const [displayName, setDisplayName] = useState(""); const [headline, setHeadline] = useState(""); const [profileCvText, setProfileCvText] = useState(""); const [currentPassword, setCurrentPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); const loadProfile = useCallback(async () => { setLoading(true); try { // /profile only needs the account row. The master-profile data (runs, structured CV, // saved jobs) is loaded by CareerProfilePage on /career. const r = await api.get("/auth/me"); setMe(r.data); setEmail(r.data?.email ?? ""); setUserName(r.data?.userName ?? ""); setFirstName(r.data?.firstName ?? ""); setLastName(r.data?.lastName ?? ""); setDisplayName(r.data?.displayName ?? ""); setProfileCvText(r.data?.profileCvText ?? ""); setHeadline(window.localStorage.getItem("profileHeadline") ?? ""); setLoadError(null); } catch (error: any) { setMe(null); setLoadError(String(error?.response?.data || error?.message || "Unable to load profile right now.")); } finally { setLoading(false); } }, []); useEffect(() => { void loadProfile(); }, [loadProfile]); const initials = useMemo(() => initialsFrom([me?.displayName, me?.firstName, me?.lastName, me?.userName, me?.email]), [me]); const isLocal = me?.provider === "local"; const fullName = [me?.firstName, me?.lastName].filter(Boolean).join(" "); const cvWordCount = profileCvText.trim() ? profileCvText.trim().split(/\s+/).length : 0; const providerLabel = me?.provider === "local" ? t("profileLocalAccount") : me?.provider === "google" ? t("profileGoogleSession") : t("profileExternalSession"); const googleLabel = me?.googleLink?.linked ? me.googleLink.email ? t("profileGoogleLinkedWithEmail", { email: me.googleLink.email }) : t("profileGoogleLinked") : t("profileGoogleNotLinked"); const cvLabel = profileCvText.trim() ? t("profileCvReady", { count: cvWordCount }) : t("profileCvMissing"); return ( { setCropOpen(false); setAvatarFile(null); }} onSave={async (blob) => { const file = new File([blob], "avatar.png", { type: "image/png" }); const formData = new FormData(); formData.append("file", file); setUploadingAvatar(true); try { const response = await api.post<{ avatarImageDataUrl?: string }>("/auth/avatar", formData, { headers: { "Content-Type": "multipart/form-data" }, }); setMe((prev) => (prev ? { ...prev, avatarImageDataUrl: response.data?.avatarImageDataUrl ?? prev.avatarImageDataUrl } : prev)); setCropOpen(false); setAvatarFile(null); toast(t("profileImageUpdated"), "success"); } catch (e: any) { toast(String(e?.response?.data || e?.message || t("profileImageUploadFailed")), "error"); } finally { setUploadingAvatar(false); } }} /> {loadError ? ( void loadProfile()}>Retry}> Unable to load profile. {loadError} ) : null} {initials} { const file = event.target.files?.[0] ?? null; event.target.value = ""; if (!file) return; setAvatarFile(file); setCropOpen(true); }} /> {me?.avatarImageDataUrl ? ( ) : null} {careerOnly ? "Master career profile" : t("profileTitle")} {me?.userName || me?.displayName || fullName || me?.email || "-"} {headline || t("profileHeadlinePlaceholder")} {!careerOnly ? <> : null} {!careerOnly ? {t("profileAccountSection")} {!isLocal ? ( {t("profileReadOnlyInfo")} ) : null} : null} {!careerOnly ? <> setDisplayName(e.target.value)} disabled={!isLocal} fullWidth /> setUserName(e.target.value)} disabled={!isLocal} fullWidth /> setFirstName(e.target.value)} disabled={!isLocal} fullWidth /> setLastName(e.target.value)} disabled={!isLocal} fullWidth /> setEmail(e.target.value)} disabled={!isLocal} fullWidth /> setHeadline(e.target.value)} helperText={t("profileHeadlineHelp")} fullWidth /> : null} {!careerOnly ? {t("profileChangePassword")} {!isLocal ? {t("profilePasswordLocalOnly")} : null} : null} {!careerOnly ? <> setCurrentPassword(e.target.value)} disabled={!isLocal} fullWidth /> setNewPassword(e.target.value)} disabled={!isLocal} fullWidth /> : null} {!careerOnly && isLocal ? : null} {!careerOnly && isLocal ? : null} ); }