Translate correspondence flow and localize AI system details

This commit is contained in:
cesnimda
2026-03-23 20:20:06 +01:00
parent 653f713a78
commit b3cbaee16c
3 changed files with 213 additions and 73 deletions
@@ -31,6 +31,7 @@ import { api, getApiErrorMessage } from "../api";
import { useToast } from "../toast";
import { CorrespondenceMessage, GmailMessageSummary, GmailStatus } from "../types";
import { useDialogActions } from "../dialogs";
import { useI18n } from "../i18n/I18nProvider";
function parseRawEmail(raw: string): { subject?: string; date?: string; from?: string; to?: string; body: string } {
const lines = raw.replace(/\r\n/g, "\n").split("\n");
@@ -80,6 +81,7 @@ function scoreMessage(message: GmailMessageSummary, query: string, messages: Cor
export default function Correspondence({ jobId }: { jobId: number }) {
const theme = useTheme();
const { toast } = useToast();
const { t } = useI18n();
const { confirmAction } = useDialogActions();
const [messages, setMessages] = useState<CorrespondenceMessage[]>([]);
const [from, setFrom] = useState<"Me" | "Company">("Me");
@@ -157,18 +159,18 @@ export default function Correspondence({ jobId }: { jobId: number }) {
const data = event.data as { source?: string; status?: string; message?: string };
if (data?.source !== "jobtracker-gmail-oauth") return;
if (data.status === "connected") {
toast(data.message || "Gmail connected.", "success");
toast(data.message || t("googleLinkedSuccess"), "success");
void loadGmailStatus();
setImportTab(1);
void loadGmailMessages();
} else {
toast(data.message || "Gmail connection failed.", "error");
toast(data.message || t("googleAuthFailed"), "error");
}
};
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, [loadGmailMessages, loadGmailStatus, toast]);
}, [loadGmailMessages, loadGmailStatus, t, toast]);
const canSend = useMemo(() => text.trim().length > 0, [text]);
@@ -213,7 +215,7 @@ export default function Correspondence({ jobId }: { jobId: number }) {
const importEmail = async () => {
const parsed = parseRawEmail(rawEmail);
if (!parsed.body && !parsed.subject && !rawEmail.trim()) {
toast("Paste an email first.", "error");
toast(t("addJobModalPasteUrlFirst"), "error");
return;
}
@@ -229,7 +231,7 @@ export default function Correspondence({ jobId }: { jobId: number }) {
setImportOpen(false);
setRawEmail("");
await load();
toast("Email logged.", "success");
toast(t("correspondenceLogEmail"), "success");
} catch (error) {
toast(getApiErrorMessage(error, "Failed to import email."), "error");
}
@@ -250,14 +252,14 @@ export default function Correspondence({ jobId }: { jobId: number }) {
await api.delete("/gmail/connection");
setGmailStatus({ connected: false });
setGmailMessages([]);
toast("Gmail disconnected.", "success");
toast(t("googleUnlinked"), "success");
} catch (error) {
toast(getApiErrorMessage(error, "Failed to disconnect Gmail."), "error");
}
};
const deleteMessage = async (messageId: number) => {
if (!(await confirmAction("Remove this correspondence message?", { title: "Delete message", confirmLabel: "Delete", destructive: true }))) return;
if (!(await confirmAction("Remove this correspondence message?", { title: "Delete message", confirmLabel: t("jobTableDeleteSelected"), destructive: true }))) return;
try {
await api.delete(`/correspondence/${messageId}`);
await load();
@@ -272,7 +274,7 @@ export default function Correspondence({ jobId }: { jobId: number }) {
setImportingMessageId(messageId);
await api.post("/gmail/import", { jobApplicationId: jobId, messageId });
await load();
toast("Email imported from Gmail.", "success");
toast(t("correspondenceImportEmail"), "success");
} catch (error: any) {
toast(getApiErrorMessage(error, "Failed to import Gmail message."), "error");
} finally {
@@ -310,7 +312,7 @@ export default function Correspondence({ jobId }: { jobId: number }) {
<Typography sx={{ whiteSpace: "pre-wrap", lineHeight: 1.35 }}>{m.content}</Typography>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "flex-end", mt: 0.75 }}>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{isMe ? "Me" : "Company"}{m.channel ? ` - ${m.channel}` : ""}{m.date ? ` - ${new Date(m.date).toLocaleString()}` : ""}
{isMe ? t("correspondenceMe") : t("correspondenceCompany")}{m.channel ? ` - ${m.channel}` : ""}{m.date ? ` - ${new Date(m.date).toLocaleString()}` : ""}
</Typography>
<IconButton size="small" onClick={() => void deleteMessage(m.id)} sx={{ color: "text.secondary" }}>
<DeleteOutlineIcon fontSize="small" />
@@ -326,47 +328,47 @@ export default function Correspondence({ jobId }: { jobId: number }) {
<Box sx={{ display: "flex", gap: 1, alignItems: "flex-start", mt: 1.5, flexWrap: "wrap" }}>
<ToggleButtonGroup exclusive value={from} onChange={(_, v) => v && setFrom(v)} size="small">
<ToggleButton value="Me">Me</ToggleButton>
<ToggleButton value="Company">Company</ToggleButton>
<ToggleButton value="Me">{t("correspondenceMe")}</ToggleButton>
<ToggleButton value="Company">{t("correspondenceCompany")}</ToggleButton>
</ToggleButtonGroup>
<Button variant="outlined" size="small" onClick={() => setImportOpen(true)}>Import Email</Button>
<Button variant="outlined" size="small" onClick={() => setImportOpen(true)}>{t("correspondenceImportEmail")}</Button>
<TextField label="Log note or message" value={text} onChange={(e) => setText(e.target.value)} multiline minRows={3} sx={{ flex: "1 1 320px" }} helperText={`${text.length} characters`} />
<TextField label={t("correspondenceLogNoteOrMessage")} value={text} onChange={(e) => setText(e.target.value)} multiline minRows={3} sx={{ flex: "1 1 320px" }} helperText={t("correspondenceCharacters", { count: text.length })} />
<Button variant="contained" onClick={send} disabled={!canSend}>Add</Button>
<Button variant="contained" onClick={send} disabled={!canSend}>{t("correspondenceAdd")}</Button>
</Box>
<Dialog open={importOpen} onClose={() => setImportOpen(false)} fullWidth maxWidth="md">
<DialogTitle>Import Email</DialogTitle>
<DialogTitle>{t("correspondenceImportTitle")}</DialogTitle>
<DialogContent>
<Tabs value={importTab} onChange={(_, v) => setImportTab(v)} sx={{ mb: 2 }}>
<Tab label="Paste email" />
<Tab label="Gmail" />
<Tab label={t("correspondencePasteEmail")} />
<Tab label={t("google")} />
</Tabs>
{importTab === 0 ? (
<>
<Typography sx={{ color: "text.secondary", mb: 1 }}>Paste raw email text (headers optional). We parse Subject and Date when present.</Typography>
<Typography sx={{ color: "text.secondary", mb: 1 }}>{t("correspondencePasteEmailHelp")}</Typography>
<TextField multiline minRows={10} fullWidth value={rawEmail} onChange={(e) => setRawEmail(e.target.value)} placeholder={"Subject: ...\nDate: ...\nFrom: ...\nTo: ...\n\nBody..."} />
</>
) : (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
<Box>
<Typography sx={{ fontWeight: 800 }}>Google Gmail</Typography>
<Typography sx={{ fontWeight: 800 }}>{t("correspondenceGoogleGmail")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{gmailLoading ? "Checking connection..." : gmailStatus?.connected ? `Connected as ${gmailStatus.gmailAddress}` : "Connect your Gmail account to browse recent emails."}
{gmailLoading ? t("correspondenceCheckingConnection") : gmailStatus?.connected ? t("correspondenceConnectedAs", { email: gmailStatus.gmailAddress || "" }) : t("correspondenceConnectGmailHint")}
</Typography>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
{gmailStatus?.connected ? (
<>
<Button variant="outlined" onClick={() => void loadGmailMessages()} disabled={gmailMessagesLoading}>Refresh</Button>
<Button variant="outlined" color="error" onClick={() => void disconnectGmail()}>Disconnect</Button>
<Button variant="outlined" onClick={() => void loadGmailMessages()} disabled={gmailMessagesLoading}>{t("correspondenceRefresh")}</Button>
<Button variant="outlined" color="error" onClick={() => void disconnectGmail()}>{t("correspondenceDisconnect")}</Button>
</>
) : (
<Button variant="contained" onClick={() => void connectGmail()}>Connect Gmail</Button>
<Button variant="contained" onClick={() => void connectGmail()}>{t("correspondenceConnectGmail")}</Button>
)}
</Box>
</Box>
@@ -379,8 +381,8 @@ export default function Correspondence({ jobId }: { jobId: number }) {
))}
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
<TextField label="Search Gmail" value={gmailQuery} onChange={(e) => setGmailQuery(e.target.value)} placeholder="from:company@example.com OR interview" size="small" fullWidth />
<Button variant="outlined" onClick={() => void loadGmailMessages()} disabled={gmailMessagesLoading}>Search</Button>
<TextField label={t("correspondenceSearchGmail")} value={gmailQuery} onChange={(e) => setGmailQuery(e.target.value)} placeholder={t("correspondenceSearchGmailPlaceholder")} size="small" fullWidth />
<Button variant="outlined" onClick={() => void loadGmailMessages()} disabled={gmailMessagesLoading}>{t("correspondenceSearch")}</Button>
</Box>
{gmailStatus.lastSyncedAt ? <Chip label={`Last synced ${new Date(gmailStatus.lastSyncedAt).toLocaleString()}`} size="small" /> : null}
<Paper variant="outlined" sx={{ maxHeight: 420, overflowY: "auto" }}>
@@ -409,7 +411,7 @@ export default function Correspondence({ jobId }: { jobId: number }) {
<ListItemButton sx={{ alignItems: "flex-start", px: 0, py: 1 }}>
<ListItemText
primary={<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}><Typography sx={{ fontWeight: 700 }}>{message.subject || "(No subject)"}</Typography><Typography variant="caption" sx={{ color: "text.secondary" }}>{message.date ? new Date(message.date).toLocaleString() : ""}</Typography></Box>}
secondary={<Box sx={{ mt: 0.5 }}><Typography variant="body2" sx={{ color: "text.primary" }}>From: {message.from || "Unknown"}</Typography><Typography variant="body2" sx={{ color: "text.secondary", mt: 0.25 }}>{message.snippet}</Typography></Box>}
secondary={<Box sx={{ mt: 0.5 }}><Typography variant="body2" sx={{ color: "text.primary" }}>From: {message.from || t("correspondenceUnknown")}</Typography><Typography variant="body2" sx={{ color: "text.secondary", mt: 0.25 }}>{message.snippet}</Typography></Box>}
/>
<Button variant="contained" size="small" disabled={importingMessageId === message.id} onClick={() => void importGmailMessage(message.id)}>
{importingMessageId === message.id ? "Importing..." : "Import"}
@@ -429,8 +431,8 @@ export default function Correspondence({ jobId }: { jobId: number }) {
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setImportOpen(false)}>Close</Button>
{importTab === 0 ? <Button variant="contained" onClick={importEmail}>Log Email</Button> : null}
<Button onClick={() => setImportOpen(false)}>{t("correspondenceClose")}</Button>
{importTab === 0 ? <Button variant="contained" onClick={importEmail}>{t("correspondenceLogEmail")}</Button> : null}
</DialogActions>
</Dialog>
</Box>