Files
AR-VINchecker/static/js/app.js
T
alro65 dc3ec90109 feat: AR-VINchecker v1.0 — VIN report generator with AI analysis
FastAPI server querying NHTSA/EPA APIs, generating PDF reports
with risk scoring, and local Ollama AI analysis via DealAnalyzer.
Security hardened: XSS fix, SSRF protection, CORS restricted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 11:40:35 -04:00

260 lines
8.2 KiB
JavaScript

"use strict";
const form = document.getElementById("fetchForm");
const fetchBtn = document.getElementById("fetchBtn");
const cardsContainer = document.getElementById("cardsContainer");
// Remove empty state on first card
function clearEmpty() {
const empty = cardsContainer.querySelector(".empty-state");
if (empty) empty.remove();
}
// ── Create a new VIN card ──────────────────────────────────────────────────────
function createCard(vin) {
clearEmpty();
const safeVin = vin.replace(/[^A-Z0-9]/g, "");
const card = document.createElement("div");
card.className = "vin-card";
card.id = `card-${safeVin}`;
// Build DOM without innerHTML interpolation to prevent XSS
const header = document.createElement("div");
header.className = "card-header";
const headerLeft = document.createElement("div");
const vinEl = document.createElement("div");
vinEl.className = "card-vin";
vinEl.textContent = safeVin;
const vehicleEl = document.createElement("div");
vehicleEl.className = "card-vehicle";
vehicleEl.id = `vehicle-${safeVin}`;
vehicleEl.textContent = "Consultando...";
headerLeft.appendChild(vinEl);
headerLeft.appendChild(vehicleEl);
const dismissBtn = document.createElement("button");
dismissBtn.className = "card-dismiss";
dismissBtn.title = "Cerrar";
dismissBtn.textContent = "✕";
dismissBtn.addEventListener("click", () => card.remove());
header.appendChild(headerLeft);
header.appendChild(dismissBtn);
const body = document.createElement("div");
body.className = "card-body";
const logEl = document.createElement("div");
logEl.className = "progress-log";
logEl.id = `log-${safeVin}`;
const actionsEl = document.createElement("div");
actionsEl.id = `actions-${safeVin}`;
body.appendChild(logEl);
body.appendChild(actionsEl);
card.appendChild(header);
card.appendChild(body);
cardsContainer.prepend(card);
return card;
}
// ── Add a log line to a card ───────────────────────────────────────────────────
function addLog(vin, text, status) {
const log = document.getElementById(`log-${vin}`);
if (!log) return;
// Remove spinner from any previous progress item
if (status !== "progress") {
const prev = log.querySelector(".log-item.progress");
if (prev) prev.classList.replace("progress", "done");
}
const item = document.createElement("div");
item.className = `log-item ${status}`;
if (status === "progress") {
item.innerHTML = `<span class="spinner"></span><span>${text}</span>`;
} else {
item.textContent = text;
}
log.appendChild(item);
item.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
// ── Show card actions after fetch completes ───────────────────────────────────
function showActions(vin, risk) {
const actionsDiv = document.getElementById(`actions-${vin}`);
if (!actionsDiv) return;
// Risk badge
const badge = document.createElement("div");
badge.className = `risk-badge ${risk.color}`;
badge.textContent = `${risk.emoji} ${risk.score}/100 — ${risk.level}`;
actionsDiv.appendChild(badge);
// Buttons row
const row = document.createElement("div");
row.className = "card-actions";
const genBtn = document.createElement("button");
genBtn.className = "btn btn-gen";
genBtn.textContent = "📄 Generar PDF";
genBtn.addEventListener("click", () => generatePDF(vin, genBtn));
row.appendChild(genBtn);
actionsDiv.appendChild(row);
}
// ── Generate PDF ──────────────────────────────────────────────────────────────
async function generatePDF(vin, btn) {
btn.disabled = true;
btn.textContent = "⏳ Generando...";
try {
const res = await fetch("/api/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ vin }),
});
const data = await res.json();
if (data.error) {
btn.disabled = false;
btn.textContent = "📄 Generar PDF";
addLog(vin, `❌ ${data.error}`, "error");
return;
}
btn.textContent = "✅ PDF listo";
const actionsDiv = document.getElementById(`actions-${vin}`);
// Download link
const link = document.createElement("a");
link.href = data.url;
link.download = data.filename;
link.className = "pdf-link";
link.textContent = `⬇️ ${data.filename}`;
actionsDiv.appendChild(link);
// Open locally button (Windows only via server)
const openBtn = document.createElement("button");
openBtn.className = "btn btn-open";
openBtn.textContent = "🗂️ Abrir localmente";
openBtn.addEventListener("click", () => openLocally(data.filename, openBtn));
actionsDiv.querySelector(".card-actions").appendChild(openBtn);
addLog(vin, `✅ PDF generado: ${data.filename}`, "success");
} catch (e) {
btn.disabled = false;
btn.textContent = "📄 Generar PDF";
addLog(vin, `❌ Error al generar PDF: ${e.message}`, "error");
}
}
// ── Open file locally via server ──────────────────────────────────────────────
async function openLocally(filename, btn) {
btn.disabled = true;
try {
const res = await fetch("/api/open", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename }),
});
const data = await res.json();
if (data.error) {
alert(data.error);
}
} catch (e) {
alert("Error al abrir: " + e.message);
} finally {
btn.disabled = false;
}
}
// ── Form submission → SSE ──────────────────────────────────────────────────────
form.addEventListener("submit", async (e) => {
e.preventDefault();
const fd = new FormData(form);
const vin = (fd.get("vin") || "").trim().toUpperCase();
if (!vin) return;
// Prevent duplicate active cards
const existing = document.getElementById(`card-${vin}`);
if (existing) {
existing.scrollIntoView({ behavior: "smooth" });
existing.style.outline = "2px solid var(--accent)";
setTimeout(() => (existing.style.outline = ""), 1500);
return;
}
fetchBtn.disabled = true;
fetchBtn.textContent = "⏳ Consultando...";
createCard(vin);
const params = new URLSearchParams({
vin,
odometer: fd.get("odometer") || "",
primary_damage: fd.get("primary_damage") || "",
secondary_damage: fd.get("secondary_damage") || "",
title: fd.get("title") || "",
bid: fd.get("bid") || "",
auction: fd.get("auction") || "",
photo_url: fd.get("photo_url") || "",
});
const es = new EventSource(`/api/fetch?${params.toString()}`);
es.onmessage = (event) => {
let msg;
try { msg = JSON.parse(event.data); } catch { return; }
const status = msg.status || "progress";
if (status === "done") {
const vehicleEl = document.getElementById(`vehicle-${vin}`);
if (vehicleEl && msg.vehicle) vehicleEl.textContent = msg.vehicle;
showActions(vin, msg.risk);
es.close();
fetchBtn.disabled = false;
fetchBtn.textContent = "⚡ FETCH DATA";
return;
}
if (status === "error") {
addLog(vin, msg.step, "error");
es.close();
fetchBtn.disabled = false;
fetchBtn.textContent = "⚡ FETCH DATA";
return;
}
if (status === "success" && msg.vehicle) {
const vehicleEl = document.getElementById(`vehicle-${vin}`);
if (vehicleEl) vehicleEl.textContent = msg.vehicle;
}
addLog(vin, msg.step, status);
};
es.onerror = () => {
addLog(vin, "❌ Conexión interrumpida con el servidor", "error");
es.close();
fetchBtn.disabled = false;
fetchBtn.textContent = "⚡ FETCH DATA";
};
});