Files
hire-me/public/app.js
T
2026-09-04 20:08:12 +00:00

262 lines
8.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
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 loffre";
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 loffre</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 dabord un aperçu.`;
if (!data.offers.length) {
statusEl.hidden = false;
statusEl.textContent = "Aucune offre. Lance dabord `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 dabord un aperçu.`;
} catch {
// ignore
}
}, 8000);
} catch (error) {
statusEl.classList.add("error");
statusEl.textContent = error instanceof Error ? error.message : "Erreur de chargement";
}
}
boot();