- Eigenes Icon: Lupe über aufsteigenden Preis-Balken (Beobachtung + Markt) in Markengrün, als skalierbares SVG. Favicon via /favicon.svg-Route (image/svg+xml, gecached) + Inline-Motiv im App-Bar-Logo. - App-Bar-Wortmarke „MABOTO / Marktbeobachtung", Seitentitel, Intro-Kopf und FastAPI-App-Titel umbenannt; README-H1 + Doku-Screenshots neu. - Tests: Index prüft jetzt auf MABOTO, neuer favicon.svg-Test (77 grün). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
957 lines
46 KiB
HTML
957 lines
46 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<meta name="color-scheme" content="light dark" />
|
||
<meta name="theme-color" content="#0e5d42" media="(prefers-color-scheme: light)" />
|
||
<meta name="theme-color" content="#13120d" media="(prefers-color-scheme: dark)" />
|
||
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
||
<link rel="apple-touch-icon" href="favicon.svg" />
|
||
<title>MABOTO — Marktbeobachtungstool</title>
|
||
<meta name="description" content="MABOTO — Marktbeobachtungstool: ortskonkrete, händlerübergreifende Übersicht der wöchentlichen Angebote, nach Produktgruppen." />
|
||
<style>
|
||
/* ============================================================
|
||
Farb-Tokens — AAA-verifiziert (jedes Text-Paar >=7:1,
|
||
Steuer-Ränder >=3:1). Hell als Default, Dunkel adaptiv.
|
||
============================================================ */
|
||
:root {
|
||
--bg:#f4f2ee; --panel:#ffffff; --panel-2:#f6f4ef; --panel-3:#efece4;
|
||
--ink:#161510; --ink-2:#2f2d26; --muted:#504d45;
|
||
--brand:#0e5d42; --brand-text:#0c5238; --on-brand:#ffffff;
|
||
--brand-weak:#eef5f1; --brand-weak-line:#cfe3d9;
|
||
--warn:#684500; --warn-bg:#f8efd6; --warn-line:#e6cf97;
|
||
--err:#8a231d; --err-bg:#fbeae8; --err-line:#e6b8b2;
|
||
--price:#11221b;
|
||
--line:#e3ded5; --line-2:#eceae2; --ctrl:#8a8474;
|
||
--focus:#0e5d42;
|
||
--s1:4px; --s2:8px; --s3:12px; --s4:16px; --s6:24px; --s8:32px; --s12:48px;
|
||
--radius:16px; --radius-sm:11px; --radius-pill:999px;
|
||
--appbar-h:60px;
|
||
--mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
|
||
--shadow-sm:0 1px 2px rgba(20,18,12,.06);
|
||
--shadow-md:0 10px 30px -12px rgba(20,18,12,.22);
|
||
--shadow-lg:0 20px 50px -20px rgba(20,18,12,.30);
|
||
--ring:0 0 0 3px var(--brand-weak), 0 0 0 1.5px var(--brand);
|
||
}
|
||
@media (prefers-color-scheme: dark) {
|
||
:root {
|
||
--bg:#13120d; --panel:#1d1b15; --panel-2:#23211a; --panel-3:#2a2820;
|
||
--ink:#f2efe7; --ink-2:#d6d2c6; --muted:#cbc6b7;
|
||
--brand:#3db188; --brand-text:#6cd2a4; --on-brand:#06130c;
|
||
--brand-weak:#18352a; --brand-weak-line:#2c5a47;
|
||
--warn:#f4cf86; --warn-bg:#3a2f12; --warn-line:#5c4a1e;
|
||
--err:#f0a59c; --err-bg:#3a1f1c; --err-line:#5e2e2a;
|
||
--price:#eaf7f0;
|
||
--line:#2e2c24; --line-2:#272519; --ctrl:#767161;
|
||
--focus:#6cd2a4;
|
||
--shadow-sm:0 1px 2px rgba(0,0,0,.4);
|
||
--shadow-md:0 12px 32px -14px rgba(0,0,0,.6);
|
||
--shadow-lg:0 24px 56px -22px rgba(0,0,0,.7);
|
||
--ring:0 0 0 3px rgba(108,210,164,.28), 0 0 0 1.5px var(--brand-text);
|
||
}
|
||
}
|
||
|
||
* { box-sizing:border-box; }
|
||
html { scroll-padding-top: calc(var(--appbar-h) + var(--s4)); }
|
||
body {
|
||
margin:0; background:var(--bg); color:var(--ink);
|
||
font:16px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
|
||
-webkit-font-smoothing:antialiased; letter-spacing:-.003em;
|
||
}
|
||
@media (prefers-reduced-motion: reduce) { *,*::before,*::after { transition-duration:.01ms !important; animation-duration:.01ms !important; } }
|
||
|
||
/* sichtbarer Fokus — überall, kräftig (WCAG 2.4.13/2.4.11) */
|
||
:focus { outline:none; }
|
||
:focus-visible { outline:none; box-shadow:var(--ring); border-radius:var(--radius-sm); }
|
||
a { color:var(--brand-text); }
|
||
|
||
/* ============ App-Bar (sticky, gibt App-Identität) ============ */
|
||
.appbar {
|
||
position:sticky; top:0; z-index:50; height:var(--appbar-h);
|
||
background:color-mix(in srgb, var(--panel) 86%, transparent);
|
||
-webkit-backdrop-filter:saturate(1.4) blur(10px); backdrop-filter:saturate(1.4) blur(10px);
|
||
border-bottom:1px solid var(--line);
|
||
}
|
||
.appbar-inner { max-width:1100px; margin:0 auto; height:100%; padding:0 var(--s6);
|
||
display:flex; align-items:center; gap:var(--s3); }
|
||
.brand { display:flex; align-items:center; gap:11px; font-weight:800; font-size:17px;
|
||
letter-spacing:-.02em; color:var(--ink); text-decoration:none; }
|
||
.brand .word { display:flex; flex-direction:column; line-height:1.04; }
|
||
.brand .word small { font-size:10px; font-weight:700; letter-spacing:.1em;
|
||
text-transform:uppercase; color:var(--muted); margin-top:1px; }
|
||
.brand .logo {
|
||
width:34px; height:34px; border-radius:10px; flex:none; display:grid; place-items:center;
|
||
background:linear-gradient(145deg,var(--brand),color-mix(in srgb,var(--brand) 70%, #000 12%));
|
||
color:var(--on-brand); font-size:18px; box-shadow:var(--shadow-sm);
|
||
}
|
||
.appbar .place {
|
||
margin-left:6px; display:inline-flex; align-items:center; gap:6px;
|
||
font-size:13px; font-weight:700; color:var(--brand-text);
|
||
background:var(--brand-weak); border:1px solid var(--brand-weak-line);
|
||
padding:5px 11px; border-radius:var(--radius-pill); font-variant-numeric:tabular-nums;
|
||
}
|
||
.appbar .place.leer { color:var(--muted); background:var(--panel-2); border-color:var(--line); }
|
||
.appbar .spacer { flex:1; }
|
||
.appbar .iconbtn {
|
||
width:40px; height:40px; border-radius:11px; border:1px solid var(--line);
|
||
background:var(--panel); color:var(--ink-2); display:grid; place-items:center;
|
||
cursor:pointer; font-size:17px; padding:0; min-height:0;
|
||
}
|
||
.appbar .iconbtn:hover { background:var(--panel-2); border-color:var(--ctrl); transform:none; box-shadow:none; }
|
||
|
||
/* ============ Intro ============ */
|
||
.intro { max-width:1100px; margin:0 auto; padding:var(--s8) var(--s6) 0; }
|
||
.intro .eyebrow {
|
||
font-size:12px; text-transform:uppercase; letter-spacing:.14em;
|
||
color:var(--brand-text); font-weight:800; margin:0 0 var(--s2);
|
||
}
|
||
.intro h1 { margin:0; font-size:clamp(1.8rem,3vw,2.5rem); line-height:1.05;
|
||
letter-spacing:-.03em; font-weight:800; }
|
||
.intro p { margin:var(--s3) 0 0; color:var(--muted); font-size:15.5px; max-width:64ch; text-wrap:pretty; }
|
||
|
||
.wrap { max-width:1100px; margin:0 auto; padding:var(--s6) var(--s6) var(--s12); }
|
||
.stages { display:grid; grid-template-columns:1fr; gap:var(--s4); }
|
||
|
||
/* ============ Stufen-Karten ============ */
|
||
.stage {
|
||
background:var(--panel); border:1px solid var(--line); border-radius:var(--radius);
|
||
box-shadow:var(--shadow-sm); overflow:hidden;
|
||
transition:opacity .2s, box-shadow .2s;
|
||
}
|
||
.stage.locked { opacity:.6; }
|
||
.stage-head {
|
||
display:flex; align-items:center; gap:var(--s4);
|
||
padding:var(--s4) var(--s6); border-bottom:1px solid var(--line-2);
|
||
background:linear-gradient(var(--panel),var(--panel-2));
|
||
}
|
||
.stage-num {
|
||
flex:none; width:32px; height:32px; border-radius:10px;
|
||
display:grid; place-items:center; font-weight:800; font-size:15px;
|
||
background:var(--brand); color:var(--on-brand); font-variant-numeric:tabular-nums;
|
||
box-shadow:var(--shadow-sm);
|
||
}
|
||
.stage.locked .stage-num { background:var(--ctrl); color:var(--panel); }
|
||
.stage-titles { flex:1; min-width:0; }
|
||
.stage-titles h2 { margin:0; font-size:16.5px; font-weight:800; letter-spacing:-.015em; }
|
||
.stage-titles .sub { margin:3px 0 0; font-size:13px; color:var(--muted); }
|
||
.stage-flag {
|
||
flex:none; font-size:11px; font-weight:700; letter-spacing:.03em;
|
||
text-transform:uppercase; padding:4px 10px; border-radius:var(--radius-pill);
|
||
background:var(--brand-weak); color:var(--brand-text); border:1px solid var(--brand-weak-line);
|
||
}
|
||
.stage-flag.llm { background:color-mix(in srgb, var(--brand-weak) 60%, var(--panel)); }
|
||
.stage-body { padding:var(--s6); }
|
||
|
||
.row { display:flex; flex-wrap:wrap; gap:var(--s4) var(--s6); align-items:end; }
|
||
.field { display:flex; flex-direction:column; gap:6px; }
|
||
.field label { font-size:11px; text-transform:uppercase; letter-spacing:.06em; color:var(--muted); font-weight:700; }
|
||
input, select, button { font:inherit; }
|
||
input[type=text], input[type=password], select {
|
||
padding:11px 13px; border:1.5px solid var(--ctrl); border-radius:var(--radius-sm);
|
||
background:var(--panel); color:var(--ink); transition:border-color .15s, box-shadow .15s;
|
||
}
|
||
input::placeholder { color:var(--muted); opacity:1; }
|
||
input:focus-visible, select:focus-visible { box-shadow:var(--ring); border-color:var(--brand); }
|
||
input#plz { width:120px; font-variant-numeric:tabular-nums; font-size:19px; font-weight:800; letter-spacing:.06em; }
|
||
select#modell { min-width:280px; }
|
||
input#modellsuche { width:160px; }
|
||
|
||
button {
|
||
padding:12px 22px; border:1.5px solid var(--brand); border-radius:var(--radius-sm);
|
||
background:var(--brand); color:var(--on-brand); cursor:pointer; font-weight:700; min-height:46px;
|
||
transition:transform .12s ease, filter .15s, box-shadow .15s; white-space:nowrap;
|
||
}
|
||
button:hover:not(:disabled) { filter:brightness(1.06); transform:translateY(-1px); box-shadow:var(--shadow-md); }
|
||
button:active:not(:disabled) { transform:translateY(0); }
|
||
button.ghost { background:var(--panel); color:var(--brand-text); border-color:var(--ctrl); }
|
||
button.ghost:hover:not(:disabled) { background:var(--brand-weak); border-color:var(--brand); filter:none; }
|
||
button:disabled { opacity:.5; cursor:not-allowed; }
|
||
button.icon { padding:11px 15px; }
|
||
|
||
.hint { font-size:13px; color:var(--muted); margin-top:var(--s3); max-width:70ch; }
|
||
.hint.lock { color:var(--warn); display:flex; align-items:center; gap:6px; font-weight:600; }
|
||
|
||
/* Roh-Stand */
|
||
.rohstand { margin-top:var(--s4); padding:var(--s4); border:1px solid var(--line-2);
|
||
border-radius:var(--radius-sm); background:var(--panel-2); }
|
||
.rohstand.ok { border-color:var(--brand-weak-line); background:var(--brand-weak); }
|
||
.rohstand .line1 { display:flex; flex-wrap:wrap; gap:6px 16px; align-items:baseline; }
|
||
.rohstand .ok-dot { color:var(--brand-text); font-weight:800; }
|
||
.rohstand b { color:var(--ink); }
|
||
.rohstand .meta { color:var(--muted); font-size:13px; }
|
||
.rohstand .haendler-tags { margin-top:var(--s3); display:flex; flex-wrap:wrap; gap:6px; }
|
||
.tag { font-size:12px; background:var(--panel); color:var(--ink-2); padding:3px 10px;
|
||
border-radius:var(--radius-pill); border:1px solid var(--line); font-weight:600; }
|
||
|
||
details.rohliste { margin-top:var(--s3); }
|
||
details.rohliste > summary { cursor:pointer; font-size:13.5px; color:var(--brand-text); font-weight:700;
|
||
user-select:none; padding:var(--s2) var(--s2); border-radius:var(--radius-sm); display:inline-block; }
|
||
details.rohliste > summary:focus-visible { box-shadow:var(--ring); }
|
||
.rohtable { width:100%; border-collapse:collapse; margin-top:var(--s2); font-size:13.5px; }
|
||
.rohtable th { text-align:left; font-weight:700; color:var(--muted); font-size:11px; text-transform:uppercase;
|
||
letter-spacing:.05em; padding:6px 10px; border-bottom:1px solid var(--line); }
|
||
.rohtable td { padding:8px 10px; border-bottom:1px solid var(--line-2); vertical-align:top; }
|
||
.rohtable tr:last-child td { border-bottom:none; }
|
||
.rohtable .t-preis { text-align:right; font-variant-numeric:tabular-nums; font-weight:700; white-space:nowrap; color:var(--price); }
|
||
.rohtable .t-h { color:var(--muted); }
|
||
.rohtable .marke { color:var(--brand-text); font-weight:700; }
|
||
|
||
/* LLM-Konfig */
|
||
.config { border:1px solid var(--line); border-radius:var(--radius); background:var(--panel);
|
||
box-shadow:var(--shadow-sm); overflow:hidden; }
|
||
.config > summary { cursor:pointer; user-select:none; list-style:none;
|
||
display:flex; align-items:center; gap:var(--s3); padding:var(--s4) var(--s6); font-weight:800; font-size:15px; }
|
||
.config > summary::-webkit-details-marker { display:none; }
|
||
.config > summary:focus-visible { box-shadow:var(--ring); border-radius:var(--radius); }
|
||
.config > summary .chev { transition:transform .2s; color:var(--muted); }
|
||
.config[open] > summary .chev { transform:rotate(90deg); }
|
||
.config > summary .ico { font-size:15px; }
|
||
.config > summary .gewaehlt-chip { margin-left:auto; font-size:12px; font-weight:700; color:var(--brand-text);
|
||
background:var(--brand-weak); border:1px solid var(--brand-weak-line); border-radius:var(--radius-pill);
|
||
padding:4px 12px; max-width:46ch; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||
.config > summary .gilt { margin-left:var(--s3); font-weight:600; font-size:12px; color:var(--muted); }
|
||
.config-body { padding:0 var(--s6) var(--s6); border-top:1px solid var(--line-2); padding-top:var(--s4); }
|
||
.keyrow { width:100%; max-width:560px; }
|
||
.keyrow input { width:100%; }
|
||
|
||
/* Status / Fortschritt */
|
||
#status, #rohstatus { margin:var(--s4) 0 0; font-size:14.5px; }
|
||
#status:empty, #rohstatus:empty { margin:0; }
|
||
.err { background:var(--err-bg); border:1px solid var(--err-line); color:var(--err);
|
||
padding:var(--s4); border-radius:var(--radius-sm); white-space:pre-wrap;
|
||
font-family:var(--mono); font-size:13px; line-height:1.5; }
|
||
.pending { color:var(--muted); }
|
||
|
||
/* LLM-Arbeit SICHTBAR (Grundregel) */
|
||
@keyframes llm-spin { to { transform:rotate(360deg); } }
|
||
@keyframes llm-shimmer { 0%{background-position:-220px 0} 100%{background-position:220px 0} }
|
||
.llm-busy { display:flex; align-items:center; gap:var(--s4); margin-top:var(--s3);
|
||
padding:var(--s4); border-radius:var(--radius); background:var(--brand-weak); border:1px solid var(--brand-weak-line); }
|
||
.llm-busy .spin { flex:none; width:22px; height:22px; border:2.5px solid var(--brand-weak-line);
|
||
border-top-color:var(--brand); border-radius:50%; animation:llm-spin .8s linear infinite; }
|
||
.llm-busy .txt { flex:1; min-width:0; }
|
||
.llm-busy .txt .hl { font-weight:800; color:var(--brand-text); }
|
||
.llm-busy .txt small { display:block; color:var(--muted); font-size:12.5px; margin-top:2px; }
|
||
.llm-busy .bar2 { height:8px; border-radius:5px; background:var(--brand-weak-line); overflow:hidden; margin-top:8px; }
|
||
.llm-busy .bar2 > i { display:block; height:100%; background:var(--brand); border-radius:5px; transition:width .3s; }
|
||
.llm-busy.indet .bar2 > i { width:40%; background:linear-gradient(90deg,var(--brand-weak-line),var(--brand),var(--brand-weak-line));
|
||
background-size:220px 100%; animation:llm-shimmer 1.1s linear infinite; }
|
||
@media (prefers-reduced-motion: reduce){ .llm-busy .spin{ animation:none; } .llm-busy.indet .bar2 > i{ animation:none; width:100%; } }
|
||
|
||
/* ============ Ergebnis ============ */
|
||
#result { margin-top:var(--s8); }
|
||
header.ergebnis-header { position:sticky; top:var(--appbar-h); z-index:20; background:var(--bg);
|
||
padding-top:var(--s3); padding-bottom:var(--s3); margin-bottom:var(--s4); border-bottom:1px solid var(--line); }
|
||
.summary { display:flex; flex-wrap:wrap; gap:6px var(--s4); align-items:baseline; color:var(--muted);
|
||
font-size:14px; margin-bottom:var(--s3); }
|
||
.summary b { color:var(--ink); font-weight:700; }
|
||
.filters { display:flex; flex-wrap:wrap; gap:var(--s4) var(--s6); align-items:end;
|
||
padding:var(--s4) var(--s6); background:var(--panel); border:1px solid var(--line);
|
||
border-radius:var(--radius); margin-bottom:var(--s3); box-shadow:var(--shadow-sm); }
|
||
.check { flex-direction:row; align-items:center; gap:9px; }
|
||
.check input { width:20px; height:20px; accent-color:var(--brand); }
|
||
.check label { text-transform:none; letter-spacing:0; font-size:14px; color:var(--ink); font-weight:600; }
|
||
|
||
/* Gruppen-Band */
|
||
nav.gruppenband { display:flex; gap:8px; overflow-x:auto; padding:4px 0; scrollbar-width:thin; }
|
||
.navitem { flex:none; display:inline-flex; align-items:center; gap:8px; white-space:nowrap;
|
||
padding:9px 15px; border-radius:var(--radius-pill); min-height:40px; cursor:pointer;
|
||
background:var(--panel); color:var(--ink-2); border:1px solid var(--line);
|
||
font-weight:700; font-size:13.5px; transition:background .15s, color .15s, border-color .15s; }
|
||
.navitem:hover { background:var(--panel-2); transform:none; box-shadow:none; color:var(--ink); border-color:var(--ctrl); filter:none; }
|
||
.navitem.aktiv { background:var(--brand-weak); color:var(--brand-text); border-color:var(--brand-weak-line); }
|
||
.navitem .nav-cnt { font-variant-numeric:tabular-nums; color:var(--muted); font-size:12.5px; font-weight:600; }
|
||
.navitem.aktiv .nav-cnt { color:var(--brand-text); }
|
||
.navitem .nav-uns { font-size:10.5px; color:var(--warn); border:1px solid var(--warn-line);
|
||
background:var(--warn-bg); border-radius:6px; padding:1px 6px; font-weight:800; }
|
||
.kfeedback { font-size:13.5px; color:var(--brand-text); font-weight:700; height:0; overflow:hidden; transition:height .2s; }
|
||
.kfeedback.an { height:24px; margin-top:var(--s2); }
|
||
|
||
/* Gruppen-Sektionen */
|
||
section.grp { background:var(--panel); border:1px solid var(--line); border-radius:var(--radius);
|
||
margin-bottom:var(--s4); overflow:hidden; box-shadow:var(--shadow-sm); }
|
||
.grp-h { width:100%; margin:0; padding:0; border:none; background:transparent; cursor:pointer; }
|
||
.grp-h-btn { width:100%; display:flex; justify-content:space-between; align-items:center; gap:var(--s3);
|
||
padding:var(--s4) var(--s6); background:linear-gradient(var(--panel),var(--panel-2));
|
||
border:none; border-bottom:1px solid var(--line-2); border-radius:0; cursor:pointer;
|
||
font-size:15.5px; font-weight:800; letter-spacing:-.01em; color:var(--ink); min-height:0; }
|
||
.grp-h-btn:hover { filter:none; transform:none; box-shadow:none; background:var(--panel-2); }
|
||
.grp-h-btn:focus-visible { box-shadow:var(--ring); }
|
||
.grp-h-btn .left { display:flex; align-items:center; gap:10px; }
|
||
.grp-h-btn .dot { width:10px; height:10px; border-radius:50%; background:var(--brand); flex:none; }
|
||
.grp-h-btn .cnt { color:var(--muted); font-weight:700; font-variant-numeric:tabular-nums; font-size:13px;
|
||
display:inline-flex; align-items:center; gap:8px; }
|
||
.grp-h-btn .chev { color:var(--muted); transition:transform .2s; font-size:13px; }
|
||
section.grp.zu .grp-h-btn { border-bottom:none; }
|
||
section.grp.zu .grp-h-btn .chev { transform:rotate(-90deg); }
|
||
section.grp.zu ul { display:none; }
|
||
section.grp ul { list-style:none; margin:0; padding:var(--s1) 0; }
|
||
|
||
li.ang { display:grid; grid-template-columns:1fr auto; gap:3px var(--s4); padding:13px var(--s6);
|
||
border-bottom:1px solid var(--line-2); align-items:baseline; }
|
||
li.ang:last-child { border-bottom:none; }
|
||
li.ang:hover { background:var(--panel-2); }
|
||
.ang .name { font-weight:700; font-size:15px; color:var(--ink); }
|
||
.ang .name .marke { color:var(--brand-text); }
|
||
.ang .meta { grid-column:1; color:var(--muted); font-size:13px; display:flex; flex-wrap:wrap; align-items:center; gap:5px 9px; }
|
||
.ang .meta .haendler { color:var(--ink-2); background:var(--brand-weak); border:1px solid var(--brand-weak-line);
|
||
padding:2px 9px; border-radius:var(--radius-pill); font-weight:700; font-size:12px; }
|
||
.ang .preis { grid-column:2; grid-row:1; text-align:right; font-weight:800; font-size:17px;
|
||
color:var(--price); font-variant-numeric:tabular-nums; white-space:nowrap; }
|
||
.ang .grund { grid-column:2; grid-row:2; text-align:right; color:var(--muted); font-size:12px; white-space:nowrap; }
|
||
.ang.unsicher { background:var(--warn-bg); }
|
||
.ang.unsicher:hover { background:var(--warn-bg); filter:brightness(.99); }
|
||
.badge { display:inline-block; font-size:11px; background:var(--warn-bg); color:var(--warn);
|
||
border:1px solid var(--warn-line); border-radius:6px; padding:1px 8px; margin-left:8px; font-weight:700; }
|
||
.leer { padding:var(--s4) var(--s6); color:var(--muted); font-style:italic; font-size:14px; }
|
||
|
||
/* Quelle: sichtbar lassen, nur schöner (kein Roh-String mehr) */
|
||
.quelle { display:inline-flex; align-items:center; gap:4px; font-size:11.5px; color:var(--muted);
|
||
background:var(--panel-3); border:1px solid var(--line); border-radius:6px; padding:1px 7px; font-weight:600; }
|
||
|
||
/* Kategorie-Chip / Korrektur-Anker — jetzt ein echter Button */
|
||
.gchip { display:inline-flex; align-items:center; font-size:11.5px; color:var(--muted);
|
||
background:var(--panel-2); border:1px solid var(--line); border-radius:6px; padding:2px 9px; margin-left:8px;
|
||
cursor:pointer; vertical-align:middle; font-weight:600; min-height:0; }
|
||
.gchip:hover { border-color:var(--brand); color:var(--brand-text); background:var(--brand-weak); filter:none; transform:none; box-shadow:none; }
|
||
.gchip:focus-visible { box-shadow:var(--ring); }
|
||
.ang.unsicher .gchip { border-color:var(--warn-line); background:var(--warn-bg); color:var(--warn); }
|
||
.ksel { font-size:13px; padding:6px 8px; border:1.5px solid var(--brand); border-radius:8px; margin-left:8px;
|
||
vertical-align:middle; background:var(--panel); color:var(--ink); min-height:0; }
|
||
|
||
footer.note { margin-top:var(--s6); padding-top:var(--s4); border-top:1px solid var(--line);
|
||
color:var(--muted); font-size:13px; }
|
||
footer.note .haendler { color:var(--ink-2); }
|
||
footer.note .hinweis { display:block; margin-top:6px; font-style:italic; }
|
||
.sep { color:var(--muted); }
|
||
|
||
/* sr-only für reine Screenreader-Hinweise */
|
||
.sr-only { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden;
|
||
clip:rect(0,0,0,0); white-space:nowrap; border:0; }
|
||
|
||
@media (max-width:640px) {
|
||
.intro { padding-top:var(--s6); }
|
||
.wrap { padding-left:var(--s4); padding-right:var(--s4); }
|
||
.appbar-inner { padding:0 var(--s4); }
|
||
.appbar .brand span.word { display:none; } /* nur Logo auf schmalem Schirm */
|
||
li.ang { padding-left:var(--s4); padding-right:var(--s4); }
|
||
.grp-h-btn { padding-left:var(--s4); padding-right:var(--s4); }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<a class="sr-only" href="#stage1">Zum Inhalt springen</a>
|
||
|
||
<header class="appbar">
|
||
<div class="appbar-inner">
|
||
<a class="brand" href="#" aria-label="MABOTO — Marktbeobachtungstool, zum Anfang">
|
||
<span class="logo" aria-hidden="true">
|
||
<svg viewBox="0 0 24 24" width="21" height="21" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||
<rect x="6.2" y="9.0" width="1.7" height="3.6" rx="0.5" fill="#fff"/>
|
||
<rect x="8.1" y="7.2" width="1.7" height="5.4" rx="0.5" fill="#fff"/>
|
||
<rect x="10.0" y="5.4" width="1.7" height="7.2" rx="0.5" fill="#fff"/>
|
||
<circle cx="9.2" cy="9.2" r="6.1" fill="none" stroke="#fff" stroke-width="1.9"/>
|
||
<path d="M13.7 13.7 L18.6 18.6" stroke="#fff" stroke-width="2.4" stroke-linecap="round"/>
|
||
</svg>
|
||
</span>
|
||
<span class="word">MABOTO<small>Marktbeobachtung</small></span>
|
||
</a>
|
||
<span class="place leer" id="appbarPlz" aria-live="polite">PLZ …</span>
|
||
<span class="spacer"></span>
|
||
<button class="iconbtn" id="appbarSettings" type="button" title="LLM-Konfiguration" aria-label="LLM-Konfiguration öffnen">⚙</button>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="intro">
|
||
<p class="eyebrow">MABOTO · ortskonkret · händlerübergreifend · belegt</p>
|
||
<h1>MABOTO</h1>
|
||
<p><b>Marktbeobachtungstool.</b> Zwei strikt getrennte Stufen: zuerst die Rohdaten
|
||
<b>deterministisch</b> holen und speichern (kein LLM, kein Key), danach erst per
|
||
LLM in Produktgruppen einordnen. Jedes Angebot ist belegt — kein Auffüllen,
|
||
Unsicheres ist markiert.</p>
|
||
</div>
|
||
|
||
<div class="wrap">
|
||
<div class="stages">
|
||
|
||
<!-- ============ STUFE 1 ============ -->
|
||
<section class="stage" id="stage1">
|
||
<div class="stage-head">
|
||
<div class="stage-num" aria-hidden="true">1</div>
|
||
<div class="stage-titles">
|
||
<h2>Rohdaten holen & speichern</h2>
|
||
<p class="sub">Deterministischer Abruf für eine PLZ. Wird pro PLZ/Woche auf Platte gespeichert.</p>
|
||
</div>
|
||
<div class="stage-flag">deterministisch · kein LLM</div>
|
||
</div>
|
||
<div class="stage-body">
|
||
<div class="row">
|
||
<div class="field">
|
||
<label for="plz">PLZ</label>
|
||
<input id="plz" type="text" value="60487" inputmode="numeric" maxlength="5" autocomplete="postal-code" />
|
||
</div>
|
||
<div class="field">
|
||
<label for="rohholen"> </label>
|
||
<button id="rohholen" type="button">Rohdaten holen ▶</button>
|
||
</div>
|
||
</div>
|
||
<div id="rohstatus" role="status" aria-live="polite"></div>
|
||
<div id="rohstand"></div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ============ LLM-KONFIG ============ -->
|
||
<details class="config" id="config">
|
||
<summary>
|
||
<span class="chev" aria-hidden="true">▸</span>
|
||
<span class="ico" aria-hidden="true">⚙</span>
|
||
<span>LLM-Konfiguration</span>
|
||
<span class="gewaehlt-chip" id="gewaehltChip" title="aktuell gewähltes Modell">—</span>
|
||
<span class="gilt">gilt für Stufe 2</span>
|
||
</summary>
|
||
<div class="config-body">
|
||
<div class="row">
|
||
<div class="field">
|
||
<label for="anbieter">Anbieter</label>
|
||
<select id="anbieter">
|
||
<option value="openrouter">OpenRouter (Cloud)</option>
|
||
<option value="ollama">Ollama (lokal)</option>
|
||
</select>
|
||
</div>
|
||
<div class="field">
|
||
<label for="modell">Modell</label>
|
||
<select id="modell"><option>lädt…</option></select>
|
||
</div>
|
||
<div class="field" id="suchfeld">
|
||
<label for="modellsuche">Modell suchen</label>
|
||
<input id="modellsuche" type="text" placeholder="z. B. qwen" />
|
||
</div>
|
||
<div class="field">
|
||
<label for="refresh"> </label>
|
||
<button id="refresh" class="ghost icon" type="button" title="Modell-Liste aktualisieren">↻ aktualisieren</button>
|
||
</div>
|
||
</div>
|
||
<div class="row" style="margin-top:var(--s4)" id="keyzeile">
|
||
<div class="field keyrow">
|
||
<label for="key">API-Key (bleibt lokal, nur an deinen Server)</label>
|
||
<input id="key" type="password" placeholder="OPENROUTER_API_KEY — optional, falls nicht in der Server-Umgebung" autocomplete="off" />
|
||
</div>
|
||
</div>
|
||
<p class="hint" id="konfighint">Der Key wird ausschließlich für Stufe 2 verwendet und nie in Stufe 1 (Datenabruf) eingesetzt.</p>
|
||
</div>
|
||
</details>
|
||
|
||
<!-- ============ STUFE 2 ============ -->
|
||
<section class="stage locked" id="stage2">
|
||
<div class="stage-head">
|
||
<div class="stage-num" aria-hidden="true">2</div>
|
||
<div class="stage-titles">
|
||
<h2>Kategorisieren</h2>
|
||
<p class="sub">Ordnet die gespeicherten Rohdaten per LLM in Produktgruppen. Verändert keine Angebotsdaten.</p>
|
||
</div>
|
||
<div class="stage-flag llm">LLM-Schritt</div>
|
||
</div>
|
||
<div class="stage-body">
|
||
<div class="row">
|
||
<div class="field">
|
||
<label for="kategorisieren"> </label>
|
||
<button id="kategorisieren" type="button" disabled>Kategorisieren ▶</button>
|
||
</div>
|
||
</div>
|
||
<div class="hint lock" id="lockhint">🔒 Erst aktiv, sobald für die PLZ Rohdaten gespeichert sind (Stufe 1).</div>
|
||
<div id="status" role="status" aria-live="polite"></div>
|
||
</div>
|
||
</section>
|
||
|
||
</div>
|
||
|
||
<!-- ============ ERGEBNIS ============ -->
|
||
<div id="result" hidden>
|
||
<header class="ergebnis-header">
|
||
<div class="summary" id="summary"></div>
|
||
<div class="filters">
|
||
<div class="field"><label for="fhaendler">Händler</label><select id="fhaendler"><option value="">alle</option></select></div>
|
||
<div class="field"><label for="ftext">Angebot suchen</label><input id="ftext" type="text" placeholder="Titel…" /></div>
|
||
<div class="field check"><input id="fsicher" type="checkbox" /><label for="fsicher">nur sichere</label></div>
|
||
</div>
|
||
<nav id="gruppennav" class="gruppenband" aria-label="Produktgruppen — zum Abschnitt springen"></nav>
|
||
<div id="korrektur-feedback" class="kfeedback" role="status" aria-live="polite"></div>
|
||
</header>
|
||
<div id="gruppen"></div>
|
||
<footer class="note" id="footer"></footer>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const $ = s => document.querySelector(s);
|
||
let DATEN = null; // kategorisiertes Ergebnis (Stufe 2)
|
||
let ROHDA = false; // ob für die aktuelle PLZ Rohdaten vorliegen
|
||
|
||
// Pfad-Basis: lokal "/", hinter einem Reverse-Proxy z.B. "/angebote/".
|
||
const BASE = location.pathname.endsWith("/")
|
||
? location.pathname
|
||
: location.pathname.replace(/[^/]*$/, "");
|
||
const api = p => BASE + String(p).replace(/^\//, "");
|
||
|
||
function esc(s){ return (s==null?"":String(s)).replace(/[&<>"]/g,c=>({"&":"&","<":"<",">":">",'"':"""}[c])); }
|
||
function preisFmt(p){ return p!=null ? p.toLocaleString("de-DE",{minimumFractionDigits:2,maximumFractionDigits:2})+" €" : "Preis fehlt"; }
|
||
function setRohStatus(html){ $("#rohstatus").innerHTML = html; }
|
||
function setStatus(html){ $("#status").innerHTML = html; }
|
||
|
||
// App-Bar: aktuelle PLZ spiegeln (gibt App-Gefühl + Orientierung)
|
||
function setAppbarPlz(){
|
||
const v = ($("#plz").value || "").trim();
|
||
const el = $("#appbarPlz");
|
||
if (/^\d{5}$/.test(v)){ el.textContent = "📍 " + v; el.classList.remove("leer"); }
|
||
else { el.textContent = "PLZ wählen"; el.classList.add("leer"); }
|
||
}
|
||
|
||
// ----- Modelle (LLM-Konfig: OpenRouter ODER Ollama) -------------------------
|
||
function anbieter(){ return $("#anbieter").value; }
|
||
|
||
function merkeWahl(){
|
||
try {
|
||
localStorage.setItem("ang_anbieter", anbieter());
|
||
const m = $("#modell").value;
|
||
if (m) localStorage.setItem("ang_modell_" + anbieter(), m);
|
||
} catch(e){}
|
||
}
|
||
function gemerkterAnbieter(){ try { return localStorage.getItem("ang_anbieter"); } catch(e){ return null; } }
|
||
function gemerktesModell(ab){ try { return localStorage.getItem("ang_modell_" + ab); } catch(e){ return null; } }
|
||
|
||
function setGewaehlt(){
|
||
const sel = $("#modell");
|
||
const opt = sel.options[sel.selectedIndex];
|
||
const m = (sel.value && opt && !opt.disabled) ? sel.value : null;
|
||
const ab = anbieter() === "ollama" ? "Ollama (lokal)" : "OpenRouter";
|
||
$("#gewaehltChip").textContent = m ? `${m} · ${ab}` : `kein Modell · ${ab}`;
|
||
merkeWahl();
|
||
}
|
||
|
||
async function ladeModelle(q=""){
|
||
const sel = $("#modell");
|
||
const ab = anbieter();
|
||
$("#keyzeile").style.display = ab === "ollama" ? "none" : "";
|
||
$("#suchfeld").style.display = ab === "ollama" ? "none" : "";
|
||
$("#konfighint").innerHTML = ab === "ollama"
|
||
? "Lokale Modelle über Ollama (localhost:11434) — kein Key, kein Netz. <b>Achtung:</b> kleine Modelle (3–9 B) liefern für diese Batch-Aufgabe oft kein zuverlässiges Tool-Calling — dann wird ehrlich alles als „Sonstiges/unsicher“ markiert statt geraten. Für brauchbare Ergebnisse ein starkes tool-Modell oder OpenRouter nutzen."
|
||
: "Der Key wird ausschließlich für Stufe 2 verwendet und nie in Stufe 1 (Datenabruf) eingesetzt.";
|
||
const url = ab === "ollama" ? "/api/ollama-modelle" : ("/api/modelle?q=" + encodeURIComponent(q));
|
||
sel.innerHTML = "<option>lädt…</option>";
|
||
try {
|
||
const r = await fetch(api(url));
|
||
if (!r.ok) throw new Error((await r.json()).detail || r.status);
|
||
const liste = await r.json();
|
||
sel.innerHTML = "";
|
||
if (!liste.length){
|
||
sel.innerHTML = ab === "ollama"
|
||
? "<option value=''>(kein lokales Modell — Ollama gestartet? 'ollama serve')</option>"
|
||
: "<option value=''>(keine Treffer)</option>";
|
||
setGewaehlt(); return;
|
||
}
|
||
for (const m of liste){
|
||
const o = document.createElement("option");
|
||
o.value = m.id;
|
||
o.dataset.frei = m.frei ? "1" : "0";
|
||
o.dataset.empf = m.empfohlen ? "1" : "0";
|
||
let tag;
|
||
if (ab === "ollama") tag = "lokal";
|
||
else if (m.empfohlen) tag = "★ empfohlen";
|
||
else tag = m.frei ? "FREE · oft gedrosselt" : "paid";
|
||
const tools = m.tools ? "" : " ⚠ kein tool-calling";
|
||
o.textContent = `${m.id} [${tag}]${tools}`;
|
||
if (!m.tools) o.disabled = true;
|
||
sel.appendChild(o);
|
||
}
|
||
const opts = [...sel.options];
|
||
const gemerkt = gemerktesModell(ab);
|
||
const gemerktOpt = gemerkt ? opts.find(o => o.value === gemerkt && !o.disabled) : null;
|
||
const empfOpt = opts.find(o => o.dataset.empf === "1" && !o.disabled);
|
||
if (gemerktOpt && !(ab !== "ollama" && gemerktOpt.dataset.frei === "1")){
|
||
sel.value = gemerktOpt.value;
|
||
} else if (empfOpt){
|
||
sel.value = empfOpt.value;
|
||
} else {
|
||
const ok = opts.find(o => !o.disabled && o.value);
|
||
if (ok) sel.value = ok.value;
|
||
}
|
||
setGewaehlt();
|
||
} catch (e){
|
||
sel.innerHTML = `<option value=''>Modelle nicht abrufbar</option>`;
|
||
setGewaehlt();
|
||
}
|
||
}
|
||
|
||
// ----- Stufe 1: Rohdaten holen / prüfen -------------------------------------
|
||
function lockStage2(grund){
|
||
ROHDA = false;
|
||
$("#kategorisieren").disabled = true;
|
||
$("#stage2").classList.add("locked");
|
||
$("#lockhint").style.display = "flex";
|
||
if (grund) $("#lockhint").innerHTML = "🔒 " + grund;
|
||
}
|
||
function unlockStage2(){
|
||
ROHDA = true;
|
||
$("#kategorisieren").disabled = false;
|
||
$("#stage2").classList.remove("locked");
|
||
$("#lockhint").style.display = "none";
|
||
}
|
||
|
||
function zeigeRohstand(d, frisch){
|
||
const dt = d.abgerufen_am ? new Date(d.abgerufen_am).toLocaleString("de-DE") : "—";
|
||
const tags = (d.haendler||[]).map(h => `<span class="tag">${esc(h)}</span>`).join("");
|
||
const zeilen = (d.angebote||[]).map(a => `
|
||
<tr>
|
||
<td>${a.marke ? `<span class="marke">${esc(a.marke)}</span> ` : ""}${esc(a.titel)}${a.menge?` <span class="t-h">· ${esc(a.menge)}</span>`:""}</td>
|
||
<td class="t-h">${esc(a.haendler)}</td>
|
||
<td class="t-preis">${preisFmt(a.preis)}</td>
|
||
</tr>`).join("");
|
||
$("#rohstand").innerHTML = `
|
||
<div class="rohstand ok">
|
||
<div class="line1">
|
||
<span class="ok-dot">✓ Rohdaten vorhanden</span>
|
||
<span>für PLZ <b>${esc(d.plz)}</b>${d.ort_name?` (${esc(d.ort_name)})`:""}</span>
|
||
<span class="meta">— <b>${d.anzahl}</b> Angebote · <b>${(d.haendler||[]).length}</b> Händler · geholt am ${esc(dt)}${frisch?" (gerade aktualisiert)":""}</span>
|
||
</div>
|
||
<div class="haendler-tags">${tags}</div>
|
||
${(d.angebote||[]).length ? `
|
||
<details class="rohliste">
|
||
<summary>Belegte Rohliste anzeigen (${d.angebote.length}) — ungruppiert</summary>
|
||
<table class="rohtable">
|
||
<thead><tr><th>Artikel</th><th>Händler</th><th style="text-align:right">Preis</th></tr></thead>
|
||
<tbody>${zeilen}</tbody>
|
||
</table>
|
||
</details>` : `<div class="hint">Quellen liefen, lieferten aber 0 Angebote — kein Auffüllen.</div>`}
|
||
</div>`;
|
||
unlockStage2();
|
||
}
|
||
|
||
async function pruefeRohstand(plz){
|
||
$("#rohstand").innerHTML = "";
|
||
lockStage2();
|
||
if (!/^\d{5}$/.test(plz)) return;
|
||
try {
|
||
const r = await fetch(api("/api/rohdaten/" + encodeURIComponent(plz)));
|
||
if ($("#plz").value.trim() !== plz) return;
|
||
if (r.status === 404) { lockStage2("Noch keine Rohdaten für diese PLZ — Stufe 1 ausführen."); return; }
|
||
if (!r.ok) return;
|
||
const d = await r.json();
|
||
if ($("#plz").value.trim() !== plz) return;
|
||
zeigeRohstand(d, false);
|
||
} catch {}
|
||
}
|
||
|
||
async function holeRohdaten(){
|
||
const plz = $("#plz").value.trim();
|
||
if (!/^\d{5}$/.test(plz)) { setRohStatus(`<div class="err">Bitte eine 5-stellige PLZ eingeben.</div>`); return; }
|
||
$("#rohholen").disabled = true;
|
||
$("#result").hidden = true; DATEN = null;
|
||
setRohStatus(`<div class="pending">Hole Angebote für <b>${esc(plz)}</b> … (deterministisch, ohne LLM)</div>`);
|
||
try {
|
||
const r = await fetch(api("/api/rohdaten"), {
|
||
method:"POST", headers:{"Content-Type":"application/json"},
|
||
body: JSON.stringify({ plz })
|
||
});
|
||
const d = await r.json();
|
||
if (!r.ok) throw new Error(d.detail || r.status);
|
||
setRohStatus("");
|
||
zeigeRohstand(d, true);
|
||
} catch (e){
|
||
setRohStatus(`<div class="err">${esc(e.message)}</div>`);
|
||
lockStage2();
|
||
} finally {
|
||
$("#rohholen").disabled = false;
|
||
}
|
||
}
|
||
|
||
// ----- Stufe 2: Kategorisieren ----------------------------------------------
|
||
function llmBusy(titel, done, total, modell){
|
||
const indet = !total;
|
||
const pct = indet ? 0 : Math.round(100 * done / total);
|
||
return `<div class="llm-busy ${indet ? "indet" : ""}" aria-busy="true">
|
||
<div class="spin" role="status" aria-label="Das Sprachmodell arbeitet"></div>
|
||
<div class="txt">
|
||
<span class="hl">🤖 ${esc(titel)}</span>${total ? ` · <b>Batch ${done}/${total}</b>` : ""}
|
||
<small>${modell ? "Modell: " + esc(modell) : "das Sprachmodell ordnet die Angebote ein …"}</small>
|
||
<div class="bar2"><i style="width:${pct}%"></i></div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
async function kategorisiere(){
|
||
if (!ROHDA) return;
|
||
const plz = $("#plz").value.trim();
|
||
const btn = $("#kategorisieren");
|
||
const btnText = btn.dataset.label || btn.textContent;
|
||
btn.dataset.label = btnText;
|
||
const modellName = $("#modell").value;
|
||
btn.disabled = true; btn.textContent = "⏳ LLM arbeitet …";
|
||
$("#result").hidden = true;
|
||
setStatus(llmBusy("LLM startet die Kategorisierung …", 0, 0, modellName));
|
||
|
||
let job;
|
||
try {
|
||
const r = await fetch(api("/api/kategorisieren"), {
|
||
method:"POST", headers:{"Content-Type":"application/json"},
|
||
body: JSON.stringify({
|
||
plz, modell: modellName, anbieter: anbieter(),
|
||
key: anbieter()==="openrouter" ? ($("#key").value || undefined) : undefined
|
||
})
|
||
});
|
||
const d = await r.json();
|
||
if (!r.ok) throw new Error(d.detail || r.status);
|
||
job = d.job_id;
|
||
} catch (e){
|
||
setStatus(`<div class="err">${esc(e.message)}</div>`);
|
||
btn.disabled = false; btn.textContent = btnText; return;
|
||
}
|
||
|
||
const poll = setInterval(async () => {
|
||
let s;
|
||
try { s = await (await fetch(api("/api/lauf/" + job))).json(); }
|
||
catch { return; }
|
||
if (s.status === "laufend"){
|
||
setStatus(llmBusy("LLM kategorisiert …", s.done || 0, s.total || 0, modellName));
|
||
return;
|
||
}
|
||
clearInterval(poll);
|
||
btn.disabled = false; btn.textContent = btnText;
|
||
if (s.status === "fehler"){ setStatus(`<div class="err">${esc(s.fehler)}</div>`); return; }
|
||
setStatus("");
|
||
DATEN = s.ergebnis;
|
||
render();
|
||
$("#result").scrollIntoView({behavior:"smooth", block:"start"});
|
||
}, 1000);
|
||
}
|
||
|
||
// ----- Ergebnis-Rendering ----------------------------------------------------
|
||
function render(){
|
||
const d = DATEN;
|
||
$("#result").hidden = false;
|
||
const modellTxt = d.modell
|
||
? ` <span class="sep">·</span> kategorisiert mit <b>${esc(d.modell)}</b>${d.anbieter?` <span class="t-h">(${esc(d.anbieter)})</span>`:""}`
|
||
: "";
|
||
const cacheTxt = (d.aus_cache != null)
|
||
? ` <span class="sep">·</span> <b>${d.aus_cache}</b> aus Cache · <b>${d.neu}</b> neu` : "";
|
||
$("#summary").innerHTML =
|
||
`Ort <b>${esc(d.ort_name || d.ort_plz)}</b> (PLZ ${esc(d.ort_plz)}) <span class="sep">·</span> <b>${d.anzahl}</b> Angebote <span class="sep">·</span>
|
||
Quellen: ${d.quellen.map(esc).join(", ") || "—"} <span class="sep">·</span> <b>${d.unsicher}</b> unsicher${cacheTxt}${modellTxt}`;
|
||
|
||
const fh = $("#fhaendler");
|
||
const aktuell = fh.value;
|
||
fh.innerHTML = "<option value=''>alle</option>" + d.haendler.map(h => `<option>${esc(h)}</option>`).join("");
|
||
fh.value = aktuell;
|
||
|
||
zeichneGruppen();
|
||
|
||
$("#footer").innerHTML =
|
||
`<span class="haendler"><b>Beobachtete Händler (belegt):</b> ${d.haendler.map(esc).join(", ")}.</span>` +
|
||
d.hinweise.map(h => `<span class="hinweis">${esc(h)}</span>`).join("");
|
||
}
|
||
|
||
function passt(a, fH, fT, nurSicher){
|
||
return (!fH || a.haendler === fH) &&
|
||
(!nurSicher || !a.unsicher) &&
|
||
(!fT || (a.titel + " " + (a.marke||"")).toLowerCase().includes(fT));
|
||
}
|
||
|
||
function zeichneGruppen(){
|
||
const d = DATEN;
|
||
const fH = $("#fhaendler").value;
|
||
const fT = $("#ftext").value.trim().toLowerCase();
|
||
const nurSicher = $("#fsicher").checked;
|
||
const box = $("#gruppen");
|
||
box.innerHTML = "";
|
||
const navDaten = [];
|
||
|
||
for (const g of d.gruppen){
|
||
const items = g.angebote.filter(a => passt(a, fH, fT, nurSicher));
|
||
const sec = document.createElement("section");
|
||
sec.className = "grp";
|
||
sec.dataset.grp = g.name;
|
||
const secId = "grp-" + g.name.replace(/[^a-z0-9]+/gi, "-").toLowerCase();
|
||
sec.id = secId;
|
||
|
||
const h = document.createElement("h3");
|
||
h.className = "grp-h";
|
||
const btn = document.createElement("button");
|
||
btn.type = "button";
|
||
btn.className = "grp-h-btn";
|
||
btn.setAttribute("aria-expanded", "true");
|
||
btn.innerHTML =
|
||
`<span class="left"><span class="dot" aria-hidden="true"></span>${esc(g.name)}</span>` +
|
||
`<span class="cnt">${items.length}<span class="chev" aria-hidden="true">▾</span></span>`;
|
||
btn.onclick = () => {
|
||
const zu = sec.classList.toggle("zu");
|
||
btn.setAttribute("aria-expanded", zu ? "false" : "true");
|
||
};
|
||
h.appendChild(btn);
|
||
sec.appendChild(h);
|
||
|
||
if (!items.length){
|
||
const p = document.createElement("div"); p.className = "leer";
|
||
p.textContent = (fH || fT || nurSicher) ? "keine Treffer im Filter" : "keine Angebote";
|
||
sec.appendChild(p);
|
||
} else {
|
||
const ul = document.createElement("ul");
|
||
for (const a of items) ul.appendChild(zeile(a, g.name));
|
||
sec.appendChild(ul);
|
||
navDaten.push({ name:g.name, count:items.length, uns:items.filter(a=>a.unsicher).length });
|
||
}
|
||
box.appendChild(sec);
|
||
}
|
||
baueGruppenband(navDaten);
|
||
}
|
||
|
||
function baueGruppenband(navDaten){
|
||
const nav = $("#gruppennav");
|
||
nav.innerHTML = "";
|
||
if (!navDaten.length){ nav.innerHTML = `<span class="navitem" style="cursor:default;color:var(--muted)">keine Treffer</span>`; return; }
|
||
for (const g of navDaten){
|
||
const b = document.createElement("button");
|
||
b.type = "button";
|
||
b.className = "navitem"; b.dataset.grp = g.name;
|
||
const uns = g.uns ? ` <span class="nav-uns" title="${g.uns} unsicher">${g.uns}</span>` : "";
|
||
b.innerHTML = `<span>${esc(g.name)}</span><span class="nav-cnt">${g.count}</span>${uns}`;
|
||
b.onclick = () => springeZu(g.name);
|
||
nav.appendChild(b);
|
||
}
|
||
}
|
||
|
||
function springeZu(name){
|
||
let ziel = null;
|
||
for (const sec of document.querySelectorAll("#gruppen section.grp")){
|
||
if (sec.dataset.grp === name){ ziel = sec; break; }
|
||
}
|
||
if (ziel){
|
||
ziel.classList.remove("zu");
|
||
const btn = ziel.querySelector(".grp-h-btn");
|
||
if (btn) btn.setAttribute("aria-expanded", "true");
|
||
ziel.scrollIntoView({behavior:"smooth", block:"start"});
|
||
}
|
||
for (const b of document.querySelectorAll(".navitem")){
|
||
const aktiv = b.dataset.grp === name;
|
||
b.classList.toggle("aktiv", aktiv);
|
||
if (aktiv) b.setAttribute("aria-current", "true"); else b.removeAttribute("aria-current");
|
||
}
|
||
}
|
||
|
||
function zeile(a, gruppe){
|
||
const li = document.createElement("li");
|
||
li.className = "ang" + (a.unsicher ? " unsicher" : "");
|
||
const marke = a.marke ? `<span class="marke">${esc(a.marke)}</span> ` : "";
|
||
const menge = a.menge ? ` <span class="sep">·</span> ${esc(a.menge)}` : "";
|
||
const gueltig = (a.gueltig_von || a.gueltig_bis)
|
||
? ` <span class="sep">·</span> gültig ${a.gueltig_von||"?"}–${a.gueltig_bis||"?"}` : "";
|
||
const badge = a.unsicher ? `<span class="badge">unsicher</span>` : "";
|
||
// Quelle: sichtbar lassen, aber als ruhiges Badge mit Quellen-Namen (statt Roh-URL)
|
||
const qName = (a.quelle||"").split(/[:/?#]/)[0] || "Quelle";
|
||
const quelleBadge = a.quelle
|
||
? `<span class="quelle" title="${esc(a.quelle)}">🔗 ${esc(qName)}</span>` : "";
|
||
li.innerHTML =
|
||
`<div class="name">${marke}${esc(a.titel)}${badge}` +
|
||
`<button type="button" class="gchip" title="Produktgruppe ändern" aria-label="Produktgruppe von „${esc(a.titel)}“ ändern (aktuell ${esc(gruppe)})">${esc(gruppe)}</button></div>` +
|
||
`<div class="preis">${preisFmt(a.preis)}</div>` +
|
||
`<div class="meta"><span class="haendler">${esc(a.haendler)}</span>${menge}${gueltig} ${quelleBadge}</div>` +
|
||
`<div class="grund">${a.grundpreis ? esc(a.grundpreis) : ""}</div>`;
|
||
const chip = li.querySelector(".gchip");
|
||
chip.onclick = () => oeffneKorrektur(chip, a, gruppe);
|
||
return li;
|
||
}
|
||
|
||
// ----- Korrektur: Produktgruppe manuell setzen -> Produkt-Cache --------------
|
||
function oeffneKorrektur(chip, a, vonGruppe){
|
||
const sel = document.createElement("select");
|
||
sel.className = "ksel";
|
||
sel.setAttribute("aria-label", "Neue Produktgruppe wählen");
|
||
for (const g of DATEN.gruppen.map(x => x.name)){
|
||
const o = document.createElement("option");
|
||
o.value = g; o.textContent = g;
|
||
if (g === vonGruppe) o.selected = true;
|
||
sel.appendChild(o);
|
||
}
|
||
chip.replaceWith(sel);
|
||
sel.focus();
|
||
let fertig = false;
|
||
sel.onchange = () => { fertig = true; korrigiere(a, vonGruppe, sel.value); };
|
||
sel.onblur = () => { setTimeout(() => { if (!fertig && DATEN) render(); }, 150); };
|
||
}
|
||
|
||
async function korrigiere(a, vonGruppe, zielGruppe){
|
||
if (zielGruppe === vonGruppe){ render(); return; }
|
||
try {
|
||
const r = await fetch(api("/api/korrektur"), {
|
||
method:"POST", headers:{"Content-Type":"application/json"},
|
||
body: JSON.stringify({ titel:a.titel, marke:a.marke, gruppe:zielGruppe, plz:$("#plz").value.trim() })
|
||
});
|
||
if (!r.ok){ throw new Error((await r.json()).detail || r.status); }
|
||
const von = DATEN.gruppen.find(g => g.name === vonGruppe);
|
||
const ziel = DATEN.gruppen.find(g => g.name === zielGruppe);
|
||
if (von){ von.angebote = von.angebote.filter(x => x !== a); von.anzahl = von.angebote.length; }
|
||
a.unsicher = false;
|
||
if (ziel){ ziel.angebote.push(a); ziel.anzahl = ziel.angebote.length; }
|
||
DATEN.unsicher = DATEN.gruppen.reduce((s,g) => s + g.angebote.filter(x=>x.unsicher).length, 0);
|
||
render();
|
||
zeigeFeedback(`✓ „${a.titel}" → ${zielGruppe} gemerkt (auch im Cache)`);
|
||
} catch(e){
|
||
render();
|
||
zeigeFeedback(`Korrektur fehlgeschlagen: ${e.message}`, true);
|
||
}
|
||
}
|
||
|
||
function zeigeFeedback(txt, fehler){
|
||
const el = $("#korrektur-feedback");
|
||
el.textContent = txt;
|
||
el.style.color = fehler ? "var(--err)" : "var(--brand-text)";
|
||
el.classList.add("an");
|
||
clearTimeout(zeigeFeedback._t);
|
||
zeigeFeedback._t = setTimeout(() => el.classList.remove("an"), 2800);
|
||
}
|
||
|
||
// ----- Events ----------------------------------------------------------------
|
||
$("#rohholen").onclick = holeRohdaten;
|
||
$("#kategorisieren").onclick = kategorisiere;
|
||
$("#refresh").onclick = () => ladeModelle($("#modellsuche").value.trim());
|
||
$("#modellsuche").addEventListener("keydown", e => { if (e.key==="Enter") ladeModelle(e.target.value.trim()); });
|
||
$("#anbieter").addEventListener("change", () => ladeModelle());
|
||
$("#modell").addEventListener("change", setGewaehlt);
|
||
$("#plz").addEventListener("keydown", e => { if (e.key==="Enter") holeRohdaten(); });
|
||
$("#appbarSettings").onclick = () => {
|
||
const c = $("#config"); c.open = true;
|
||
c.scrollIntoView({behavior:"smooth", block:"center"});
|
||
c.querySelector("summary").focus();
|
||
};
|
||
let _plzTimer;
|
||
$("#plz").addEventListener("input", () => {
|
||
setAppbarPlz();
|
||
clearTimeout(_plzTimer);
|
||
const plz = $("#plz").value.trim();
|
||
_plzTimer = setTimeout(() => pruefeRohstand(plz), 350);
|
||
});
|
||
for (const id of ["#fhaendler","#ftext","#fsicher"])
|
||
$(id).addEventListener("input", () => DATEN && zeichneGruppen());
|
||
|
||
// ----- Init ------------------------------------------------------------------
|
||
(async function init(){
|
||
const _p = new URLSearchParams(location.search);
|
||
const ab = _p.get("anbieter") || gemerkterAnbieter();
|
||
if (ab) $("#anbieter").value = ab;
|
||
await ladeModelle();
|
||
const m = _p.get("modell");
|
||
if (m){
|
||
const sel = $("#modell");
|
||
if (![...sel.options].some(o => o.value === m)){
|
||
const o = document.createElement("option"); o.value = o.textContent = m;
|
||
sel.insertBefore(o, sel.firstChild);
|
||
}
|
||
sel.value = m; setGewaehlt();
|
||
}
|
||
if (_p.get("plz")) $("#plz").value = _p.get("plz").trim();
|
||
setAppbarPlz();
|
||
await pruefeRohstand($("#plz").value.trim());
|
||
if (_p.get("auto") && ROHDA) kategorisiere();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|