fix(i18n): localize generated labels without changing saved content

This commit is contained in:
cesnimda
2026-10-20 20:27:42 +02:00
parent d8c0187305
commit ab89b6984c
17 changed files with 1106 additions and 31 deletions
@@ -404,6 +404,15 @@ export function SubmittedPackagesSection({ jobId, allowCapture = true }: { jobId
// ---------- Tailoring ----------
const tailoringLabels = new Map<string, [TranslationKey, TranslationKey]>([
["highlight-skills", ["assetsTailoringHighlightTitle", "assetsTailoringHighlightDetail"]],
["prioritise-experience", ["assetsTailoringExperienceTitle", "assetsTailoringExperienceDetail"]],
["emphasise-projects", ["assetsTailoringProjectsTitle", "assetsTailoringProjectsDetail"]],
["career-evidence", ["assetsTailoringEvidenceTitle", "assetsTailoringEvidenceDetail"]],
["include-keywords", ["assetsTailoringKeywordsTitle", "assetsTailoringKeywordsDetail"]],
["gaps", ["assetsTailoringGapsTitle", "assetsTailoringGapsDetail"]],
]);
export function ApplicationTailoringSection({ jobId }: { jobId: number }) {
const { t } = useI18n();
const { data, error, loading, reload } = useAsset<TailoringPlan>(
@@ -437,11 +446,13 @@ export function ApplicationTailoringSection({ jobId }: { jobId: number }) {
{t("assetsNoSuggestions")}
</Typography>
) : (
(data?.suggestions ?? []).map((s) => (
(data?.suggestions ?? []).map((s) => {
const labels = tailoringLabels.get(s.kind);
return (
<Box key={s.kind}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>{s.title}</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>{labels ? t(labels[0]) : s.title}</Typography>
{s.detail && (
<Typography variant="caption" color="text.secondary">{s.detail}</Typography>
<Typography variant="caption" color="text.secondary">{labels ? t(labels[1]) : s.detail}</Typography>
)}
<Stack direction="row" flexWrap="wrap" gap={0.5} sx={{ mt: 0.75 }}>
{s.items.map((item) => (
@@ -449,7 +460,7 @@ export function ApplicationTailoringSection({ jobId }: { jobId: number }) {
))}
</Stack>
</Box>
))
); })
)}
</Stack>
</Shell>
@@ -458,6 +469,18 @@ export function ApplicationTailoringSection({ jobId }: { jobId: number }) {
// ---------- Cover letter ----------
const historySources = new Map<string, TranslationKey>([
["manual", "historySourceManual"], ["ai", "historySourceAi"],
["template", "historySourceTemplate"], ["restore", "historySourceRestore"],
]);
const historyAiActions = new Map<string, TranslationKey>([
["generate", "coverActionGenerate"], ["regenerate", "coverActionRegenerate"],
["improve", "coverActionImprove"], ["shorten", "coverActionShorten"],
["expand", "coverActionExpand"], ["professional", "coverActionProfessional"],
["natural", "coverActionNatural"], ["grammar", "coverActionGrammar"],
["tone", "coverAiTone"], ["tailor", "coverActionTailor"],
]);
const COVER_LETTER_TEMPLATES = {
en: `Dear Hiring Manager,
@@ -615,7 +638,12 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
</Typography>
) : (
<Stack spacing={0.5}>
{(data?.versions ?? []).map((v) => (
{(data?.versions ?? []).map((v) => {
const sourceKey = historySources.get(v.source);
const actionKey = historyAiActions.get(v.aiAction ?? "");
const source = sourceKey ? t(sourceKey) : v.source;
const action = actionKey ? t(actionKey) : v.aiAction;
return (
<Stack
key={v.version}
direction="row"
@@ -626,7 +654,7 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
<Box sx={{ minWidth: 0 }}>
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap">
<Typography variant="body2" sx={{ fontWeight: 600 }}>v{v.version}</Typography>
<Chip size="small" variant="outlined" label={v.aiAction ? `${v.source} · ${v.aiAction}` : v.source} />
<Chip size="small" variant="outlined" label={action ? `${source} · ${action}` : source} />
{v.isCurrent && <Chip size="small" color="primary" variant="outlined" label={t("assetsCurrent")} />}
</Stack>
<Typography variant="caption" color="text.secondary">
@@ -648,7 +676,7 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
</Tooltip>
)}
</Stack>
))}
); })}
</Stack>
)}
</Shell>
@@ -4,6 +4,7 @@ import { Box, Button, Chip, Paper, Stack, Typography } from "@mui/material";
import { CvTheme, getVisibleCvThemes } from "../cvBuilder";
import { useI18n } from "../i18n/I18nProvider";
import type { TranslationKey } from "../i18n/translations";
import CvTemplateThumbnail from "./CvTemplateThumbnail";
type Props = {
@@ -15,8 +16,20 @@ type Props = {
disabled?: boolean;
};
const templateLabels = new Map<string, [TranslationKey, TranslationKey, TranslationKey]>([
["modern", ["cvTemplateModernName", "cvTemplateCategoryModern", "cvTemplateModernDescription"]],
["minimal", ["cvTemplateMinimalName", "cvTemplateCategoryMinimal", "cvTemplateMinimalDescription"]],
["executive", ["cvTemplateExecutiveName", "cvTemplateCategoryExecutive", "cvTemplateExecutiveDescription"]],
["technical", ["cvTemplateTechnicalName", "cvTemplateCategoryTechnical", "cvTemplateTechnicalDescription"]],
["ats-classic", ["cvTemplateAtsClassicName", "cvTemplateCategoryAts", "cvTemplateAtsClassicDescription"]],
["nordic", ["cvTemplateNordicName", "cvTemplateCategoryModern", "cvTemplateNordicDescription"]],
["elegant", ["cvTemplateElegantName", "cvTemplateCategoryCreative", "cvTemplateElegantDescription"]],
["creative", ["cvTemplateCreativeName", "cvTemplateCategoryCreative", "cvTemplateCreativeDescription"]],
["code", ["cvTemplateCodeName", "cvTemplateCategoryTechnical", "cvTemplateCodeDescription"]],
]);
export default function CvTemplateGallery({ themes, selectedId, onSelect, previewHeight = 170, showDescriptions = true, disabled = false }: Props) {
const { t } = useI18n();
const { t, language } = useI18n();
const id = useId();
const [atsOnly, setAtsOnly] = useState(true);
const visibleThemes = useMemo(() => getVisibleCvThemes(themes, atsOnly, selectedId), [atsOnly, selectedId, themes]);
@@ -33,16 +46,18 @@ export default function CvTemplateGallery({ themes, selectedId, onSelect, previe
const active = theme.id === selectedId;
const locked = theme.available === false;
const unavailable = locked || disabled;
const labels = language === "nb" ? templateLabels.get(theme.id) : undefined;
const name = labels ? t(labels[0]) : theme.name;
return (
<Paper key={theme.id} variant="outlined" role="button" tabIndex={unavailable ? -1 : 0} aria-label={theme.name} aria-describedby={`${id}-${theme.id}-category ${showDescriptions ? `${id}-${theme.id}-description ` : ""}${id}-${theme.id}-features`} aria-disabled={unavailable} aria-pressed={active}
<Paper key={theme.id} variant="outlined" role="button" tabIndex={unavailable ? -1 : 0} aria-label={name} aria-describedby={`${id}-${theme.id}-category ${showDescriptions ? `${id}-${theme.id}-description ` : ""}${id}-${theme.id}-features`} aria-disabled={unavailable} aria-pressed={active}
onClick={() => { if (!unavailable) onSelect(theme); }}
onKeyDown={(event) => { if (!unavailable && (event.key === "Enter" || event.key === " ")) { event.preventDefault(); onSelect(theme); } }}
sx={{ p: 1, cursor: unavailable ? "not-allowed" : "pointer", position: "relative", minWidth: 0, overflowWrap: "anywhere", borderColor: active ? "primary.main" : "divider", borderWidth: active ? 2 : 1, "&:hover": unavailable ? undefined : { borderColor: "primary.main" } }}>
{active && <CheckCircleIcon aria-label={t("cvEditorSelectedTemplate")} color="primary" sx={{ position: "absolute", zIndex: 2, top: 6, right: 6, bgcolor: "background.paper", borderRadius: "50%" }} />}
<Box sx={{ mb: 1 }}><CvTemplateThumbnail theme={theme} height={previewHeight} /></Box>
<Typography variant="body2" sx={{ fontWeight: 750 }}>{theme.name}</Typography>
<Typography id={`${id}-${theme.id}-category`} variant="caption" color="text.secondary" sx={{ display: "block" }}>{theme.category}</Typography>
{showDescriptions && <Typography id={`${id}-${theme.id}-description`} variant="caption" color="text.secondary" sx={{ display: "block", mt: 0.25, lineHeight: 1.35 }}>{theme.description}</Typography>}
<Typography variant="body2" sx={{ fontWeight: 750 }}>{name}</Typography>
<Typography id={`${id}-${theme.id}-category`} variant="caption" color="text.secondary" sx={{ display: "block" }}>{labels ? t(labels[1]) : theme.category}</Typography>
{showDescriptions && <Typography id={`${id}-${theme.id}-description`} variant="caption" color="text.secondary" sx={{ display: "block", mt: 0.25, lineHeight: 1.35 }}>{labels ? t(labels[2]) : theme.description}</Typography>}
<Stack id={`${id}-${theme.id}-features`} direction="row" spacing={0.5} alignItems="center" flexWrap="wrap" useFlexGap sx={{ mt: 0.75 }}>
{theme.atsFriendly && <Chip size="small" label={t("cvEditorAtsFriendly")} color="success" variant="outlined" sx={{ height: 20, fontSize: 10 }} />}
{theme.requiresPro && <Chip size="small" label={locked ? t("cvEditorPro") : t("cvEditorProUnlocked")} color="secondary" variant="outlined" sx={{ height: 20, fontSize: 10 }} />}
@@ -12,6 +12,33 @@ import { useI18n } from "../i18n/I18nProvider";
import { useViewResource, type ViewResourceError } from "../hooks/useViewResource";
import { PageHeader } from "../layout/PageLayout";
import { statusLabel } from "../pipeline";
import type { TranslationKey } from "../i18n/translations";
const generatedReasons = new Map<string, TranslationKey>([
["Interview stage reached but prep notes are still missing.", "reminderReasonInterview"],
["Tailored CV and saved application answers still need work.", "reminderReasonCvAndAnswers"],
["Tailored CV missing for this role.", "reminderReasonCv"],
["Saved application answers still need work.", "reminderReasonAnswers"],
["No response yet and no follow-up is scheduled.", "reminderReasonUnscheduled"],
["Follow-up is due for this role.", "reminderReasonDue"],
["No urgent workflow gaps are blocking this job right now.", "reminderReasonNoGaps"],
]);
const generatedDayReasons: [RegExp, TranslationKey][] = [
[/^No reply after (\d+)d$/, "reminderReasonNoReplyDays"],
[/^Stalled after (\d+)d$/, "reminderReasonStalledDays"],
[/^Feedback requested (\d+)d ago$/, "reminderReasonFeedbackDays"],
[/^Waiting (\d+)d$/, "reminderReasonWaitingDays"],
];
function reminderReason(reason: string, t: ReturnType<typeof useI18n>["t"]) {
const key = generatedReasons.get(reason);
if (key) return t(key);
for (const [pattern, dayKey] of generatedDayReasons) {
const match = pattern.exec(reason);
if (match) return t(dayKey, { days: match[1] });
}
return reason;
}
type FollowUpFailure = {
job: Pick<JobApplication, "id" | "jobTitle">;
@@ -62,7 +89,7 @@ function ReminderSection({ title, items, disabled, onOpen, onSetFollowUp }: { ti
</Typography>
<Box sx={{ display: "flex", gap: 1, mt: 0.5, flexWrap: "wrap", minWidth: 0, "& .MuiChip-root": { maxWidth: "100%", height: "auto", minHeight: 24 }, "& .MuiChip-label": { whiteSpace: "normal", overflowWrap: "anywhere", py: 0.5 } }}>
{j.needsFollowUp ? <Chip size="small" color="warning" label={t("remindersFollowUpLabel")} /> : null}
{(j.workflowSignal?.reason ?? j.followUpReason) ? <Chip size="small" label={j.workflowSignal?.reason ?? j.followUpReason} variant="outlined" /> : null}
{(j.workflowSignal?.reason ?? j.followUpReason) ? <Chip size="small" label={reminderReason((j.workflowSignal?.reason ?? j.followUpReason)!, t)} variant="outlined" /> : null}
{j.followUpAt ? <Chip size="small" label={t("remindersFollowUpDate", { date: new Date(j.followUpAt).toLocaleDateString(language === "nb" ? "nb-NO" : "en") })} variant="outlined" /> : null}
<Chip size="small" label={statusLabel(t, j.status)} variant="outlined" />
</Box>