fix(i18n): localize application workflows
Use stable checklist and interview keys for system copy while preserving user-authored content. Localize creation-tab errors, language selectors, match bands, dates, and selected-CV context.
This commit is contained in:
@@ -26,6 +26,7 @@ import { useI18n } from "../i18n/I18nProvider";
|
||||
// is application-specific by nature. docs/architecture/application-workspace.md.
|
||||
|
||||
function useAsset<T>(load: () => Promise<T>, deps: React.DependencyList) {
|
||||
const { t } = useI18n();
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -44,7 +45,7 @@ function useAsset<T>(load: () => Promise<T>, deps: React.DependencyList) {
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(getApiErrorMessage(err, "Could not load this section."));
|
||||
if (!cancelled) setError(getApiErrorMessage(err, t("assetsLoadFailed")));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
@@ -52,7 +53,7 @@ function useAsset<T>(load: () => Promise<T>, deps: React.DependencyList) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [run]);
|
||||
}, [run, t]);
|
||||
|
||||
useEffect(() => reload(), [reload]);
|
||||
|
||||
@@ -98,7 +99,7 @@ export function ApplicationCvSection({ jobId }: { jobId: number }) {
|
||||
setData(await applicationAssetsApi.attachVariant(jobId, variantId));
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(getApiErrorMessage(err, "Could not change the attached CV."));
|
||||
setError(getApiErrorMessage(err, t("assetsAttachCvFailed")));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -110,11 +111,11 @@ export function ApplicationCvSection({ jobId }: { jobId: number }) {
|
||||
if (!data?.attachedVariantId) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const copy = await cvBuilderApi.duplicate(data.attachedVariantId, `${data.attachedVariantName || "CV"} — tailored copy`);
|
||||
const copy = await cvBuilderApi.duplicate(data.attachedVariantId, t("assetsTailoredCopyName", { name: data.attachedVariantName || "CV" }));
|
||||
setData(await applicationAssetsApi.attachVariant(jobId, copy.id));
|
||||
window.location.assign(`/career/builder/${copy.id}`);
|
||||
} catch (err) {
|
||||
setError(getApiErrorMessage(err, "Could not create a tailored CV copy."));
|
||||
setError(getApiErrorMessage(err, t("assetsDuplicateCvFailed")));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -308,7 +309,7 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
|
||||
setDraftAiAction(null);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(getApiErrorMessage(err, "Could not save the cover letter."));
|
||||
setError(getApiErrorMessage(err, t("assetsCoverSaveFailed")));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -322,7 +323,7 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
|
||||
setDraftAiAction(null);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(getApiErrorMessage(err, "Could not restore that version."));
|
||||
setError(getApiErrorMessage(err, t("assetsCoverRestoreFailed")));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -394,7 +395,7 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
|
||||
{v.isCurrent && <Chip size="small" color="primary" variant="outlined" label={t("assetsCurrent")} />}
|
||||
</Stack>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{new Date(v.createdAtUtc).toLocaleString()} · {t("assetsCharacterCount", { count: v.length })}
|
||||
{new Date(v.createdAtUtc).toLocaleString(language === "nb" ? "nb-NO" : "en")} · {t("assetsCharacterCount", { count: v.length })}
|
||||
</Typography>
|
||||
</Box>
|
||||
{!v.isCurrent && (
|
||||
@@ -458,7 +459,7 @@ function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number
|
||||
});
|
||||
setSuggestion(result.result.text?.trim() ?? "");
|
||||
} catch (err) {
|
||||
setError(getApiErrorMessage(err, "Could not generate a cover-letter suggestion."));
|
||||
setError(getApiErrorMessage(err, t("coverAiGenerateFailed")));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -499,8 +500,8 @@ function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number
|
||||
<FormControl size="small" sx={{ minWidth: 190 }}>
|
||||
<InputLabel>{t("coverAiDocumentLanguage")}</InputLabel>
|
||||
<Select label={t("coverAiDocumentLanguage")} value={language} onChange={(event) => setLanguage(event.target.value as "en" | "nb-NO")}>
|
||||
<MenuItem value="en">English</MenuItem>
|
||||
<MenuItem value="nb-NO">Norsk bokmål</MenuItem>
|
||||
<MenuItem value="en">{t("languageEnglish")}</MenuItem>
|
||||
<MenuItem value="nb-NO">{t("languageNorwegianBokmal")}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
@@ -598,7 +599,7 @@ export function ApplicationPackageDraftsSection({
|
||||
setError(null);
|
||||
onSaved?.();
|
||||
} catch (err) {
|
||||
setError(getApiErrorMessage(err, "Could not save the application drafts."));
|
||||
setError(getApiErrorMessage(err, t("assetsDraftsSaveFailed")));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
|
||||
import { getApiErrorMessage } from "../api";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import {
|
||||
CHECKLIST_CATEGORIES, Checklist, ChecklistItem, applicationChecklistApi,
|
||||
CHECKLIST_CATEGORIES, Checklist, ChecklistItem, applicationChecklistApi, checklistSystemTranslationToken,
|
||||
} from "../applicationWorkspace";
|
||||
|
||||
// Phase 5 Milestone 2 — the application checklist.
|
||||
@@ -214,8 +214,8 @@ function checklistCategoryLabel(t: (key: any, vars?: Record<string, string | num
|
||||
}
|
||||
|
||||
function checklistItemDisplay(t: (key: any, vars?: Record<string, string | number>) => string, item: ChecklistItem) {
|
||||
if (!item.systemKey || item.systemKey.startsWith("learning:")) return { title: item.title, description: item.description };
|
||||
const token = item.systemKey.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase());
|
||||
const token = checklistSystemTranslationToken(item.systemKey);
|
||||
if (!token) return { title: item.title, description: item.description };
|
||||
return {
|
||||
title: t(`checklistItem_${token}`),
|
||||
description: t(`checklistItem_${token}Description`),
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useI18n } from "../i18n/I18nProvider";
|
||||
// One loader for all three sections: same fetch/loading/empty/error shape, so the sections stay
|
||||
// consistent and each one is just its own rendering.
|
||||
function useIntelligence<T>(load: () => Promise<T>, deps: React.DependencyList) {
|
||||
const { t } = useI18n();
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -38,7 +39,7 @@ function useIntelligence<T>(load: () => Promise<T>, deps: React.DependencyList)
|
||||
setError(null);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(getApiErrorMessage(err, "Could not load this section."));
|
||||
if (!cancelled) setError(getApiErrorMessage(err, t("intelligenceLoadFailed")));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
@@ -46,7 +47,7 @@ function useIntelligence<T>(load: () => Promise<T>, deps: React.DependencyList)
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [run]);
|
||||
}, [run, t]);
|
||||
|
||||
return { data, error, loading };
|
||||
}
|
||||
@@ -110,7 +111,7 @@ function Bullets({ label, values }: { label: string; values: string[] }) {
|
||||
// ---------- Timeline ----------
|
||||
|
||||
export function ApplicationTimeline({ jobId }: { jobId: number }) {
|
||||
const { t } = useI18n();
|
||||
const { language, t } = useI18n();
|
||||
const [category, setCategory] = useState<string>("");
|
||||
const { data, error, loading } = useIntelligence<Timeline>(
|
||||
() => applicationIntelligenceApi.timeline(jobId, category || undefined),
|
||||
@@ -129,7 +130,7 @@ export function ApplicationTimeline({ jobId }: { jobId: number }) {
|
||||
<Stack key={m.id} direction="row" spacing={1} justifyContent="space-between" alignItems="baseline">
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{m.summary}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{new Date(m.at).toLocaleDateString()}
|
||||
{new Date(m.at).toLocaleDateString(language === "nb" ? "nb-NO" : "en")}
|
||||
</Typography>
|
||||
</Stack>
|
||||
))}
|
||||
@@ -267,7 +268,7 @@ export function ApplicationMatch({ jobId }: { jobId: number }) {
|
||||
<SectionShell
|
||||
title={t("workspaceCvMatch")}
|
||||
subtitle={data?.selectedCvName
|
||||
? `${t("workspaceComparingCv")} ${data.selectedCvName}`
|
||||
? t("workspaceComparingCv", { name: data.selectedCvName })
|
||||
: t("workspaceSelectCvMatch")}
|
||||
loading={loading}
|
||||
error={error}
|
||||
@@ -286,7 +287,7 @@ export function ApplicationMatch({ jobId }: { jobId: number }) {
|
||||
<Box>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="baseline">
|
||||
<Typography variant="h4" sx={{ fontWeight: 900 }}>{data?.score}%</Typography>
|
||||
<Chip size="small" label={data?.band} variant="outlined" />
|
||||
<Chip size="small" label={matchBandLabel(t, data?.band)} variant="outlined" />
|
||||
</Stack>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
@@ -369,3 +370,10 @@ function timelineCategoryLabel(t: (key: any) => string, category: string): strin
|
||||
};
|
||||
return keys[category] ? t(keys[category]) : category;
|
||||
}
|
||||
|
||||
function matchBandLabel(t: (key: any) => string, band?: string): string {
|
||||
if (!band) return t("matchScoreBand_Unknown");
|
||||
return ["Strong", "Partial", "Low", "Unknown"].includes(band)
|
||||
? t(`matchScoreBand_${band}`)
|
||||
: band;
|
||||
}
|
||||
|
||||
@@ -61,11 +61,11 @@ export function ApplicationInterviewPrep({ jobId }: { jobId: number }) {
|
||||
setBoard(await interviewPrepApi.get(jobId));
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(getApiErrorMessage(err, "Could not load interview preparation."));
|
||||
setError(getApiErrorMessage(err, t("interviewPrepLoadFailed")));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [jobId]);
|
||||
}, [jobId, t]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
@@ -77,7 +77,7 @@ export function ApplicationInterviewPrep({ jobId }: { jobId: number }) {
|
||||
await run();
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(getApiErrorMessage(err, "Could not update interview preparation."));
|
||||
setError(getApiErrorMessage(err, t("interviewPrepUpdateFailed")));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -133,7 +133,7 @@ export function ApplicationInterviewPrep({ jobId }: { jobId: number }) {
|
||||
(board?.groups ?? []).map((group) => (
|
||||
<Box key={group.category}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 800, textTransform: "uppercase", letterSpacing: ".06em", color: "text.secondary" }}>
|
||||
{group.label}
|
||||
{interviewCategoryLabel(t, group.category)}
|
||||
</Typography>
|
||||
<Stack sx={{ mt: 0.5 }}>
|
||||
{group.items.map((item) => (
|
||||
@@ -163,7 +163,7 @@ export function ApplicationInterviewPrep({ jobId }: { jobId: number }) {
|
||||
sx={{ minWidth: { sm: 200 } }}
|
||||
>
|
||||
{INTERVIEW_PREP_CATEGORIES.map((c) => (
|
||||
<MenuItem key={c.key} value={c.key}>{c.label}</MenuItem>
|
||||
<MenuItem key={c.key} value={c.key}>{interviewCategoryLabel(t, c.key)}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
@@ -271,8 +271,8 @@ function InterviewAiAssistant({ jobId, onAccepted }: { jobId: number; onAccepted
|
||||
{INTERVIEW_AI_FOCUS.map((item) => <MenuItem key={item.key} value={item.key}>{t(item.labelKey)}</MenuItem>)}
|
||||
</TextField>
|
||||
<TextField select size="small" label={t("coverAiDocumentLanguage")} value={language} onChange={(event) => setLanguage(event.target.value as "en" | "nb-NO")} sx={{ minWidth: 190 }}>
|
||||
<MenuItem value="en">English</MenuItem>
|
||||
<MenuItem value="nb-NO">Norsk bokmål</MenuItem>
|
||||
<MenuItem value="en">{t("languageEnglish")}</MenuItem>
|
||||
<MenuItem value="nb-NO">{t("languageNorwegianBokmal")}</MenuItem>
|
||||
</TextField>
|
||||
</Stack>
|
||||
<TextField multiline minRows={2} fullWidth label={t("coverAiAdditionalInstructions")} value={instructions} onChange={(event) => setInstructions(event.target.value)} />
|
||||
@@ -317,7 +317,7 @@ function PrepRow({ jobId, item, busy, onChanged, onError }: {
|
||||
setDraft(null);
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
onError(getApiErrorMessage(err, "Could not save this answer."));
|
||||
onError(getApiErrorMessage(err, t("interviewPrepAnswerSaveFailed")));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -409,11 +409,11 @@ export function ApplicationFollowUp({ jobId }: { jobId: number }) {
|
||||
setAction(result.nextAction ?? "");
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(getApiErrorMessage(err, "Could not load follow-up."));
|
||||
setError(getApiErrorMessage(err, t("interviewFollowUpLoadFailed")));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [jobId]);
|
||||
}, [jobId, t]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
@@ -426,7 +426,7 @@ export function ApplicationFollowUp({ jobId }: { jobId: number }) {
|
||||
setData(result);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(getApiErrorMessage(err, "Could not save the follow-up."));
|
||||
setError(getApiErrorMessage(err, t("interviewFollowUpSaveFailed")));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -476,3 +476,15 @@ export function ApplicationFollowUp({ jobId }: { jobId: number }) {
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
function interviewCategoryLabel(t: (key: any) => string, category: string) {
|
||||
const keys: Record<string, string> = {
|
||||
"company-research": "interviewCategoryCompanyResearch",
|
||||
technical: "interviewCategoryTechnical",
|
||||
behavioural: "interviewCategoryBehavioural",
|
||||
star: "interviewCategoryStar",
|
||||
question: "interviewCategoryQuestions",
|
||||
note: "interviewCategoryNotes",
|
||||
};
|
||||
return keys[category] ? t(keys[category]) : category;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user