2026-05-24 19:14:41 +02:00
|
|
|
|
<!DOCTYPE html>
|
|
|
|
|
|
<html lang="da">
|
|
|
|
|
|
<head>
|
|
|
|
|
|
<meta charset="UTF-8">
|
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
|
|
|
<title>BlaaAi — Find den bedste annonce</title>
|
|
|
|
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
|
|
|
|
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
|
|
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
|
|
|
|
<script>
|
|
|
|
|
|
tailwind.config = {
|
|
|
|
|
|
theme: {
|
|
|
|
|
|
extend: {
|
|
|
|
|
|
fontFamily: { sans: ['Space Grotesk', 'sans-serif'] },
|
|
|
|
|
|
colors: {
|
|
|
|
|
|
ink: { DEFAULT: '#09090b', 50: '#f4f4f5', 100: '#e4e4e7', 200: '#27272a', 300: '#3f3f46' }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
</script>
|
|
|
|
|
|
<style>
|
|
|
|
|
|
body { background: #fafaf9; color: #0c0a09; font-family: 'Space Grotesk', sans-serif; }
|
|
|
|
|
|
.card-enter { animation: fadeUp .25s ease both; }
|
|
|
|
|
|
@keyframes fadeUp { from { opacity:0; transform:translateY(10px); } to { opacity:1; transform:translateY(0); } }
|
|
|
|
|
|
.spinner { animation: spin .9s linear infinite; }
|
|
|
|
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
|
|
|
|
.score-fill { background: #0c0a09; transition: width .6s cubic-bezier(.4,0,.2,1); }
|
|
|
|
|
|
@keyframes shimmer { 0%{background-position:-400% 0} 100%{background-position:400% 0} }
|
|
|
|
|
|
.skeleton { background:linear-gradient(90deg,#f0efed 25%,#e8e5e1 50%,#f0efed 75%);background-size:400% 100%;animation:shimmer 1.6s ease-in-out infinite;border-radius:4px; }
|
|
|
|
|
|
input, textarea {
|
|
|
|
|
|
background: #fff;
|
|
|
|
|
|
border: 1px solid #e7e5e4;
|
|
|
|
|
|
color: #0c0a09;
|
|
|
|
|
|
outline: none;
|
|
|
|
|
|
transition: border-color .15s;
|
|
|
|
|
|
}
|
|
|
|
|
|
input:focus, textarea:focus { border-color: #a8a29e; }
|
|
|
|
|
|
input::placeholder, textarea::placeholder { color: #a8a29e; }
|
|
|
|
|
|
.btn-primary {
|
|
|
|
|
|
background: #0c0a09; color: #fafaf9;
|
|
|
|
|
|
font-weight: 600; letter-spacing: -.01em;
|
|
|
|
|
|
transition: background .15s, transform .1s;
|
|
|
|
|
|
}
|
|
|
|
|
|
.btn-primary:hover:not(:disabled) { background: #292524; }
|
|
|
|
|
|
.btn-primary:active:not(:disabled) { transform: scale(.98); }
|
|
|
|
|
|
.btn-primary:disabled { opacity: .4; cursor: default; }
|
|
|
|
|
|
.card {
|
|
|
|
|
|
background: #fff;
|
|
|
|
|
|
border: 1px solid #e7e5e4;
|
|
|
|
|
|
transition: border-color .15s, box-shadow .15s;
|
|
|
|
|
|
}
|
|
|
|
|
|
.card:hover { border-color: #d6d3d1; box-shadow: 0 1px 8px rgba(0,0,0,.05); }
|
|
|
|
|
|
/* Video modal */
|
|
|
|
|
|
#video-modal { display:none; position:fixed; inset:0; z-index:50; align-items:center; justify-content:center; }
|
|
|
|
|
|
#video-modal.open { display:flex; }
|
|
|
|
|
|
#video-backdrop { position:absolute; inset:0; background:rgba(0,0,0,.7); backdrop-filter:blur(4px); }
|
|
|
|
|
|
#video-box {
|
|
|
|
|
|
position:relative; z-index:1; width:min(860px,92vw);
|
|
|
|
|
|
background:#0f172a; border-radius:16px; overflow:hidden;
|
|
|
|
|
|
box-shadow:0 24px 80px rgba(0,0,0,.6);
|
|
|
|
|
|
animation: modalIn .2s ease both;
|
|
|
|
|
|
}
|
|
|
|
|
|
@keyframes modalIn { from{opacity:0;transform:scale(.96)} to{opacity:1;transform:scale(1)} }
|
|
|
|
|
|
#video-box video { display:block; width:100%; }
|
|
|
|
|
|
#video-close {
|
|
|
|
|
|
position:absolute; top:12px; right:12px; z-index:2;
|
|
|
|
|
|
background:rgba(255,255,255,.1); border:none; color:#fff;
|
|
|
|
|
|
width:32px; height:32px; border-radius:50%; cursor:pointer;
|
|
|
|
|
|
font-size:1rem; display:flex; align-items:center; justify-content:center;
|
|
|
|
|
|
transition:background .15s;
|
|
|
|
|
|
}
|
|
|
|
|
|
#video-close:hover { background:rgba(255,255,255,.25); }
|
|
|
|
|
|
.btn-help {
|
|
|
|
|
|
display:inline-flex; align-items:center; gap:6px;
|
|
|
|
|
|
font-size:.75rem; font-weight:500; color:#78716c;
|
|
|
|
|
|
border:1px solid #e7e5e4; border-radius:8px;
|
|
|
|
|
|
padding:6px 12px; cursor:pointer; background:transparent;
|
|
|
|
|
|
transition:color .15s, border-color .15s;
|
|
|
|
|
|
}
|
|
|
|
|
|
.btn-help:hover { color:#0c0a09; border-color:#a8a29e; }
|
|
|
|
|
|
.tag-top { background:#f0fdf4; color:#15803d; border:1px solid #bbf7d0; }
|
|
|
|
|
|
.tag-good { background:#eff6ff; color:#1d4ed8; border:1px solid #bfdbfe; }
|
|
|
|
|
|
.tag-mid { background:#fefce8; color:#a16207; border:1px solid #fde68a; }
|
|
|
|
|
|
a.annonce-link {
|
|
|
|
|
|
color: #a8a29e; font-size:.75rem; letter-spacing:.02em;
|
|
|
|
|
|
text-decoration: none; transition: color .15s;
|
|
|
|
|
|
}
|
|
|
|
|
|
a.annonce-link:hover { color: #0c0a09; }
|
|
|
|
|
|
header { border-bottom: 1px solid #e7e5e4; background: #fafaf9; }
|
|
|
|
|
|
</style>
|
|
|
|
|
|
</head>
|
|
|
|
|
|
<body style="
|
|
|
|
|
|
background-color: #fafaf9;
|
|
|
|
|
|
background-image: url('/static/background.png');
|
|
|
|
|
|
background-repeat: no-repeat;
|
|
|
|
|
|
background-position: bottom center;
|
|
|
|
|
|
background-size: clamp(600px, 80vw, 1100px) auto;
|
|
|
|
|
|
background-attachment: fixed;
|
|
|
|
|
|
">
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Top bar (always visible) -->
|
|
|
|
|
|
<header>
|
|
|
|
|
|
<div class="max-w-2xl mx-auto px-6 py-4 flex items-center justify-between">
|
|
|
|
|
|
<button onclick="resetForm()" class="flex items-center gap-2 hover:opacity-60 transition-opacity">
|
|
|
|
|
|
<span style="font-size:1.1rem;line-height:1">◈</span>
|
|
|
|
|
|
<span class="font-semibold tracking-tight">BlaaAi</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<div class="flex items-center gap-3">
|
|
|
|
|
|
<span id="header-status" class="text-xs" style="color:#a8a29e"></span>
|
|
|
|
|
|
<button class="btn-help" onclick="openVideoModal()">
|
|
|
|
|
|
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
|
|
|
|
<circle cx="12" cy="12" r="10"/><polygon points="10 8 16 12 10 16 10 8" fill="currentColor" stroke="none"/>
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
Video hjælp
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- ── VIDEO MODAL ── -->
|
|
|
|
|
|
<div id="video-modal" role="dialog" aria-modal="true" aria-label="Video hjælp">
|
|
|
|
|
|
<div id="video-backdrop" onclick="closeVideoModal()"></div>
|
|
|
|
|
|
<div id="video-box">
|
|
|
|
|
|
<button id="video-close" onclick="closeVideoModal()" aria-label="Luk video">✕</button>
|
|
|
|
|
|
<video id="help-video" controls preload="metadata"
|
|
|
|
|
|
poster=""
|
|
|
|
|
|
src="/static/blaaai_tutorial.mp4">
|
|
|
|
|
|
</video>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<main class="max-w-2xl mx-auto px-6" style="min-height:100vh;display:flex;flex-direction:column;justify-content:center;padding-top:2rem;padding-bottom:22rem">
|
|
|
|
|
|
|
|
|
|
|
|
<!-- ── FORM ── -->
|
|
|
|
|
|
<section id="form-section" class="py-12 relative">
|
|
|
|
|
|
<p class="text-xs font-medium tracking-widest uppercase mb-5" style="color:#a8a29e">AI-powered annonce analyse</p>
|
|
|
|
|
|
<h1 class="text-4xl font-bold tracking-tight leading-tight mb-10" style="letter-spacing:-.03em">
|
|
|
|
|
|
Find den bedste<br>DBA-annonce.
|
|
|
|
|
|
</h1>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="space-y-3">
|
|
|
|
|
|
<input
|
|
|
|
|
|
id="url-input"
|
|
|
|
|
|
type="url"
|
|
|
|
|
|
placeholder="https://www.dba.dk/mobility/search/…"
|
|
|
|
|
|
class="w-full px-4 py-3 rounded-lg text-sm"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<div id="prefs-section" class="hidden">
|
|
|
|
|
|
<textarea
|
|
|
|
|
|
id="prefs-input"
|
|
|
|
|
|
rows="2"
|
|
|
|
|
|
placeholder="Dine præferencer — fx 'ingen franske biler, helst automatgear'"
|
|
|
|
|
|
class="w-full px-4 py-3 rounded-lg text-sm resize-none"
|
|
|
|
|
|
></textarea>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="flex items-center gap-3 pt-1">
|
|
|
|
|
|
<button
|
|
|
|
|
|
id="submit-btn"
|
|
|
|
|
|
onclick="submitSearch()"
|
|
|
|
|
|
class="btn-primary px-6 py-3 rounded-lg text-sm flex items-center gap-2"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span>Analyser</span>
|
|
|
|
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3"/>
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button onclick="togglePrefs()" id="prefs-btn"
|
|
|
|
|
|
class="text-sm px-4 py-3 rounded-lg transition-colors"
|
|
|
|
|
|
style="color:#78716c;border:1px solid #e7e5e4">
|
|
|
|
|
|
+ Præferencer
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<p class="text-xs mt-8 leading-relaxed" style="color:#a8a29e">
|
|
|
|
|
|
Paste en DBA søge-URL — AI'en gennemgår alle annoncer og rangerer dem efter pris, stand og kvalitet.<br>
|
|
|
|
|
|
<span style="color:#d6d3d1">AI kan tage fejl. Brug det som inspiration, ikke som facit.</span>
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- ── STATUS ── -->
|
|
|
|
|
|
<section id="status-section" class="hidden pt-8 pb-20">
|
|
|
|
|
|
<div class="flex items-center gap-3 mb-1">
|
|
|
|
|
|
<svg class="spinner w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" style="color:#a8a29e">
|
|
|
|
|
|
<circle class="opacity-20" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3"/>
|
|
|
|
|
|
<path class="opacity-80" fill="currentColor" d="M4 12a8 8 0 018-8v8z"/>
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
<p id="status-text" class="font-medium text-sm">Henter annoncer…</p>
|
|
|
|
|
|
<span id="status-progress" class="text-xs ml-auto" style="color:#a8a29e"></span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p id="status-sub" class="text-xs mb-6 ml-7" style="color:#a8a29e;min-height:1.2em"></p>
|
|
|
|
|
|
<div id="skeleton-container" class="space-y-2"></div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- ── RESULTS ── -->
|
|
|
|
|
|
<section id="results-section" class="hidden pt-8 pb-20">
|
|
|
|
|
|
|
|
|
|
|
|
<div class="flex items-baseline justify-between mb-8">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h2 class="text-xl font-bold tracking-tight">Resultater</h2>
|
|
|
|
|
|
<p id="result-count" class="text-xs mt-1" style="color:#a8a29e"></p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div id="listings-container" class="space-y-2"></div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Email -->
|
|
|
|
|
|
<div class="mt-12 pt-8" style="border-top:1px solid #e7e5e4">
|
|
|
|
|
|
<p class="text-sm font-medium mb-3">Send top-10 på email</p>
|
|
|
|
|
|
<div class="flex gap-2">
|
|
|
|
|
|
<input
|
|
|
|
|
|
id="email-input"
|
|
|
|
|
|
type="email"
|
|
|
|
|
|
placeholder="din@email.dk"
|
|
|
|
|
|
class="flex-1 px-4 py-2.5 rounded-lg text-sm"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onclick="sendEmail()"
|
|
|
|
|
|
id="email-btn"
|
|
|
|
|
|
class="btn-primary px-5 py-2.5 rounded-lg text-sm"
|
|
|
|
|
|
>Send</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p id="email-status" class="text-xs mt-2 hidden" style="color:#71717a"></p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
</main>
|
|
|
|
|
|
|
|
|
|
|
|
<script>
|
|
|
|
|
|
function openVideoModal() {
|
|
|
|
|
|
document.getElementById('video-modal').classList.add('open');
|
|
|
|
|
|
document.body.style.overflow = 'hidden';
|
|
|
|
|
|
const v = document.getElementById('help-video');
|
|
|
|
|
|
v.currentTime = 0;
|
|
|
|
|
|
v.play();
|
|
|
|
|
|
}
|
|
|
|
|
|
function closeVideoModal() {
|
|
|
|
|
|
document.getElementById('video-modal').classList.remove('open');
|
|
|
|
|
|
document.body.style.overflow = '';
|
|
|
|
|
|
const v = document.getElementById('help-video');
|
|
|
|
|
|
v.pause();
|
|
|
|
|
|
v.currentTime = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
document.getElementById('help-video').addEventListener('ended', closeVideoModal);
|
|
|
|
|
|
document.addEventListener('keydown', e => {
|
|
|
|
|
|
if (e.key === 'Escape') closeVideoModal();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const PRELOAD_ID = {{ search_id | tojson if search_id is defined else 'null' }};
|
|
|
|
|
|
let currentSearchId = null;
|
|
|
|
|
|
let pollInterval = null;
|
|
|
|
|
|
let skeletonsRendered = false;
|
|
|
|
|
|
let msgInterval = null;
|
|
|
|
|
|
|
|
|
|
|
|
const statusMessages = {
|
|
|
|
|
|
queued: ["Stiller i kø…", "Venter på ledig plads"],
|
|
|
|
|
|
fetching: ["Henter annoncer fra DBA…", "Indlæser titler, priser og beskrivelser"],
|
|
|
|
|
|
scoring: ["AI analyserer annoncerne…", ""],
|
|
|
|
|
|
ready: ["Analyse færdig", ""],
|
|
|
|
|
|
error: ["Noget gik galt", "Tjek søgelinket og prøv igen"],
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const funMessages = [
|
|
|
|
|
|
"Vurderer bilers pålidelighed og historik…",
|
|
|
|
|
|
"Sammenligner kilometertal og prisforhold…",
|
|
|
|
|
|
"Tjekker om sælger lyder troværdig…",
|
|
|
|
|
|
"Vurderer forventede vedligeholdelsesomkostninger…",
|
|
|
|
|
|
"Sammenligner mod aktuelle markedspriser…",
|
|
|
|
|
|
"Analyserer annoncebeskrivelserne for røde flag…",
|
|
|
|
|
|
"Overvejer årstal og udstyrsgrad…",
|
|
|
|
|
|
"Ranker efter kvalitet og pris…",
|
|
|
|
|
|
"Tjekker km-stand mod forventet for alderen…",
|
|
|
|
|
|
"Næsten færdig — finpudser rangeringen…",
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
function togglePrefs() {
|
|
|
|
|
|
const s = document.getElementById("prefs-section");
|
|
|
|
|
|
const b = document.getElementById("prefs-btn");
|
|
|
|
|
|
s.classList.toggle("hidden");
|
|
|
|
|
|
b.textContent = s.classList.contains("hidden") ? "+ Præferencer" : "− Præferencer";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function submitSearch() {
|
|
|
|
|
|
const url = document.getElementById("url-input").value.trim();
|
|
|
|
|
|
const prefs = document.getElementById("prefs-input").value.trim();
|
|
|
|
|
|
|
|
|
|
|
|
if (!url.startsWith("https://www.dba.dk")) {
|
|
|
|
|
|
alert("Indsæt et gyldigt DBA søgelink");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const btn = document.getElementById("submit-btn");
|
|
|
|
|
|
btn.disabled = true;
|
|
|
|
|
|
btn.innerHTML = `<svg class="spinner w-4 h-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-20" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3"/><path class="opacity-80" fill="currentColor" d="M4 12a8 8 0 018-8v8z"/></svg><span>Sender…</span>`;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch("/api/searches", {
|
|
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: {"Content-Type": "application/json"},
|
|
|
|
|
|
body: JSON.stringify({url, prefs}),
|
|
|
|
|
|
});
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
currentSearchId = data.id;
|
|
|
|
|
|
|
|
|
|
|
|
showStatus();
|
|
|
|
|
|
startPolling(data.id);
|
|
|
|
|
|
} catch(e) {
|
|
|
|
|
|
alert("Fejl: " + e.message);
|
|
|
|
|
|
btn.disabled = false;
|
|
|
|
|
|
btn.innerHTML = `<span>Analyser</span><svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3"/></svg>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function showStatus() {
|
|
|
|
|
|
document.getElementById("form-section").classList.add("hidden");
|
|
|
|
|
|
document.getElementById("status-section").classList.remove("hidden");
|
|
|
|
|
|
document.getElementById("results-section").classList.add("hidden");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function startPolling(id) {
|
|
|
|
|
|
history.pushState(null, "", `/search/${id}`);
|
|
|
|
|
|
pollInterval = setInterval(() => poll(id), 2000);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function startFunMessages() {
|
|
|
|
|
|
let i = 0;
|
|
|
|
|
|
const el = document.getElementById("status-sub");
|
|
|
|
|
|
el.textContent = funMessages[0];
|
|
|
|
|
|
msgInterval = setInterval(() => {
|
|
|
|
|
|
i = (i + 1) % funMessages.length;
|
|
|
|
|
|
el.textContent = funMessages[i];
|
|
|
|
|
|
}, 3500);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderSkeletons(count) {
|
|
|
|
|
|
if (skeletonsRendered) return;
|
|
|
|
|
|
skeletonsRendered = true;
|
|
|
|
|
|
const container = document.getElementById("skeleton-container");
|
|
|
|
|
|
container.innerHTML = "";
|
|
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
|
|
|
|
const card = document.createElement("div");
|
|
|
|
|
|
card.className = "card rounded-xl p-5";
|
|
|
|
|
|
card.style.animationDelay = `${i * 40}ms`;
|
|
|
|
|
|
card.innerHTML = `
|
|
|
|
|
|
<div class="flex items-start gap-4">
|
|
|
|
|
|
<div class="skeleton shrink-0 mt-1" style="width:1rem;height:.75rem;border-radius:3px"></div>
|
|
|
|
|
|
<div class="flex-1 space-y-2.5">
|
|
|
|
|
|
<div class="flex gap-2">
|
|
|
|
|
|
<div class="skeleton" style="height:.8rem;width:7rem;border-radius:3px"></div>
|
|
|
|
|
|
<div class="skeleton" style="height:.8rem;width:4rem;border-radius:3px"></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="skeleton" style="height:.4rem;width:100%;border-radius:9999px"></div>
|
|
|
|
|
|
<div class="skeleton" style="height:.7rem;width:75%;border-radius:3px"></div>
|
|
|
|
|
|
<div class="skeleton" style="height:.7rem;width:55%;border-radius:3px"></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="shrink-0 ml-2 space-y-2">
|
|
|
|
|
|
<div class="skeleton" style="height:.9rem;width:5rem;border-radius:3px"></div>
|
|
|
|
|
|
<div class="skeleton" style="height:.7rem;width:3rem;border-radius:3px;margin-left:auto"></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>`;
|
|
|
|
|
|
container.appendChild(card);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function stopFunMessages() {
|
|
|
|
|
|
if (msgInterval) { clearInterval(msgInterval); msgInterval = null; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function poll(id) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`/api/searches/${id}`);
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
|
|
|
|
|
|
const total = data.listing_count || 0;
|
|
|
|
|
|
const scored = data.scored_count || 0;
|
|
|
|
|
|
|
|
|
|
|
|
// Show skeletons as soon as we know the count
|
|
|
|
|
|
if (total > 0) renderSkeletons(total);
|
|
|
|
|
|
|
|
|
|
|
|
// Update status text
|
|
|
|
|
|
const [main] = statusMessages[data.status] || ["Arbejder…"];
|
|
|
|
|
|
document.getElementById("status-text").textContent = main;
|
|
|
|
|
|
document.getElementById("header-status").textContent = data.status === "ready" ? "" : main;
|
|
|
|
|
|
|
|
|
|
|
|
// Progress counter during scoring
|
|
|
|
|
|
if (data.status === "scoring" && total > 0) {
|
|
|
|
|
|
document.getElementById("status-progress").textContent = `${scored}/${total}`;
|
|
|
|
|
|
if (!msgInterval) startFunMessages();
|
|
|
|
|
|
} else if (data.status !== "scoring") {
|
|
|
|
|
|
const [, sub] = statusMessages[data.status] || ["", ""];
|
|
|
|
|
|
document.getElementById("status-sub").textContent = sub;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (data.status === "ready") {
|
|
|
|
|
|
stopFunMessages();
|
|
|
|
|
|
clearInterval(pollInterval);
|
|
|
|
|
|
showResults(data);
|
|
|
|
|
|
} else if (data.status === "error") {
|
|
|
|
|
|
stopFunMessages();
|
|
|
|
|
|
clearInterval(pollInterval);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch(e) {
|
|
|
|
|
|
console.error("Poll fejl:", e);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function showResults(data) {
|
|
|
|
|
|
document.getElementById("status-section").classList.add("hidden");
|
|
|
|
|
|
document.getElementById("results-section").classList.remove("hidden");
|
|
|
|
|
|
document.getElementById("header-status").textContent = "";
|
|
|
|
|
|
|
|
|
|
|
|
const listings = data.listings || [];
|
|
|
|
|
|
document.getElementById("result-count").textContent =
|
|
|
|
|
|
`${listings.length} annoncer analyseret · sorteret efter AI-score`;
|
|
|
|
|
|
|
|
|
|
|
|
const container = document.getElementById("listings-container");
|
|
|
|
|
|
container.innerHTML = "";
|
|
|
|
|
|
|
|
|
|
|
|
listings.forEach((item, i) => {
|
|
|
|
|
|
const score = item.ai_score || 0;
|
|
|
|
|
|
const pct = Math.round(score * 10);
|
|
|
|
|
|
const warn = item.ai_warnings
|
|
|
|
|
|
? `<p class="text-xs mt-2" style="color:#dc2626">↑ ${item.ai_warnings}</p>` : "";
|
2026-05-24 19:35:43 +02:00
|
|
|
|
const qualityFlags = (item.data_quality_flags || []).length > 0
|
|
|
|
|
|
? `<div class="mt-2 rounded-lg px-3 py-2" style="background:#fff7ed;border:1px solid #fed7aa">
|
|
|
|
|
|
${item.data_quality_flags.map(f => `<p class="text-xs" style="color:#c2410c">${f}</p>`).join("")}
|
|
|
|
|
|
</div>` : "";
|
2026-05-24 19:14:41 +02:00
|
|
|
|
const tag = rankTag(score);
|
|
|
|
|
|
|
|
|
|
|
|
const card = document.createElement("div");
|
|
|
|
|
|
card.className = "card rounded-xl p-5 card-enter";
|
|
|
|
|
|
card.style.animationDelay = `${i * 30}ms`;
|
|
|
|
|
|
card.innerHTML = `
|
|
|
|
|
|
<div class="flex items-start gap-4">
|
|
|
|
|
|
<span class="text-xs font-mono pt-0.5 shrink-0" style="color:#d6d3d1;min-width:1.5rem">${i+1}</span>
|
|
|
|
|
|
<div class="flex-1 min-w-0">
|
|
|
|
|
|
<div class="flex items-center gap-2 mb-2 flex-wrap">
|
|
|
|
|
|
<span class="font-semibold text-sm tracking-tight">${item.name}</span>
|
|
|
|
|
|
<span class="text-xs" style="color:#a8a29e">${item.description || ""}</span>
|
|
|
|
|
|
${tag}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="flex items-center gap-3 mb-3">
|
|
|
|
|
|
<div class="flex-1 rounded-full" style="background:#f5f5f4;height:3px">
|
|
|
|
|
|
<div class="score-fill rounded-full" style="height:3px;width:${pct}%"></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span class="text-xs font-bold tabular-nums shrink-0">${score.toFixed(1)}<span style="color:#d6d3d1">/10</span></span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p class="text-xs leading-relaxed" style="color:#78716c">${item.ai_reason || ""}</p>
|
|
|
|
|
|
${warn}
|
2026-05-24 19:35:43 +02:00
|
|
|
|
${qualityFlags}
|
2026-05-24 19:14:41 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="shrink-0 text-right ml-2">
|
|
|
|
|
|
<p class="font-semibold text-sm tabular-nums">${Number(item.price_dkk || 0).toLocaleString("da-DK")}<span class="text-xs font-normal ml-0.5" style="color:#a8a29e">kr</span></p>
|
|
|
|
|
|
<a href="${item.url}" target="_blank" class="annonce-link mt-1.5 block">Se →</a>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>`;
|
|
|
|
|
|
container.appendChild(card);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function rankTag(score) {
|
|
|
|
|
|
if (score >= 8.5) return '<span class="tag-top text-xs px-2 py-0.5 rounded font-medium">Topvalg</span>';
|
|
|
|
|
|
if (score >= 7.5) return '<span class="tag-good text-xs px-2 py-0.5 rounded font-medium">Godt køb</span>';
|
|
|
|
|
|
if (score >= 6.5) return '<span class="tag-mid text-xs px-2 py-0.5 rounded font-medium">Middel</span>';
|
|
|
|
|
|
return '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function sendEmail() {
|
|
|
|
|
|
const email = document.getElementById("email-input").value.trim();
|
|
|
|
|
|
const btn = document.getElementById("email-btn");
|
|
|
|
|
|
const status = document.getElementById("email-status");
|
|
|
|
|
|
|
|
|
|
|
|
if (!email) { alert("Indtast en email-adresse"); return; }
|
|
|
|
|
|
if (!currentSearchId) return;
|
|
|
|
|
|
|
|
|
|
|
|
btn.disabled = true;
|
|
|
|
|
|
btn.textContent = "Sender…";
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`/api/searches/${currentSearchId}/email`, {
|
|
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: {"Content-Type": "application/json"},
|
|
|
|
|
|
body: JSON.stringify({email}),
|
|
|
|
|
|
});
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
status.classList.remove("hidden");
|
|
|
|
|
|
if (res.ok) {
|
|
|
|
|
|
status.style.color = "#4ade80";
|
|
|
|
|
|
status.textContent = `Sendt til ${email}`;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
status.style.color = "#f87171";
|
|
|
|
|
|
status.textContent = data.detail || "Fejl ved afsendelse";
|
|
|
|
|
|
btn.disabled = false; btn.textContent = "Send";
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch(e) {
|
|
|
|
|
|
status.classList.remove("hidden");
|
|
|
|
|
|
status.style.color = "#f87171";
|
|
|
|
|
|
status.textContent = "Netværksfejl — prøv igen";
|
|
|
|
|
|
btn.disabled = false; btn.textContent = "Send";
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resetForm() {
|
|
|
|
|
|
if (pollInterval) clearInterval(pollInterval);
|
|
|
|
|
|
stopFunMessages();
|
|
|
|
|
|
skeletonsRendered = false;
|
|
|
|
|
|
currentSearchId = null;
|
|
|
|
|
|
document.getElementById("form-section").classList.remove("hidden");
|
|
|
|
|
|
document.getElementById("status-section").classList.add("hidden");
|
|
|
|
|
|
document.getElementById("results-section").classList.add("hidden");
|
|
|
|
|
|
document.getElementById("skeleton-container").innerHTML = "";
|
|
|
|
|
|
document.getElementById("status-progress").textContent = "";
|
|
|
|
|
|
document.getElementById("listings-container").innerHTML = "";
|
|
|
|
|
|
document.getElementById("url-input").value = "";
|
|
|
|
|
|
document.getElementById("prefs-input").value = "";
|
|
|
|
|
|
document.getElementById("email-input").value = "";
|
|
|
|
|
|
document.getElementById("header-status").textContent = "";
|
|
|
|
|
|
document.getElementById("email-status").classList.add("hidden");
|
|
|
|
|
|
const btn = document.getElementById("submit-btn");
|
|
|
|
|
|
btn.disabled = false;
|
|
|
|
|
|
btn.innerHTML = `<span>Analyser</span><svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3"/></svg>`;
|
|
|
|
|
|
history.pushState(null, "", "/");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (PRELOAD_ID) {
|
|
|
|
|
|
currentSearchId = PRELOAD_ID;
|
|
|
|
|
|
showStatus();
|
|
|
|
|
|
poll(PRELOAD_ID).then(() => {
|
|
|
|
|
|
if (!document.getElementById("status-section").classList.contains("hidden")) {
|
|
|
|
|
|
startPolling(PRELOAD_ID);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
</body>
|
|
|
|
|
|
</html>
|