import React, { useEffect, useId, useRef, useState } from "react"; import { Alert, Box, Button, Checkbox, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, MenuItem, Stack, TextField, Typography } from "@mui/material"; import { api } from "../api"; import { applicationAssetsApi, CoverLetterDocumentSettings } from "../applicationWorkspace"; import { cvBuilderApi, CvTheme, CvVariantSummary, getCvPageMetrics, getCvPreviewFitZoom } from "../cvBuilder"; import { previewDocumentHtml } from "../cvPreview"; import { StructuredCvProfile } from "../profileCv"; import { useI18n } from "../i18n/I18nProvider"; import type { TranslationKey } from "../i18n/translations"; const contactFields = ["senderName", "senderTitle", "senderEmail", "senderPhone", "senderLocation"] as const; const detailFields = [...contactFields, "recipient", "subject", "date"] as const; const fieldLabels: Record = { senderName: "letterSenderName", senderTitle: "letterSenderTitle", senderEmail: "letterSenderEmail", senderPhone: "letterSenderPhone", senderLocation: "letterSenderLocation", recipient: "letterRecipient", subject: "letterSubject", date: "letterDate", }; type Props = { settings: CoverLetterDocumentSettings; disabled: boolean; onChange: (settings: CoverLetterDocumentSettings) => void }; export function CoverLetterDocumentFields({ settings, disabled, onChange }: Props) { const { t } = useI18n(); const [copy, setCopy] = useState<"contact" | "design" | null>(null); const [choosingTemplate, setChoosingTemplate] = useState(false); return {t("letterDetails")} onChange({ ...settings, language: event.target.value as "en" | "nb-NO" })}> {t("languageEnglish")} {t("languageNorwegianBokmal")} {detailFields.map(key => onChange({ ...settings, [key]: event.target.value })} />)} {copy && setCopy(null)} onApply={next => { if (!disabled) onChange(next); setCopy(null); }} />} {choosingTemplate && setChoosingTemplate(false)} onApply={next => { if (!disabled) onChange(next); setChoosingTemplate(false); }} />} ; } type ThemeDefaults = CvTheme & { bodyFont: string; baseFontSizePt: number; lineHeight: number }; function LetterTemplatePicker({ settings, disabled, onClose, onApply }: Omit & { onClose: () => void; onApply: (settings: CoverLetterDocumentSettings) => void }) { const { t } = useI18n(); const id = useId(); const [themes, setThemes] = useState([]); const [selected, setSelected] = useState(settings.themeId); const [loading, setLoading] = useState(true); const [failed, setFailed] = useState(false); const [attempt, setAttempt] = useState(0); useEffect(() => { let current = true; setLoading(true); setFailed(false); cvBuilderApi.themes().then(rows => { if (current) setThemes(rows); }).catch(() => { if (current) setFailed(true); }).finally(() => { if (current) setLoading(false); }); return () => { current = false; }; }, [attempt]); const theme = themes.find(row => row.id === selected); const ready = theme?.available && !!theme.bodyFont && !!theme.baseFontSizePt && !!theme.lineHeight && !!theme.pageMarginMm; const apply = () => { if (!ready || !theme || disabled) return; onApply({ ...settings, designVersion: 2, themeId: theme.id, accentColor: theme.accent, bodyFont: theme.bodyFont!, headingFont: theme.headingFont ?? theme.bodyFont!, baseFontSizePt: theme.baseFontSizePt!, lineHeight: theme.lineHeight!, pageMarginMm: theme.pageMarginMm! }); }; return {t("letterChooseTemplate")} {t("letterTemplateHelp")} {loading && {t("loading")}} {failed && setAttempt(value => value + 1)}>{t("retry")}}>{t("letterTemplateFailed")}} row.id === selected) ? selected : ""} onChange={event => setSelected(event.target.value)}> {themes.map(row => {row.name ?? row.id}{row.available ? "" : ` — ${t("cvEditorPro")}`})} {!loading && !failed && !ready && {t("letterTemplateUnavailable")}} ; } function CopyLetterSnapshot({ kind, settings, onClose, onApply }: { kind: "contact" | "design"; settings: CoverLetterDocumentSettings; onClose: () => void; onApply: (settings: CoverLetterDocumentSettings) => void }) { const { t } = useI18n(); const id = useId(); const [variants, setVariants] = useState([]); const [selected, setSelected] = useState(""); const [snapshot, setSnapshot] = useState(null); const [failed, setFailed] = useState(false); const [attempt, setAttempt] = useState(0); const [loading, setLoading] = useState(true); const [selectedContact, setSelectedContact] = useState(contactFields.filter(key => key !== "senderLocation")); useEffect(() => { let current = true; setLoading(true); setFailed(false); cvBuilderApi.list().then(rows => { if (current) setVariants(rows); }).catch(() => { if (current) setFailed(true); }).finally(() => { if (current) setLoading(false); }); return () => { current = false; }; }, [attempt]); useEffect(() => { let current = true; setSnapshot(null); if (!selected) return; setLoading(true); setFailed(false); const load = async () => { const variant = await cvBuilderApi.get(selected); if (!current) return null; if (kind === "contact") { const { data } = await api.get<{ profile: StructuredCvProfile }>("/career/profile"); const contact = data.profile.contact; return { ...settings, senderName: contact.fullName ?? "", senderTitle: variant.settings.headline?.trim() || contact.headline || "", senderEmail: contact.email ?? "", senderPhone: contact.phone ?? "", senderLocation: contact.location ?? "", sourceCvId: selected }; } const themes = await cvBuilderApi.themes() as ThemeDefaults[]; const theme = themes.find(row => row.id === variant.settings.themeId && row.available); if (!theme || !theme.bodyFont || !theme.baseFontSizePt || !theme.lineHeight || !theme.pageMarginMm) throw new Error("Missing effective theme defaults"); const source = variant.settings; const density = source.density === "compact" ? 0.82 : source.density === "roomy" ? 1.18 : 1; const margin = Math.min(28, Math.max(8, Math.round((source.pageMarginMm ?? theme.pageMarginMm) * density * 100) / 100)); return { ...settings, designVersion: 2, themeId: theme.id, accentColor: source.accentColor ?? theme.accent, bodyFont: source.bodyFont ?? theme.bodyFont, headingFont: source.headingFont ?? theme.headingFont ?? theme.bodyFont, baseFontSizePt: source.baseFontSizePt ?? theme.baseFontSizePt, lineHeight: source.lineHeight ?? theme.lineHeight, pageMarginMm: margin, pageSize: source.pageSize ?? "a4", sourceCvId: selected }; }; load().then(next => { if (current) setSnapshot(next); }).catch(() => { if (current) setFailed(true); }).finally(() => { if (current) setLoading(false); }); return () => { current = false; }; // The dialog captures the original local draft until the user explicitly applies or cancels. // eslint-disable-next-line react-hooks/exhaustive-deps }, [selected, kind, attempt]); const apply = () => { if (!snapshot) return; if (kind === "design") { onApply(snapshot); return; } const next = { ...settings }; const chosen = selectedContact.filter(key => snapshot[key].trim()); for (const key of chosen) next[key] = snapshot[key]; if (chosen.length) next.sourceCvId = snapshot.sourceCvId; onApply(next); }; return {t(kind === "contact" ? "letterCopyContact" : "letterMatchDesign")} {t("letterCopyHelp")} {kind === "contact" && {t("letterCopyContactHelp")}} setSelected(Number(event.target.value))}> {variants.map(variant => {variant.name})} {!loading && !failed && variants.length === 0 && {t("assetsNoCvVariants")}} {failed && setAttempt(value => value + 1)}>{t("retry")}}>{t("letterCopyFailed")}} {snapshot && {kind === "contact" ? {contactFields.map(key => setSelectedContact(current => checked ? [...current, key] : current.filter(field => field !== key))} />} />)} : <> {snapshot.themeId} · {snapshot.accentColor} · {snapshot.bodyFont} {snapshot.baseFontSizePt} pt · {snapshot.lineHeight} · {snapshot.pageMarginMm} mm · {snapshot.pageSize} } } ; } export function CoverLetterDocumentPreview({ jobId, text, settings, active }: { jobId: number; text: string; settings: CoverLetterDocumentSettings; active: boolean }) { const { t } = useI18n(); const host = useRef(null); const frame = useRef(null); const [html, setHtml] = useState(""); const [pageSize, setPageSize] = useState(settings.pageSize); const [loading, setLoading] = useState(false); const [failed, setFailed] = useState(false); const [attempt, setAttempt] = useState(0); const [pages, setPages] = useState(1); const [page, setPage] = useState(0); const [zoom, setZoom] = useState(0.5); const metrics = getCvPageMetrics(pageSize); useEffect(() => { let current = true; if (!active) return; setLoading(true); setFailed(false); const timer = window.setTimeout(() => { applicationAssetsApi.previewCoverLetter(jobId, text, settings).then(result => { if (!current) return; setHtml(result.html); setPageSize(settings.pageSize); setPage(0); setPages(1); }).catch(() => { if (current) setFailed(true); }).finally(() => { if (current) setLoading(false); }); }, 300); return () => { current = false; window.clearTimeout(timer); }; }, [jobId, text, settings, active, attempt]); useEffect(() => { const element = host.current; if (!element || !active) return; const fit = () => setZoom(getCvPreviewFitZoom(element.clientWidth || metrics.widthPx, metrics.widthPx, 16, 0.2)); fit(); if (typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver(fit); observer.observe(element); return () => observer.disconnect(); }, [metrics.widthPx, active]); const measure = () => { const element = frame.current; const doc = element?.contentDocument; const ready = () => { if (element === frame.current && doc === element?.contentDocument) setPages(Number(doc?.documentElement.dataset.cvPreviewPages) || 1); }; if (doc?.documentElement.dataset.cvPreviewReady === "true" || !doc?.querySelector(".page")) ready(); else element?.contentWindow?.addEventListener("cv-preview-ready", ready, { once: true }); }; return {t("letterDocumentPreview")} {loading && {t("letterPreviewLoading")}} {failed && setAttempt(value => value + 1)}>{t("retry")}}>{t("letterPreviewFailed")}} {html &&