feat(cv): localise section AI output

This commit is contained in:
cesnimda
2026-08-28 20:30:42 +02:00
parent 557121ad31
commit 245b39f6fa
4 changed files with 154 additions and 19 deletions
@@ -1,4 +1,4 @@
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh";
import { Alert, Box, Button, MenuItem, Paper, Stack, TextField, Typography } from "@mui/material";
@@ -8,23 +8,30 @@ import { useAccountPlan } from "../../accountPlan";
import { AI_ACTIONS, cvBuilderApi } from "../../cvBuilder";
import { useToast } from "../../toast";
import ProFeatureNotice from "../ProFeatureNotice";
import { useI18n } from "../../i18n/I18nProvider";
type Props = {
sectionName: string;
text: string;
context?: string;
documentLanguage?: string | null;
onApply: (suggestion: string) => void;
};
const SECTION_ACTIONS = new Set(["improve", "professional", "shorten", "grammar", "impact", "bullets"]);
export default function AiSectionAssistant({ sectionName, text, context, onApply }: Props) {
export default function AiSectionAssistant({ sectionName, text, context, documentLanguage, onApply }: Props) {
const { t } = useI18n();
const { toast } = useToast();
const { canUseAi } = useAccountPlan();
const [open, setOpen] = useState(false);
const [action, setAction] = useState("improve");
const [suggestion, setSuggestion] = useState("");
const [busy, setBusy] = useState(false);
const normalizedLanguage = documentLanguage === "no" || documentLanguage === "nb" ? "nb-NO" : documentLanguage ?? "en";
const [targetLanguage, setTargetLanguage] = useState(normalizedLanguage);
useEffect(() => setTargetLanguage(normalizedLanguage), [normalizedLanguage]);
const run = async () => {
if (!text.trim() || !canUseAi) return;
@@ -33,11 +40,12 @@ export default function AiSectionAssistant({ sectionName, text, context, onApply
const response = await cvBuilderApi.aiAssist({
action,
text,
language: targetLanguage,
context: [`Section: ${sectionName}. Preserve facts and do not invent achievements.`, context].filter(Boolean).join("\n"),
});
setSuggestion(response.result);
} catch (error) {
toast(getApiErrorMessage(error, "AI suggestion failed."), "error");
toast(getApiErrorMessage(error, t("cvAiFailed")), "error");
} finally {
setBusy(false);
}
@@ -46,7 +54,7 @@ export default function AiSectionAssistant({ sectionName, text, context, onApply
if (!open) {
return (
<Button size="small" variant="outlined" startIcon={<AutoFixHighIcon />} disabled={!text.trim()} onClick={() => setOpen(true)}>
AI suggestions
{t("cvAiSuggestions")}
</Button>
);
}
@@ -54,10 +62,10 @@ export default function AiSectionAssistant({ sectionName, text, context, onApply
if (!canUseAi) {
return (
<Stack spacing={1}>
<ProFeatureNotice featureKey="cv-writing-ai" title="Refine this section with Pro.">
AI only proposes wording; it never changes CV content without your approval.
<ProFeatureNotice featureKey="cv-writing-ai" title={t("cvAiProTitle")}>
{t("cvAiProBody")}
</ProFeatureNotice>
<Button size="small" onClick={() => setOpen(false)}>Close</Button>
<Button size="small" onClick={() => setOpen(false)}>{t("cvAiClose")}</Button>
</Stack>
);
}
@@ -66,25 +74,39 @@ export default function AiSectionAssistant({ sectionName, text, context, onApply
<Paper variant="outlined" sx={{ p: 1.25, borderColor: "primary.main", bgcolor: "action.hover" }}>
<Stack spacing={1}>
<Stack direction={{ xs: "column", sm: "row" }} spacing={1} alignItems={{ sm: "center" }}>
<TextField select size="small" label="AI action" value={action} onChange={(event) => setAction(event.target.value)} sx={{ minWidth: 190 }}>
{AI_ACTIONS.filter((item) => SECTION_ACTIONS.has(item.key)).map((item) => <MenuItem key={item.key} value={item.key}>{item.label}</MenuItem>)}
<TextField select size="small" label={t("cvAiAction")} value={action} onChange={(event) => setAction(event.target.value)} sx={{ minWidth: 190 }}>
{AI_ACTIONS.filter((item) => SECTION_ACTIONS.has(item.key)).map((item) => <MenuItem key={item.key} value={item.key}>{actionLabel(item.key, item.label, t)}</MenuItem>)}
</TextField>
<Button size="small" variant="contained" disabled={busy || !text.trim()} onClick={() => void run()}>{busy ? "Working…" : "Suggest"}</Button>
<Button size="small" onClick={() => { setOpen(false); setSuggestion(""); }}>Close</Button>
<TextField select size="small" label={t("cvAiLanguage")} value={targetLanguage} onChange={(event) => setTargetLanguage(event.target.value)} sx={{ minWidth: 150 }}>
<MenuItem value="en">English</MenuItem>
<MenuItem value="nb-NO">Norsk bokmål</MenuItem>
</TextField>
<Button size="small" variant="contained" disabled={busy || !text.trim()} onClick={() => void run()}>{busy ? t("cvAiWorking") : t("cvAiSuggest")}</Button>
<Button size="small" onClick={() => { setOpen(false); setSuggestion(""); }}>{t("cvAiClose")}</Button>
</Stack>
{suggestion && <>
<Box>
<Typography variant="overline" color="text.secondary">Current</Typography>
<Typography variant="overline" color="text.secondary">{t("cvAiCurrent")}</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>{text}</Typography>
</Box>
<TextField label="Suggested — edit before applying" multiline minRows={3} fullWidth size="small" value={suggestion} onChange={(event) => setSuggestion(event.target.value)} />
<Alert severity="info" sx={{ py: 0 }}>Check every fact before applying this suggestion.</Alert>
<TextField label={t("cvAiSuggested")} multiline minRows={3} fullWidth size="small" value={suggestion} onChange={(event) => setSuggestion(event.target.value)} />
<Alert severity="info" sx={{ py: 0 }}>{t("cvAiCheckFacts")}</Alert>
<Stack direction="row" spacing={1}>
<Button size="small" variant="contained" onClick={() => { onApply(suggestion); setSuggestion(""); setOpen(false); toast(`Suggestion applied to ${sectionName}.`, "success"); }}>Apply</Button>
<Button size="small" onClick={() => setSuggestion("")}>Reject</Button>
<Button size="small" variant="contained" onClick={() => { onApply(suggestion); setSuggestion(""); setOpen(false); toast(t("cvAiApplied", { section: sectionName }), "success"); }}>{t("cvAiApply")}</Button>
<Button size="small" onClick={() => setSuggestion("")}>{t("cvAiReject")}</Button>
</Stack>
</>}
</Stack>
</Paper>
);
}
type Translate = ReturnType<typeof useI18n>["t"];
function actionLabel(key: string, fallback: string, t: Translate) {
const labels: Record<string, string> = {
improve: t("cvAiImprove"), professional: t("cvAiProfessional"), shorten: t("cvAiShorten"),
grammar: t("cvAiGrammar"), impact: t("cvAiImpact"), bullets: t("cvAiBullets"),
};
return labels[key] ?? fallback;
}