diff --git a/job-tracker-ui/src/cv-builder-page.test.tsx b/job-tracker-ui/src/cv-builder-page.test.tsx index c73d084..ae6f71e 100644 --- a/job-tracker-ui/src/cv-builder-page.test.tsx +++ b/job-tracker-ui/src/cv-builder-page.test.tsx @@ -47,9 +47,12 @@ function renderPage() { } beforeEach(() => { + window.localStorage.removeItem('uiLanguage'); jest.clearAllMocks(); }); +afterEach(() => window.localStorage.removeItem('uiLanguage')); + test('lists existing CVs from the variants API', async () => { mockedApi.get.mockResolvedValueOnce({ data: [ @@ -87,6 +90,19 @@ test('shows the empty state and creates a CV then navigates to the editor', asyn await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/career/builder/42')); }); +test('CV dashboard and create flow render in Bokmål', async () => { + window.localStorage.setItem('uiLanguage', 'nb'); + mockedApi.get.mockResolvedValueOnce({ data: [] } as any); + + renderPage(); + + expect(await screen.findByText('Ingen CV-er ennå')).toBeInTheDocument(); + fireEvent.click(screen.getAllByRole('button', { name: 'Ny CV' })[0]); + expect(await screen.findByRole('dialog', { name: 'Opprett en CV' })).toBeInTheDocument(); + expect(screen.getByLabelText('CV-navn')).toHaveValue('CV uten tittel'); + expect(screen.getByText('Velg en mal')).toBeInTheDocument(); +}); + test('CV deletion requires confirmation and supports cancellation', async () => { mockedApi.get.mockResolvedValueOnce({ data: [ { id: 1, name: 'Frontend CV', themeId: 'modern', publicSlug: 'abc', isPublic: false, version: 2, jobApplicationId: null, updatedAtUtc: new Date().toISOString() }, diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index fb75210..dd98348 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -371,6 +371,41 @@ export const translations = { cvEditorSaveBeforeRestore: "Save the current CV before restoring an older version.", cvEditorRestoredVersion: "Restored version {version}.", cvEditorRestoreFailed: "Restore failed.", + cvDashboardTitle: "CV Builder", + cvDashboardSubtitle: "Build polished, job-specific resumes from one trusted career profile. Every version keeps its own template, content choices and history.", + cvDashboardUntitled: "Untitled CV", + cvDashboardLoadFailed: "Could not load your CVs.", + cvDashboardCreateFailed: "Could not create a CV.", + cvDashboardDuplicated: "CV duplicated.", + cvDashboardDuplicateFailed: "Duplicate failed.", + cvDashboardDeleteMessage: "Delete ‘{name}’ and its saved version history?", + cvDashboardThisCv: "this CV", + cvDashboardDeleteCv: "Delete CV", + cvDashboardDeleted: "CV deleted.", + cvDashboardDeleteFailed: "Delete failed.", + cvDashboardRenameMessage: "Give this CV a clear name.", + cvDashboardRenameCv: "Rename CV", + cvDashboardRename: "Rename", + cvDashboardRenamed: "CV renamed.", + cvDashboardRenameFailed: "Rename failed.", + cvDashboardDownloadFailed: "PDF download failed.", + cvDashboardNewCv: "New CV", + cvDashboardEmptyTitle: "No CVs yet", + cvDashboardEmptyBody: "Create your first CV — it pulls straight from your career profile.", + cvDashboardOpenNamed: "Open {name}", + cvDashboardActionsFor: "Actions for {name}", + cvDashboardUpdatedVersion: "Updated {date} · version {version}", + cvDashboardAt: " at ", + cvDashboardJobNumber: "job #{id}", + cvDashboardOpen: "Open", + cvDashboardDelete: "Delete", + cvDashboardCreateTitle: "Create a CV", + cvDashboardCreateHelp: "Start with your saved career profile, choose a visual direction, then tailor what appears.", + cvDashboardEnterName: "Enter a name.", + cvDashboardNameExample: "For example: Backend Engineer — Acme", + cvDashboardChooseTemplate: "Choose a template", + cvDashboardCreating: "Creating…", + cvDashboardCreateCv: "Create CV", richTextBold: "Bold", richTextBoldPlaceholder: "bold text", richTextItalic: "Italic", @@ -1941,6 +1976,41 @@ export const translations = { cvEditorSaveBeforeRestore: "Lagre CV-en før du gjenoppretter en eldre versjon.", cvEditorRestoredVersion: "Versjon {version} er gjenopprettet.", cvEditorRestoreFailed: "Gjenoppretting mislyktes.", + cvDashboardTitle: "CV-bygger", + cvDashboardSubtitle: "Lag profesjonelle, jobbtilpassede CV-er fra én pålitelig karriereprofil. Hver versjon beholder sin egen mal, sine innholdsvalg og sin historikk.", + cvDashboardUntitled: "CV uten tittel", + cvDashboardLoadFailed: "Kunne ikke laste CV-ene dine.", + cvDashboardCreateFailed: "Kunne ikke opprette en CV.", + cvDashboardDuplicated: "CV-en er duplisert.", + cvDashboardDuplicateFailed: "Duplisering mislyktes.", + cvDashboardDeleteMessage: "Slette «{name}» og den lagrede versjonshistorikken?", + cvDashboardThisCv: "denne CV-en", + cvDashboardDeleteCv: "Slett CV", + cvDashboardDeleted: "CV-en er slettet.", + cvDashboardDeleteFailed: "Sletting mislyktes.", + cvDashboardRenameMessage: "Gi denne CV-en et tydelig navn.", + cvDashboardRenameCv: "Gi CV-en nytt navn", + cvDashboardRename: "Gi nytt navn", + cvDashboardRenamed: "CV-en har fått nytt navn.", + cvDashboardRenameFailed: "Navneendring mislyktes.", + cvDashboardDownloadFailed: "Nedlasting av PDF mislyktes.", + cvDashboardNewCv: "Ny CV", + cvDashboardEmptyTitle: "Ingen CV-er ennå", + cvDashboardEmptyBody: "Opprett din første CV — den henter innhold direkte fra karriereprofilen din.", + cvDashboardOpenNamed: "Åpne {name}", + cvDashboardActionsFor: "Handlinger for {name}", + cvDashboardUpdatedVersion: "Oppdatert {date} · versjon {version}", + cvDashboardAt: " hos ", + cvDashboardJobNumber: "jobb #{id}", + cvDashboardOpen: "Åpne", + cvDashboardDelete: "Slett", + cvDashboardCreateTitle: "Opprett en CV", + cvDashboardCreateHelp: "Start med den lagrede karriereprofilen, velg en visuell retning og tilpass deretter hva som skal vises.", + cvDashboardEnterName: "Skriv inn et navn.", + cvDashboardNameExample: "For eksempel: Backendutvikler — Acme", + cvDashboardChooseTemplate: "Velg en mal", + cvDashboardCreating: "Oppretter…", + cvDashboardCreateCv: "Opprett CV", richTextBold: "Fet", richTextBoldPlaceholder: "fet tekst", richTextItalic: "Kursiv", diff --git a/job-tracker-ui/src/views/CvBuilderPage.tsx b/job-tracker-ui/src/views/CvBuilderPage.tsx index fec3cbb..a25115e 100644 --- a/job-tracker-ui/src/views/CvBuilderPage.tsx +++ b/job-tracker-ui/src/views/CvBuilderPage.tsx @@ -14,10 +14,12 @@ import { useToast } from "../toast"; import { CvTheme, CvVariantSummary, cvBuilderApi, emptyCvVariantSettings } from "../cvBuilder"; import { useDialogActions } from "../dialogs"; import CvTemplateThumbnail from "../components/CvTemplateThumbnail"; +import { useI18n } from "../i18n/I18nProvider"; export default function CvBuilderPage() { const navigate = useNavigate(); const { toast } = useToast(); + const { language, t } = useI18n(); const { confirmAction, promptForValue } = useDialogActions(); const [variants, setVariants] = useState([]); const [loading, setLoading] = useState(true); @@ -25,7 +27,7 @@ export default function CvBuilderPage() { const [menu, setMenu] = useState<{ anchor: HTMLElement; id: number } | null>(null); const [themes, setThemes] = useState([]); const [createOpen, setCreateOpen] = useState(false); - const [newName, setNewName] = useState("Untitled CV"); + const [newName, setNewName] = useState(t("cvDashboardUntitled")); const [newTheme, setNewTheme] = useState("modern"); const [creating, setCreating] = useState(false); @@ -34,7 +36,7 @@ export default function CvBuilderPage() { setVariants(await cvBuilderApi.list()); try { setThemes(await cvBuilderApi.themes()); } catch { setThemes([]); } } catch (err) { - setError(getApiErrorMessage(err, "Could not load your CVs.")); + setError(getApiErrorMessage(err, t("cvDashboardLoadFailed"))); } finally { setLoading(false); } @@ -55,7 +57,7 @@ export default function CvBuilderPage() { setCreateOpen(false); navigate(`/career/builder/${variant.id}`); } catch (err) { - toast(getApiErrorMessage(err, "Could not create a CV."), "error"); + toast(getApiErrorMessage(err, t("cvDashboardCreateFailed")), "error"); } finally { setCreating(false); } }; @@ -63,9 +65,9 @@ export default function CvBuilderPage() { try { await cvBuilderApi.duplicate(id); await load(); - toast("CV duplicated.", "success"); + toast(t("cvDashboardDuplicated"), "success"); } catch (err) { - toast(getApiErrorMessage(err, "Duplicate failed."), "error"); + toast(getApiErrorMessage(err, t("cvDashboardDuplicateFailed")), "error"); } finally { setMenu(null); } @@ -74,17 +76,17 @@ export default function CvBuilderPage() { const remove = async (id: number) => { const variant = variants.find((item) => item.id === id); setMenu(null); - if (!(await confirmAction(`Delete "${variant?.name ?? "this CV"}" and its saved version history?`, { - title: "Delete CV", - confirmLabel: "Delete CV", + if (!(await confirmAction(t("cvDashboardDeleteMessage", { name: variant?.name ?? t("cvDashboardThisCv") }), { + title: t("cvDashboardDeleteCv"), + confirmLabel: t("cvDashboardDeleteCv"), destructive: true, }))) return; try { await cvBuilderApi.remove(id); setVariants((v) => v.filter((x) => x.id !== id)); - toast("CV deleted.", "success"); + toast(t("cvDashboardDeleted"), "success"); } catch (err) { - toast(getApiErrorMessage(err, "Delete failed."), "error"); + toast(getApiErrorMessage(err, t("cvDashboardDeleteFailed")), "error"); } finally { setMenu(null); } @@ -93,14 +95,14 @@ export default function CvBuilderPage() { const rename = async (id: number) => { const current = variants.find((item) => item.id === id); setMenu(null); - const nextName = await promptForValue("Give this CV a clear name.", current?.name ?? "", { title: "Rename CV", confirmLabel: "Rename" }); + const nextName = await promptForValue(t("cvDashboardRenameMessage"), current?.name ?? "", { title: t("cvDashboardRenameCv"), confirmLabel: t("cvDashboardRename") }); if (!nextName?.trim() || nextName.trim() === current?.name) return; try { const variant = await cvBuilderApi.get(id); const updated = await cvBuilderApi.save(id, { name: nextName.trim(), settings: variant.settings, source: "manual" }); setVariants((items) => items.map((item) => item.id === id ? { ...item, name: updated.name, updatedAtUtc: updated.updatedAtUtc, version: updated.version } : item)); - toast("CV renamed.", "success"); - } catch (err) { toast(getApiErrorMessage(err, "Rename failed."), "error"); } + toast(t("cvDashboardRenamed"), "success"); + } catch (err) { toast(getApiErrorMessage(err, t("cvDashboardRenameFailed")), "error"); } }; const download = async (id: number, cvName: string) => { @@ -113,17 +115,17 @@ export default function CvBuilderPage() { link.download = `${cvName || "cv"}.pdf`; link.click(); URL.revokeObjectURL(url); - } catch (err) { toast(getApiErrorMessage(err, "PDF download failed."), "error"); } + } catch (err) { toast(getApiErrorMessage(err, t("cvDashboardDownloadFailed")), "error"); } }; return ( - CV Builder - Build polished, job-specific resumes from one trusted career profile. Every version keeps its own template, content choices and history. + {t("cvDashboardTitle")} + {t("cvDashboardSubtitle")} - + {error && {error}} @@ -131,9 +133,9 @@ export default function CvBuilderPage() { {!loading && variants.length === 0 && !error && ( - No CVs yet - Create your first CV — it pulls straight from your career profile. - + {t("cvDashboardEmptyTitle")} + {t("cvDashboardEmptyBody")} + )} @@ -143,7 +145,7 @@ export default function CvBuilderPage() { key={v.id} role="link" tabIndex={0} - aria-label={`Open ${v.name}`} + aria-label={t("cvDashboardOpenNamed", { name: v.name })} sx={{ p: 1.5, borderRadius: 3, cursor: "pointer", border: "1px solid", borderColor: "divider", transition: "transform 150ms ease, box-shadow 150ms ease", "&:hover": { boxShadow: 5, transform: "translateY(-2px)" }, "&:focus-visible": { outline: "3px solid", outlineColor: "primary.main", outlineOffset: 2 } }} onClick={() => navigate(`/career/builder/${v.id}`)} onKeyDown={(event) => { @@ -156,42 +158,42 @@ export default function CvBuilderPage() { theme.id === v.themeId)} /> {v.name} - { e.stopPropagation(); setMenu({ anchor: e.currentTarget, id: v.id }); }}> + { e.stopPropagation(); setMenu({ anchor: e.currentTarget, id: v.id }); }}> - {v.isPublic && } label="Public" />} + {v.isPublic && } label={t("cvEditorPublic")} />} - Updated {new Date(v.updatedAtUtc).toLocaleDateString()} · version {v.version} - {v.jobApplicationId ? ` · ${[v.jobTitle, v.companyName].filter(Boolean).join(" at ") || `job #${v.jobApplicationId}`}` : ""} + {t("cvDashboardUpdatedVersion", { date: new Date(v.updatedAtUtc).toLocaleDateString(language === "nb" ? "nb-NO" : "en-GB"), version: v.version })} + {v.jobApplicationId ? ` · ${[v.jobTitle, v.companyName].filter(Boolean).join(t("cvDashboardAt")) || t("cvDashboardJobNumber", { id: v.jobApplicationId })}` : ""} ))} setMenu(null)}> - menu && navigate(`/career/builder/${menu.id}`)}>Open - menu && duplicate(menu.id)}>Duplicate - menu && void rename(menu.id)}>Rename - { const cv = variants.find((item) => item.id === menu?.id); if (cv) void download(cv.id, cv.name); }}>Download PDF - menu && remove(menu.id)} sx={{ color: "error.main" }}>Delete + menu && navigate(`/career/builder/${menu.id}`)}>{t("cvDashboardOpen")} + menu && duplicate(menu.id)}>{t("cvEditorDuplicate")} + menu && void rename(menu.id)}>{t("cvDashboardRename")} + { const cv = variants.find((item) => item.id === menu?.id); if (cv) void download(cv.id, cv.name); }}>{t("cvEditorDownloadPdf")} + menu && remove(menu.id)} sx={{ color: "error.main" }}>{t("cvDashboardDelete")} { if (!creating) setCreateOpen(false); }} fullWidth maxWidth="md" aria-labelledby="create-cv-title"> - Create a CV + {t("cvDashboardCreateTitle")} - Start with your saved career profile, choose a visual direction, then tailor what appears. - setNewName(event.target.value)} error={!newName.trim()} helperText={!newName.trim() ? "Enter a name." : "For example: Backend Engineer — Acme"} sx={{ mb: 2 }} /> - Choose a template + {t("cvDashboardCreateHelp")} + setNewName(event.target.value)} error={!newName.trim()} helperText={!newName.trim() ? t("cvDashboardEnterName") : t("cvDashboardNameExample")} sx={{ mb: 2 }} /> + {t("cvDashboardChooseTemplate")} {(themes.length ? themes.filter((theme) => theme.available) : [{ id: "modern", name: "Modern", category: "Professional", layout: "header-band", swatches: ["#3157d5", "#eef1f4", "#fff"] } as CvTheme]).map((theme) => setNewTheme(theme.id)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setNewTheme(theme.id); } }} variant="outlined" sx={{ p: 0.75, cursor: "pointer", borderWidth: newTheme === theme.id ? 2 : 1, borderColor: newTheme === theme.id ? "primary.main" : "divider", "&:focus-visible": { outline: "3px solid", outlineColor: "primary.main" } }}>{theme.name}{theme.category})} - + );