262 lines
8.8 KiB
JavaScript
262 lines
8.8 KiB
JavaScript
const statusEl = document.querySelector("#status");
|
||
const listEl = document.querySelector("#list");
|
||
const metaEl = document.querySelector("#meta");
|
||
const dialog = document.querySelector("#preview");
|
||
const previewTitle = document.querySelector("#preview-title");
|
||
const previewFrom = document.querySelector("#preview-from");
|
||
const previewReply = document.querySelector("#preview-reply");
|
||
const previewAttach = document.querySelector("#preview-attach");
|
||
const previewTo = document.querySelector("#preview-to");
|
||
const previewSubject = document.querySelector("#preview-subject");
|
||
const previewText = document.querySelector("#preview-text");
|
||
const previewError = document.querySelector("#preview-error");
|
||
const previewSend = document.querySelector("#preview-send");
|
||
const previewRegen = document.querySelector("#preview-regen");
|
||
|
||
/** @type {{ offer: any, article: HTMLElement, button: HTMLButtonElement, input: HTMLInputElement, hint: HTMLElement | null, feedback: HTMLElement, language: string } | null} */
|
||
let active = null;
|
||
|
||
function escapeHtml(value) {
|
||
return String(value)
|
||
.replaceAll("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """);
|
||
}
|
||
|
||
function sourceLabel(source) {
|
||
if (source === "website") return "trouvé sur le site";
|
||
if (source === "gemini") return "détecté via Gemini";
|
||
if (source === "guess") return "estimé (careers/recruiting@)";
|
||
if (source === "description") return "dans l’offre";
|
||
if (source === "manual") return "saisi";
|
||
if (source === "cache") return "en cache";
|
||
return source || "recherche…";
|
||
}
|
||
|
||
function showPreviewError(message) {
|
||
if (!previewError) return;
|
||
previewError.hidden = !message;
|
||
previewError.textContent = message || "";
|
||
}
|
||
|
||
async function loadPreview(forceTo) {
|
||
if (!active) return;
|
||
const { offer, button, input, feedback } = active;
|
||
showPreviewError("");
|
||
button.disabled = true;
|
||
button.textContent = "Rédaction…";
|
||
feedback.hidden = false;
|
||
feedback.className = "feedback";
|
||
feedback.textContent = "Gemini rédige le mail…";
|
||
if (previewRegen) previewRegen.disabled = true;
|
||
if (previewSend) previewSend.disabled = true;
|
||
|
||
try {
|
||
const response = await fetch("/api/preview", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
id: offer.id,
|
||
to: forceTo || input.value.trim() || undefined,
|
||
}),
|
||
});
|
||
const data = await response.json();
|
||
if (!response.ok) throw new Error(data.error || "Impossible de préparer le mail");
|
||
|
||
active.language = data.language || "en";
|
||
previewTitle.textContent = `${data.company} — ${data.title}`;
|
||
previewFrom.textContent = data.from;
|
||
previewReply.textContent = data.replyTo;
|
||
previewAttach.textContent = data.attachment;
|
||
previewTo.value = data.to;
|
||
previewSubject.value = data.subject;
|
||
previewText.value = data.text;
|
||
input.value = data.to;
|
||
if (active.hint) active.hint.textContent = sourceLabel(data.emailSource);
|
||
|
||
feedback.hidden = true;
|
||
dialog?.showModal();
|
||
} catch (error) {
|
||
feedback.className = "feedback err";
|
||
feedback.textContent = error instanceof Error ? error.message : "Erreur";
|
||
} finally {
|
||
button.disabled = false;
|
||
button.textContent = "Postuler";
|
||
if (previewRegen) previewRegen.disabled = false;
|
||
if (previewSend) previewSend.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function sendPreview() {
|
||
if (!active) return;
|
||
const { offer, article, button, input, hint, feedback } = active;
|
||
const to = previewTo.value.trim();
|
||
const subject = previewSubject.value.trim();
|
||
const text = previewText.value.trim();
|
||
showPreviewError("");
|
||
|
||
if (!to || !subject || !text) {
|
||
showPreviewError("Destinataire, objet et corps sont requis.");
|
||
return;
|
||
}
|
||
|
||
previewSend.disabled = true;
|
||
previewRegen.disabled = true;
|
||
previewSend.textContent = "Envoi…";
|
||
|
||
try {
|
||
const response = await fetch("/api/apply", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
id: offer.id,
|
||
to,
|
||
subject,
|
||
text,
|
||
language: active.language,
|
||
}),
|
||
});
|
||
const data = await response.json();
|
||
if (!response.ok) throw new Error(data.error || "Échec de candidature");
|
||
|
||
article.classList.add("applied");
|
||
button.classList.add("done");
|
||
button.textContent = "Envoyé";
|
||
button.disabled = true;
|
||
input.value = data.to;
|
||
input.disabled = true;
|
||
if (hint) hint.textContent = sourceLabel(data.emailSource);
|
||
feedback.hidden = false;
|
||
feedback.className = "feedback ok";
|
||
feedback.textContent = `Envoyé à ${data.to} — ${data.subject}`;
|
||
dialog?.close();
|
||
} catch (error) {
|
||
showPreviewError(error instanceof Error ? error.message : "Erreur");
|
||
} finally {
|
||
previewSend.disabled = false;
|
||
previewRegen.disabled = false;
|
||
previewSend.textContent = "Envoyer";
|
||
}
|
||
}
|
||
|
||
function renderOffer(offer) {
|
||
const applied = Boolean(offer.appliedAt);
|
||
const email = offer.emails[0] ?? "";
|
||
const article = document.createElement("article");
|
||
article.className = `offer${applied ? " applied" : ""}`;
|
||
article.dataset.id = offer.id;
|
||
article.innerHTML = `
|
||
<div class="offer-head">
|
||
<div>
|
||
<p class="company">${escapeHtml(offer.company)}</p>
|
||
<h2 class="title">${escapeHtml(offer.title)}</h2>
|
||
</div>
|
||
<span class="score">score ${escapeHtml(offer.score)}</span>
|
||
</div>
|
||
<p class="meta-row">
|
||
<span>${escapeHtml(offer.location)}</span>
|
||
<span>·</span>
|
||
<span>${escapeHtml(offer.source)}</span>
|
||
<span>·</span>
|
||
<a href="${escapeHtml(offer.url)}" target="_blank" rel="noreferrer">voir l’offre</a>
|
||
</p>
|
||
<ul class="reasons">
|
||
${offer.reasons.map((reason) => `<li>${escapeHtml(reason)}</li>`).join("")}
|
||
</ul>
|
||
<p class="excerpt">${escapeHtml(offer.excerpt)}</p>
|
||
<div class="actions">
|
||
<label>
|
||
Email destinataire
|
||
<input
|
||
type="email"
|
||
name="to"
|
||
value="${escapeHtml(email)}"
|
||
placeholder="${email ? "" : "Recherche auto en cours…"}"
|
||
${applied ? "disabled" : ""}
|
||
/>
|
||
<span class="email-hint">${
|
||
email
|
||
? escapeHtml(sourceLabel(offer.emailSource))
|
||
: "sera récupéré automatiquement au clic si besoin"
|
||
}</span>
|
||
</label>
|
||
<button type="button" ${applied ? "disabled" : ""}>
|
||
${applied ? "Déjà postulé" : "Postuler"}
|
||
</button>
|
||
</div>
|
||
<p class="feedback" hidden></p>
|
||
`;
|
||
|
||
const button = article.querySelector("button");
|
||
const input = article.querySelector('input[name="to"]');
|
||
const hint = article.querySelector(".email-hint");
|
||
const feedback = article.querySelector(".feedback");
|
||
|
||
button?.addEventListener("click", async () => {
|
||
if (!button || !input || !feedback) return;
|
||
active = { offer, article, button, input, hint, feedback, language: "en" };
|
||
await loadPreview(input.value.trim());
|
||
});
|
||
|
||
return article;
|
||
}
|
||
|
||
previewSend?.addEventListener("click", () => {
|
||
void sendPreview();
|
||
});
|
||
|
||
previewRegen?.addEventListener("click", () => {
|
||
void loadPreview(previewTo?.value.trim());
|
||
});
|
||
|
||
async function boot() {
|
||
try {
|
||
const response = await fetch("/api/offers");
|
||
const data = await response.json();
|
||
if (!response.ok) throw new Error(data.error || "Impossible de charger les offres");
|
||
|
||
statusEl.hidden = true;
|
||
listEl.hidden = false;
|
||
metaEl.hidden = false;
|
||
const withEmail = data.offers.filter((o) => o.emails?.length).length;
|
||
metaEl.textContent = `${data.count} offre(s) — ${withEmail} email(s) trouvés. Postuler ouvre d’abord un aperçu.`;
|
||
|
||
if (!data.offers.length) {
|
||
statusEl.hidden = false;
|
||
statusEl.textContent = "Aucune offre. Lance d’abord `npm run find`.";
|
||
return;
|
||
}
|
||
|
||
for (const offer of data.offers) {
|
||
listEl.append(renderOffer(offer));
|
||
}
|
||
|
||
setTimeout(async () => {
|
||
try {
|
||
const again = await fetch("/api/offers");
|
||
const next = await again.json();
|
||
if (!again.ok) return;
|
||
for (const offer of next.offers) {
|
||
const article = listEl.querySelector(`[data-id="${offer.id}"]`);
|
||
const input = article?.querySelector('input[name="to"]');
|
||
const hint = article?.querySelector(".email-hint");
|
||
if (input && !input.disabled && offer.emails?.[0] && !input.value) {
|
||
input.value = offer.emails[0];
|
||
if (hint) hint.textContent = sourceLabel(offer.emailSource);
|
||
}
|
||
}
|
||
const filled = next.offers.filter((o) => o.emails?.length).length;
|
||
metaEl.textContent = `${next.count} offre(s) — ${filled} email(s) trouvés. Postuler ouvre d’abord un aperçu.`;
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}, 8000);
|
||
} catch (error) {
|
||
statusEl.classList.add("error");
|
||
statusEl.textContent = error instanceof Error ? error.message : "Erreur de chargement";
|
||
}
|
||
}
|
||
|
||
boot();
|