Files
BlaaAI/templates/index.html
Henrik Jess Nielsen af84822641
All checks were successful
Build and Deploy BlaaAi / build-and-deploy (push) Successful in 4m38s
Fix unwanted page scroll on desktop and mobile
- Remove min-height:100vh + padding-bottom:22rem that caused overflow
- Replace with calc(100vh - 60px) to center form without scroll
- Add html,body height:100% and overflow-x:hidden
2026-05-24 22:26:37 +02:00

543 lines
23 KiB
HTML
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.
<!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>
html, body { height: 100%; }
body { background: #fafaf9; color: #0c0a09; font-family: 'Space Grotesk', sans-serif; overflow-x: hidden; }
.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="display:flex;flex-direction:column;justify-content:center;padding-top:2rem;padding-bottom:4rem;min-height:calc(100vh - 60px)">
<!-- ── 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>` : "";
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>` : "";
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}
${qualityFlags}
</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>