Fix: JS in externe app.js auslagern, dedupliziertes HTML bereinigen

This commit is contained in:
2026-06-04 08:10:56 +00:00
parent 0397ec573b
commit 737e7021fd
2 changed files with 298 additions and 297 deletions
+295
View File
@@ -0,0 +1,295 @@
const C24 = u => u; // check24 proxy urls already absolute
const REG = {
kroatien:{flag:'🇭🇷',name:'Kroatien',color:'var(--kro)',mk:'#e8643c'},
kanaren:{flag:'🇮🇨',name:'Kanaren',color:'var(--kan)',mk:'#f1a23b'},
madeira:{flag:'🇵🇹',name:'Madeira',color:'var(--mad)',mk:'#19a37a'},
kreuzfahrt:{flag:'🚢',name:'AIDA-Kreuzfahrt',color:'var(--cruise)',mk:'#2f7fd6'}
};
const OPTIONS = [
{id:'amadria',name:'Amadria Park Hotel Jakov',region:'kroatien',loc:'Šibenik · Norddalmatien',stars:4,
rate:'8,4',rlabel:'Sehr gut',bew:12,price:'ab 3.125 €',fav:true,geo:[43.698492,15.889556],
url:'https://urlaub.check24.de/suche/angebot?countryId=60&hotelId=12950&extendedSearch=1&airport=HAM,HAJ&transportType=flight&days=1w&departureDate=2026-07-11&returnDate=2026-07-21&roomAllocation=A-A-5&directFlight=1&hotelCategoryList=4,5&noRedirect=1',
chips:['Direkte Strandlage','Familienresort','Flughafen Split 37 km'],
why:[['ok','Krka-Nationalpark ~30 Min'],['ok','Kornati-Inseln per Boot ab Resort'],['ok','Shopping Split ~1 h / Šibenik 6 km'],['ok','Sehr kinderfreundlich']],
imgs:['https://cdn1.urlaub.check24.de/size=625c440/di=3/nfc=200/source=aHR0cHM6Ly9pLnRyYXZlbGFwaS5jb20vbG9kZ2luZy8zNDAwMDAwMC8zMzYzMDAwMC8zMzYyNTkwMC8zMzYyNTg1OC9iM2EyNzQxY193LmpwZw==!07717e/picture.jpg',
'https://cdn1.urlaub.check24.de/size=625c440/di=3/nfc=200/source=aHR0cHM6Ly9ob3RlbGltYWdlcy5zdW5ob3RlbHMubmV0L0hvdGVsSW5mby9ob3RlbEltYWdlLmFzcHg-aWQ9MTYxODE3MjUmZnVsbD0x!cc720d/picture.jpg']},
{id:'valamar',name:'Valamar Meteor Hotel',region:'kroatien',loc:'Makarska · Mitteldalmatien',stars:4,
rate:'8,4',rlabel:'Sehr gut',bew:128,price:'ab 3.531 €',fav:false,geo:[43.29907,17.014845],
url:'https://urlaub.check24.de/suche/angebot?countryId=60&hotelId=4619&extendedSearch=1&airport=HAM,HAJ&transportType=flight&days=1w&departureDate=2026-07-11&returnDate=2026-07-21&roomAllocation=A-A-5&directFlight=1&hotelCategoryList=4,5&noRedirect=1',
chips:['Top Hotel Award','Direkte Strandlage','Flughafen Split 64 km'],
why:[['ok','Top bewertet, Strand + Promenade'],['ok','Inselfähren Brač/Hvar via Split'],['no','Krka ~1h45, Plitvice 3h+'],['ok','Familientauglich']],
imgs:['https://cdn1.urlaub.check24.de/size=625c440/di=3/nfc=200/source=aHR0cDovL3Bob3Rvcy5ob3RlbGJlZHMuY29tL2dpYXRhL29yaWdpbmFsLzI1LzI1MzUxOS8yNTM1MTlhX2hiX3NfMDA1LmpwZw==!a9bbf6/picture.jpg',
'https://cdn1.urlaub.check24.de/size=625c440/di=3/nfc=200/source=aHR0cHM6Ly9pLnRyYXZlbGFwaS5jb20vbG9kZ2luZy84MDAwMDAwLzc3MDAwMDAvNzY5NzYwMC83Njk3NTMxLzhkMDY0YTM3X3cuanBn!7c92d2/picture.jpg',
'https://cdn1.urlaub.check24.de/size=625c440/di=3/nfc=200/source=aHR0cDovL21lZGlhLmRldi5wYXhpbXVtLmNvbS9ob3RlbGltYWdlcy8yNzA0MTgvNDcuanBn!8c2535/picture.jpg']},
{id:'bluesun',name:'Bluesun Hotel Berulia',region:'kroatien',loc:'Brela · Mitteldalmatien',stars:4,
rate:'8,4',rlabel:'Sehr gut',bew:13,price:'ab 4.706 €',fav:false,geo:[43.362448,16.938868],
url:'https://urlaub.check24.de/suche/angebot?countryId=60&hotelId=1739068&extendedSearch=1&airport=HAM,HAJ&transportType=flight&days=1w&departureDate=2026-07-11&returnDate=2026-07-21&roomAllocation=A-A-5&directFlight=1&hotelCategoryList=4,5&noRedirect=1',
chips:['Direkte Strandlage','Traumstrand Brela','Flughafen Split 55 km'],
why:[['ok','Einer der schönsten Strände Kroatiens'],['no','Krka ~1h302h, Plitvice zu weit'],['ok','Inseln via Split'],['no','Teuerste Hotel-Option']],
imgs:['https://cdn1.urlaub.check24.de/size=625c440/di=3/nfc=200/source=aHR0cHM6Ly9pLnRyYXZlbGFwaS5jb20vbG9kZ2luZy8zMDAwMDAwLzI4MjAwMDAvMjgxNDUwMC8yODE0NDA5LzZjYjI4NzAwX3cuanBn!774551/picture.jpg']},
{id:'morenia',name:'Morenia Beach Resort',region:'kroatien',loc:'Podaca · Mitteldalmatien',stars:4,
rate:'7,8',rlabel:'Gut',bew:48,price:'ab 3.527 €',fav:false,geo:[43.130196,17.286675],
url:'https://urlaub.check24.de/suche/angebot?hotelId=1859719&extendedSearch=1&airport=HAM,HAJ&transportType=flight&days=1w&departureDate=2026-07-11&returnDate=2026-07-21&roomAllocation=A-A-5&directFlight=1&hotelCategoryList=4,5&noRedirect=1',
chips:['Direkte Strandlage','Familie & Strand','Flughafen Split 92 km'],
why:[['ok','Ruhige Bucht, familienorientiert'],['no','Weit von Split & Nationalparks'],['no','Längster Flughafentransfer'],['ok','Solide Preis-Leistung']],
imgs:['https://cdn1.urlaub.check24.de/size=625c440/di=3/nfc=200/source=aHR0cHM6Ly9hcGktaW1nLmhvdGVsc3Rvbi5jb20vcmVzb3VyY2UvaG90ZWwvaW1hZ2VzLzcvMy84LzMvNy8yLzYvMy8xMDMxMzQzNTY0LmpwZw==!9db0ac/picture.jpg']},
{id:'tui',name:'TUI KIDS CLUB Taurito Princess',region:'kanaren',loc:'Taurito · Gran Canaria',stars:4,
rate:'8,0',rlabel:'Sehr gut',bew:400,price:'Preis auf Check24',fav:true,geo:[27.815366,-15.754307],
url:'https://urlaub.check24.de/suche/angebot?hotelId=3265&extendedSearch=1&airport=HAM,HAJ&transportType=flight&days=1w&departureDate=2026-07-11&returnDate=2026-07-21&roomAllocation=A-A-5&directFlight=1&hotelCategoryList=4,5&noRedirect=1',
chips:['Echter Kinderclub','Sommer mild ~26°C','Flughafen LPA 38 km'],
why:[['ok','Top für Felix (Betreuung & Animation)'],['ok','400 Bewertungen, sehr beliebt'],['no','Keine Kroatien-Wünsche (Insel/NP)'],['ok','Dünen & Berge für Ausflüge']],
imgs:['https://cdn1.urlaub.check24.de/size=625c440/di=3/nfc=200/source=aHR0cHM6Ly9pLnRyYXZlbGFwaS5jb20vbG9kZ2luZy85MDAwMDAwLzgxNjAwMDAvODE1ODAwMC84MTU3OTc5LzRjNjE3OTlkX3cuanBn!55230f/picture.jpg',
'https://cdn1.urlaub.check24.de/size=625c440/di=3/nfc=200/source=aHR0cDovL21lZGlhLmRldi5wYXhpbXVtLmNvbS9ob3RlbGltYWdlcy8yMjY0NTYvZDFhYzA3MGQ0OTg1Zjg3ZmI4YjM5OTA2MzAxNTMzZGQuanBn!c32a55/picture.jpg']},
{id:'iberostar',name:'Iberostar Waves Bouganville Playa',region:'kanaren',loc:'Playa de las Américas · Teneriffa',stars:4,
rate:'8,4',rlabel:'Sehr gut',bew:103,price:'Preis auf Check24',fav:false,geo:[28.074417,-16.732332],
url:'https://urlaub.check24.de/suche/angebot?hotelId=2134&extendedSearch=1&airport=HAM,HAJ&transportType=flight&days=1w&departureDate=2026-07-11&returnDate=2026-07-21&roomAllocation=A-A-5&directFlight=1&hotelCategoryList=4,5&noRedirect=1',
chips:['Am Meer','Sehr kurzer Transfer','Flughafen TFS 16 km'],
why:[['ok','Nur 16 km vom Flughafen'],['ok','Teide-Nationalpark als Ausflug'],['no','Keine Boots-/Inselkultur wie HR'],['ok','Gut bewertet']],
imgs:['https://cdn1.urlaub.check24.de/size=625c440/di=3/nfc=200/source=aHR0cDovL3Bob3Rvcy5ob3RlbGJlZHMuY29tL2dpYXRhL29yaWdpbmFsLzAwLzAwMTEzNi8wMDExMzZhX2hiX3RfMDExLmpwZw==!e1aee2/picture.jpg']},
{id:'riu',name:'Hotel Riu Madeira',region:'madeira',loc:'Caniço de Baixo · Madeira',stars:4,
rate:'8,4',rlabel:'Sehr gut',bew:752,price:'Preis auf Check24',fav:false,geo:[32.645679,-16.826868],
url:'https://urlaub.check24.de/suche/angebot?countryId=88&hotelId=8761&extendedSearch=1&airport=HAM,HAJ,BRE,RLG,LBC,GWT&transportType=flight&days=1w&departureDate=2026-07-12&returnDate=2026-07-22&roomAllocation=A-A-5&directFlight=1&hotelCategoryList=4,5&noRedirect=1',
chips:['Strand ~400 m','752 Bewertungen','Wander-Insel'],
why:[['ok','Sehr viele gute Bewertungen'],['no','Madeira = Wandern, kaum Sandstrand'],['no','Kein klassischer Badeurlaub für Felix'],['ok','Mild, grün, schöne Natur']],
imgs:['https://cdn1.urlaub.check24.de/size=625c440/di=3/nfc=200/source=aHR0cHM6Ly9pLnRyYXZlbGFwaS5jb20vbG9kZ2luZy81MDAwMDAwLzQ4MjAwMDAvNDgxMzQwMC80ODEzMzUxL2Y4ZjNkYTE3X3cuanBn!cb047b/picture.jpg',
'https://cdn1.urlaub.check24.de/size=625c440/di=3/nfc=200/source=aHR0cDovL3Bob3Rvcy5ob3RlbGJlZHMuY29tL2dpYXRhL29yaWdpbmFsLzAwLzAwNTUyOS8wMDU1MjlhX2hiX2FfMDIzLmpwZw==!406b07/picture.jpg']},
{id:'dreams',name:'Dreams Madeira Resort, Spa & Marina',region:'madeira',loc:'Caniçal · Madeira',stars:5,
rate:'8,4',rlabel:'Sehr gut',bew:111,price:'Preis auf Check24',fav:false,geo:[32.742452,-16.709186],
url:'https://urlaub.check24.de/suche/angebot?countryId=88&hotelId=30324&extendedSearch=1&airport=HAM,HAJ,BRE,RLG,LBC,GWT&transportType=flight&days=1w&departureDate=2026-07-12&returnDate=2026-07-22&roomAllocation=A-A-5&directFlight=1&hotelCategoryList=4,5&noRedirect=1',
chips:['5 Sterne','Direkte Strandlage','Marina & Spa'],
why:[['ok','Hochwertiges 5★-Resort'],['no','Ruhiger Osten eher Ruhe/Paar'],['no','Madeira kein Bade-Klassiker'],['ok','Schön für Astrid']],
imgs:['https://cdn1.urlaub.check24.de/size=625c440/di=3/nfc=200/source=aHR0cHM6Ly9pLnRyYXZlbGFwaS5jb20vbG9kZ2luZy83MDAwMDAwLzYxNTAwMDAvNjE0MTgwMC82MTQxNzIzLzI5YmZiMjE0X3cuanBn!da02c7/picture.jpg']},
{id:'sentido',name:'Sentido Galosol',region:'madeira',loc:'Caniço de Baixo · Madeira',stars:4,
rate:null,rlabel:null,bew:216,price:'Preis auf Check24',fav:false,geo:[32.642268,-16.831963],
url:'https://urlaub.check24.de/suche/angebot?countryId=88&hotelId=2205&extendedSearch=1&airport=HAM,HAJ,BRE,RLG,LBC,GWT&transportType=flight&days=1w&departureDate=2026-07-12&returnDate=2026-07-22&roomAllocation=A-A-5&directFlight=1&hotelCategoryList=4,5&noRedirect=1',
chips:['Top Lage','216 Bewertungen','Meerblick'],
why:[['ok','Beliebte Lage, Klippenbad'],['no','Kein Sandstrand'],['no','Eher ruhig, nicht kinderfokussiert'],['ok','Gutes Preisniveau']],
imgs:['https://cdn1.urlaub.check24.de/size=625c440/di=3/nfc=200/source=aHR0cHM6Ly9pLnRyYXZlbGFwaS5jb20vbG9kZ2luZy8xMDAwMDAwLzkzMDAwMC85MjI0MDAvOTIyMzE5Lzg3YjE3ODc3X3ouanBn!d36a31/picture.jpg']},
{id:'aida',name:'AIDA Cosma · Mediterrane Schätze mit Korsika',region:'kreuzfahrt',loc:'ab/bis Mallorca · 7 Nächte · 11.18.07.',stars:0,
rate:'4,8',rlabel:'Ahoi',bew:null,price:'ab 6.477 €',fav:true,geo:[39.5696,2.6502],
url:'https://www.ahoi-schiff.de/aida/routen/mediterrane-schaetze-mit-korsika-ab-mallorca/aidacosma-2026-07-11?paxe=2',
url2:'https://aida.de/buchen/CO07260711/CLASSIC/meine-reise/anreise',
chips:['4 Pers., 2 Balkonkabinen','inkl. Flug & Vollpension','La Spezia · Rom · Korsika · Barcelona'],
why:[['ok','Null Selbstfahren ideal für Astrid'],['ok','Fixer Komplettpreis, Vollpension'],['ok','Jeden Tag ein neues Ziel'],['no','Mit Felix (3) straffer Rhythmus']],
imgs:['https://files.ahoi-schiff.de/aida-cruises/s/aidacosma.webp']}
];
// AIDA route ports (for map polyline)
const ROUTE = [
{n:'Palma de Mallorca',c:[39.5696,2.6502]},
{n:'La Spezia / Florenz',c:[44.1025,9.8200]},
{n:'Rom / Civitavecchia',c:[42.0930,11.7896]},
{n:'Ajaccio (Korsika)',c:[41.9192,8.7386]},
{n:'Barcelona',c:[41.3568,2.1597]},
{n:'Palma de Mallorca',c:[39.5696,2.6502]}
];
const RECOS = [
{rank:'Empfehlung 1 · Rundum-Kroatien',opt:'amadria',
text:'Erfüllt alle Wünsche auf einmal: Krka-Nationalpark in ~30 Min, Kornati-Inseln per Boot, Shopping in Split und ein großes, kinderfreundliches Strand-Resort. Kürzeste Wege, größtes „für alle was dabei".',
price:'ab ~5.400 €',
lines:[['Pauschal (Flug HAM/HAJ + Hotel)','~4.700 €*'],['Mietwagen Kombi/SUV (10 T)','~650 €'],['Nationalparks/Inseln','Krka, Kornati, Plitvice']],
foot:'*für 4 Pers. hochgerechnet auf Check24 mit eurer Belegung prüfen.'},
{rank:'Empfehlung 2 · Entspannt ohne Fahren',opt:'aida',
text:'AIDA Cosma ab Mallorca: Florenz, Rom, Korsika und Barcelona ohne Koffer-Schleppen und ohne Mietwagen. Fixer All-in-Preis mit Vollpension, perfekt wenn Astrid mitkommt. Mit Felix etwas straffer Tagesrhythmus.',
price:'~6.480 €',
lines:[['4 Pers., 2 Balkonkabinen','inkl. Flug'],['Vollpension an Bord','inkl.'],['Mietwagen','nicht nötig (0 €)']],
foot:'LIGHT/CLASSIC ab Hannover 6.477 € / ab HH 6.4876.677 €. Landausflüge optional extra.'},
{rank:'Empfehlung 3 · Stressfrei mit Kind',opt:'tui',
text:'TUI KIDS CLUB auf Gran Canaria: der entspannteste Familienurlaub echter Kinderclub für Felix, mildes Sommerklima, kurzer Transfer. Ohne die Kroatien-Wünsche (Nationalpark/Insel), dafür maximal unkompliziert.',
price:'~4.5005.500 €',
lines:[['Pauschal (Flug + Hotel)','auf Check24 prüfen'],['Kinderclub für Felix','inkl.'],['Mietwagen optional','~500 € / 10 T']],
foot:'Preis grob für 4 Pers. Gran Canaria & Teneriffa beide in der Auswahl unten.'}
];
const byId = id => OPTIONS.find(o=>o.id===id);
const VOTER_LABEL = {till:'Till',lea:'Lea',astrid:'Astrid'};
let me_ = localStorage.getItem('voter') || null;
let STATE = {votes:{}};
/* ---------- hero ---------- */
document.getElementById('herobg').style.backgroundImage =
"url('"+byId('bluesun').imgs[0]+"')";
/* ---------- voter selector ---------- */
const whoEl = document.getElementById('who');
function renderWho(){
whoEl.innerHTML='';
['till','lea','astrid'].forEach(v=>{
const b=document.createElement('button');
b.className=(me_===v?'on '+v:'');
b.innerHTML=`<span class="av ${v}"></span>${VOTER_LABEL[v]}`;
b.onclick=()=>{me_=v;localStorage.setItem('voter',v);renderWho();renderAll();};
whoEl.appendChild(b);
});
}
/* ---------- recommendations ---------- */
function renderRecos(){
const el=document.getElementById('recos');el.innerHTML='';
RECOS.forEach(r=>{
const o=byId(r.opt);
const links=`<a class="btn" href="${o.url}" target="_blank" rel="noopener">${o.region==='kreuzfahrt'?'Ahoi-Schiff →':'Check24 →'}</a>`
+ (o.url2?`<a class="btn ghost" href="${o.url2}" target="_blank" rel="noopener">AIDA.de →</a>`:'')
+ `<a class="btn ghost" href="#${o.id}">Details ↓</a>`;
const d=document.createElement('div');d.className='reco';
d.innerHTML=`<div class="top"><div class="rank">${r.rank}</div><h3>${o.name.replace(' · Mediterrane Schätze mit Korsika','')}</h3><div class="place">📍 ${o.loc}</div></div>
<div class="body">
<p>${r.text}</p>
<div class="pricebox"><div class="big">${r.price}</div>
${r.lines.map(l=>`<div class="li"><span>${l[0]}</span><b>${l[1]}</b></div>`).join('')}
</div>
<div class="note">${r.foot}</div>
<div class="links">${links}</div>
</div>`;
el.appendChild(d);
});
}
/* ---------- regions + cards ---------- */
function avgFor(id){
let sum=0,n=0,per={};
for(const v of ['till','lea','astrid']){
const s=STATE.votes[v]&&STATE.votes[v][id];
if(s){sum+=s;n++;per[v]=s;}
}
return {avg:n?sum/n:0,n,per};
}
function cardHTML(o){
const imgs=o.imgs.map((s,i)=>`<img src="${s}" class="${i===0?'on':''}" loading="lazy" alt="${o.name}" onerror="this.remove()">`).join('');
const dots=o.imgs.length>1?`<div class="dots">${o.imgs.map((_,i)=>`<span class="dot ${i===0?'on':''}"></span>`).join('')}</div>`:'';
const nav=o.imgs.length>1?'<div class="nav l"></div><div class="nav r"></div>':'';
const rate=o.rate?`<span class="crate"><b>${o.rate}</b> ${o.rlabel}${o.bew?(' · '+o.bew+' Bew.'):''}</span>`:(o.bew?`<span class="crate">${o.bew} Bewertungen</span>`:'');
const linkLabel=o.region==='kreuzfahrt'?'Ahoi-Schiff →':'Auf Check24 →';
const link2=o.url2?`<a class="btn ghost" href="${o.url2}" target="_blank" rel="noopener" style="font-size:11.5px;padding:7px 10px">AIDA.de</a>`:'';
return `<div class="card ${o.fav?'fav':''}" id="${o.id}">
<div class="ph">${imgs}<span class="rtag" style="background:${REG[o.region].mk}">${REG[o.region].name}</span>${o.fav?'<span class="favtag">★ Empfehlung</span>':''}${nav}${dots}</div>
<div class="bd">
<h3>${o.name}</h3>
<div class="loc">📍 ${o.loc} · ${'★'.repeat(o.stars)||'🚢'}</div>
<div class="stars-row">${rate}</div>
<div class="chips">${o.chips.map(c=>`<span class="chip">${c}</span>`).join('')}</div>
<div class="why">${o.why.map(w=>`<div><span class="${w[0]}">${w[0]==='ok'?'✓':'✕'}</span> ${w[1]}</div>`).join('')}</div>
<div class="foot"><span class="price">${o.price.startsWith('ab')||o.price.startsWith('~')?o.price:('<span style=\"color:var(--mut)\">'+o.price+'</span>')}</span>
<span style="display:flex;gap:6px">${link2}<a class="btn" href="${o.url}" target="_blank" rel="noopener">${linkLabel}</a></span></div>
<div class="vote" data-opt="${o.id}"></div>
</div>
</div>`;
}
function renderRegions(){
const host=document.getElementById('regions');host.innerHTML='';
['kroatien','kanaren','madeira','kreuzfahrt'].forEach(rk=>{
const items=OPTIONS.filter(o=>o.region===rk);
if(!items.length)return;
const sec=document.createElement('section');
sec.innerHTML=`<div class="region-h"><span class="flag">${REG[rk].flag}</span><h2>${REG[rk].name}</h2><span class="cnt">${items.length} ${items.length>1?'Optionen':'Option'}</span></div>
<div class="grid">${items.map(cardHTML).join('')}</div>`;
host.appendChild(sec);
});
// carousels
document.querySelectorAll('.ph').forEach(ph=>{
const ims=ph.querySelectorAll('img');if(ims.length<2)return;
const dts=ph.querySelectorAll('.dot');let i=0;
const go=d=>{ims[i].classList.remove('on');dts[i]&&dts[i].classList.remove('on');
i=(i+d+ims.length)%ims.length;ims[i].classList.add('on');dts[i]&&dts[i].classList.add('on');};
const l=ph.querySelector('.nav.l'),r=ph.querySelector('.nav.r');
if(l)l.onclick=()=>go(-1);if(r)r.onclick=()=>go(1);
});
renderVotes();
}
/* ---------- voting widgets ---------- */
function renderVotes(){
document.querySelectorAll('.vote').forEach(box=>{
const id=box.dataset.opt;const {avg,n,per}=avgFor(id);
const mine=me_&&STATE.votes[me_]&&STATE.votes[me_][id]||0;
let stars='';
for(let s=1;s<=5;s++) stars+=`<span class="s ${s<=mine?'fill':''}" data-s="${s}">★</span>`;
const minis=['till','lea','astrid'].map(v=>{
const sv=per[v];
return `<span class="vm ${v} ${sv?'':'dim'}" title="${VOTER_LABEL[v]}${sv?': '+sv+'★':' noch nicht'}">${VOTER_LABEL[v][0]}${sv?`<span class="n">${sv}</span>`:''}</span>`;
}).join('');
box.innerHTML=`<div class="lead">${me_?('Deine Bewertung als <b>'+VOTER_LABEL[me_]+'</b>:'):'Wähle oben, wer du bist, dann bewerten:'}</div>
<div class="starpick" ${me_?'':'style="opacity:.4;pointer-events:none"'}>${stars}</div>
<div class="voteinfo"><span class="avg">Schnitt: <b>${avg?avg.toFixed(1):''}</b>${n?` <span style="color:var(--mut)">(${n}/3)</span>`:''}</span><span class="voters-mini">${minis}</span></div>`;
box.querySelectorAll('.starpick .s').forEach(st=>{
st.onclick=()=>{const val=+st.dataset.s;const cur=mine;sendVote(id, val===cur?0:val);};
});
});
renderRanking();
}
/* ---------- ranking ---------- */
function renderRanking(){
const arr=OPTIONS.map(o=>({o,...avgFor(o.id)})).filter(x=>x.n>0).sort((a,b)=>b.avg-a.avg||b.n-a.n);
const el=document.getElementById('ranklist');
if(!arr.length){el.innerHTML='<div class="note" style="padding:6px 2px">Noch keine Bewertungen sobald Till, Lea oder Astrid Sterne vergeben, erscheint hier das Ranking.</div>';return;}
el.innerHTML=arr.map((x,idx)=>`<div class="rrow ${idx===0?'top1':''}">
<div class="pos">${idx===0?'🥇':idx===1?'🥈':idx===2?'🥉':(idx+1)}</div>
<div class="nm">${x.o.name.replace(' · Mediterrane Schätze mit Korsika','')}<small>${x.o.loc}</small></div>
<div class="bar"><i style="width:${(x.avg/5*100).toFixed(0)}%"></i></div>
<div class="sc">${x.avg.toFixed(1)}<small> ★ · ${x.n}/3</small></div>
</div>`).join('');
}
/* ---------- server sync ---------- */
async function loadState(){
try{const r=await fetch('/api/state');STATE=await r.json();}catch(e){STATE={votes:{}};}
renderVotes();
}
async function sendVote(option,stars){
if(!me_)return;
// optimistic
STATE.votes[me_]=STATE.votes[me_]||{};
if(stars===0)delete STATE.votes[me_][option];else STATE.votes[me_][option]=stars;
renderVotes();flashSaved();
try{const r=await fetch('/api/vote',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({voter:me_,option,stars})});STATE=await r.json();renderVotes();}catch(e){}
}
function flashSaved(){const s=document.getElementById('saved');s.classList.add('on');setTimeout(()=>s.classList.remove('on'),1400);}
document.getElementById('resetBtn').onclick=async()=>{
if(!confirm('Wirklich ALLE Bewertungen von Till, Lea und Astrid zurücksetzen?'))return;
try{const r=await fetch('/api/reset',{method:'POST'});STATE=await r.json();renderVotes();flashSaved();}catch(e){}
};
function renderAll(){renderWho();renderRecos();renderRegions();}
/* ---------- map ---------- */
function initMap(){
const map=L.map('map',{scrollWheelZoom:false}).setView([41,6],5);
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
{attribution:'© OpenStreetMap, © CARTO',maxZoom:19}).addTo(map);
const pts=[];
// cruise route line
L.polyline(ROUTE.map(r=>r.c),{color:'#2f7fd6',weight:3,opacity:.75,dashArray:'6 6'}).addTo(map);
ROUTE.slice(0,5).forEach(r=>{
L.circleMarker(r.c,{radius:4,color:'#2f7fd6',fillColor:'#2f7fd6',fillOpacity:.9,weight:1})
.addTo(map).bindTooltip(r.n,{direction:'top'});
pts.push(r.c);
});
OPTIONS.forEach(o=>{
const col=REG[o.region].mk;
const icon=L.divIcon({className:'',html:`<div style="width:${o.fav?20:15}px;height:${o.fav?20:15}px;border-radius:50%;background:${col};border:3px solid ${o.fav?'#f6b73c':'#fff'};box-shadow:0 1px 4px rgba(0,0,0,.4)"></div>`,iconSize:[20,20],iconAnchor:[10,10]});
const m=L.marker(o.geo,{icon}).addTo(map);
const lbl=o.region==='kreuzfahrt'?'Zur Route →':'Zum Angebot →';
m.bindPopup(`<div style="min-width:170px"><b>${o.name}</b><br>${o.loc}<br>${o.rate?('⭐ '+o.rate+' '+o.rlabel+'<br>'):''}<a href="${o.url}" target="_blank">${lbl}</a></div>`);
pts.push(o.geo);
});
map.fitBounds(pts,{padding:[35,35],maxZoom:6});
setTimeout(()=>map.invalidateSize(),300);
}
renderAll();
initMap();
loadState();
setInterval(loadState, 12000); // live-ish sync between the three voters
+3 -297
View File
@@ -237,301 +237,7 @@
Preise &amp; Verfügbarkeit ändern sich laufend bitte vor Buchung prüfen. Erstellt für Familie Heidrich.
</footer>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const C24 = u => u; // check24 proxy urls already absolute
const REG = {
kroatien:{flag:'🇭🇷',name:'Kroatien',color:'var(--kro)',mk:'#e8643c'},
kanaren:{flag:'🇮🇨',name:'Kanaren',color:'var(--kan)',mk:'#f1a23b'},
madeira:{flag:'🇵🇹',name:'Madeira',color:'var(--mad)',mk:'#19a37a'},
kreuzfahrt:{flag:'🚢',name:'AIDA-Kreuzfahrt',color:'var(--cruise)',mk:'#2f7fd6'}
};
const OPTIONS = [
{id:'amadria',name:'Amadria Park Hotel Jakov',region:'kroatien',loc:'Šibenik · Norddalmatien',stars:4,
rate:'8,4',rlabel:'Sehr gut',bew:12,price:'ab 3.125 €',fav:true,geo:[43.698492,15.889556],
url:'https://urlaub.check24.de/suche/angebot?countryId=60&hotelId=12950&extendedSearch=1&airport=HAM,HAJ&transportType=flight&days=1w&departureDate=2026-07-11&returnDate=2026-07-21&roomAllocation=A-A-5&directFlight=1&hotelCategoryList=4,5&noRedirect=1',
chips:['Direkte Strandlage','Familienresort','Flughafen Split 37 km'],
why:[['ok','Krka-Nationalpark ~30 Min'],['ok','Kornati-Inseln per Boot ab Resort'],['ok','Shopping Split ~1 h / Šibenik 6 km'],['ok','Sehr kinderfreundlich']],
imgs:['https://cdn1.urlaub.check24.de/size=625c440/di=3/nfc=200/source=aHR0cHM6Ly9pLnRyYXZlbGFwaS5jb20vbG9kZ2luZy8zNDAwMDAwMC8zMzYzMDAwMC8zMzYyNTkwMC8zMzYyNTg1OC9iM2EyNzQxY193LmpwZw==!07717e/picture.jpg',
'https://cdn1.urlaub.check24.de/size=625c440/di=3/nfc=200/source=aHR0cHM6Ly9ob3RlbGltYWdlcy5zdW5ob3RlbHMubmV0L0hvdGVsSW5mby9ob3RlbEltYWdlLmFzcHg-aWQ9MTYxODE3MjUmZnVsbD0x!cc720d/picture.jpg']},
{id:'valamar',name:'Valamar Meteor Hotel',region:'kroatien',loc:'Makarska · Mitteldalmatien',stars:4,
rate:'8,4',rlabel:'Sehr gut',bew:128,price:'ab 3.531 €',fav:false,geo:[43.29907,17.014845],
url:'https://urlaub.check24.de/suche/angebot?countryId=60&hotelId=4619&extendedSearch=1&airport=HAM,HAJ&transportType=flight&days=1w&departureDate=2026-07-11&returnDate=2026-07-21&roomAllocation=A-A-5&directFlight=1&hotelCategoryList=4,5&noRedirect=1',
chips:['Top Hotel Award','Direkte Strandlage','Flughafen Split 64 km'],
why:[['ok','Top bewertet, Strand + Promenade'],['ok','Inselfähren Brač/Hvar via Split'],['no','Krka ~1h45, Plitvice 3h+'],['ok','Familientauglich']],
imgs:['https://cdn1.urlaub.check24.de/size=625c440/di=3/nfc=200/source=aHR0cDovL3Bob3Rvcy5ob3RlbGJlZHMuY29tL2dpYXRhL29yaWdpbmFsLzI1LzI1MzUxOS8yNTM1MTlhX2hiX3NfMDA1LmpwZw==!a9bbf6/picture.jpg',
'https://cdn1.urlaub.check24.de/size=625c440/di=3/nfc=200/source=aHR0cHM6Ly9pLnRyYXZlbGFwaS5jb20vbG9kZ2luZy84MDAwMDAwLzc3MDAwMDAvNzY5NzYwMC83Njk3NTMxLzhkMDY0YTM3X3cuanBn!7c92d2/picture.jpg',
'https://cdn1.urlaub.check24.de/size=625c440/di=3/nfc=200/source=aHR0cDovL21lZGlhLmRldi5wYXhpbXVtLmNvbS9ob3RlbGltYWdlcy8yNzA0MTgvNDcuanBn!8c2535/picture.jpg']},
{id:'bluesun',name:'Bluesun Hotel Berulia',region:'kroatien',loc:'Brela · Mitteldalmatien',stars:4,
rate:'8,4',rlabel:'Sehr gut',bew:13,price:'ab 4.706 €',fav:false,geo:[43.362448,16.938868],
url:'https://urlaub.check24.de/suche/angebot?countryId=60&hotelId=1739068&extendedSearch=1&airport=HAM,HAJ&transportType=flight&days=1w&departureDate=2026-07-11&returnDate=2026-07-21&roomAllocation=A-A-5&directFlight=1&hotelCategoryList=4,5&noRedirect=1',
chips:['Direkte Strandlage','Traumstrand Brela','Flughafen Split 55 km'],
why:[['ok','Einer der schönsten Strände Kroatiens'],['no','Krka ~1h302h, Plitvice zu weit'],['ok','Inseln via Split'],['no','Teuerste Hotel-Option']],
imgs:['https://cdn1.urlaub.check24.de/size=625c440/di=3/nfc=200/source=aHR0cHM6Ly9pLnRyYXZlbGFwaS5jb20vbG9kZ2luZy8zMDAwMDAwLzI4MjAwMDAvMjgxNDUwMC8yODE0NDA5LzZjYjI4NzAwX3cuanBn!774551/picture.jpg']},
{id:'morenia',name:'Morenia Beach Resort',region:'kroatien',loc:'Podaca · Mitteldalmatien',stars:4,
rate:'7,8',rlabel:'Gut',bew:48,price:'ab 3.527 €',fav:false,geo:[43.130196,17.286675],
url:'https://urlaub.check24.de/suche/angebot?hotelId=1859719&extendedSearch=1&airport=HAM,HAJ&transportType=flight&days=1w&departureDate=2026-07-11&returnDate=2026-07-21&roomAllocation=A-A-5&directFlight=1&hotelCategoryList=4,5&noRedirect=1',
chips:['Direkte Strandlage','Familie & Strand','Flughafen Split 92 km'],
why:[['ok','Ruhige Bucht, familienorientiert'],['no','Weit von Split & Nationalparks'],['no','Längster Flughafentransfer'],['ok','Solide Preis-Leistung']],
imgs:['https://cdn1.urlaub.check24.de/size=625c440/di=3/nfc=200/source=aHR0cHM6Ly9hcGktaW1nLmhvdGVsc3Rvbi5jb20vcmVzb3VyY2UvaG90ZWwvaW1hZ2VzLzcvMy84LzMvNy8yLzYvMy8xMDMxMzQzNTY0LmpwZw==!9db0ac/picture.jpg']},
{id:'tui',name:'TUI KIDS CLUB Taurito Princess',region:'kanaren',loc:'Taurito · Gran Canaria',stars:4,
rate:'8,0',rlabel:'Sehr gut',bew:400,price:'Preis auf Check24',fav:true,geo:[27.815366,-15.754307],
url:'https://urlaub.check24.de/suche/angebot?hotelId=3265&extendedSearch=1&airport=HAM,HAJ&transportType=flight&days=1w&departureDate=2026-07-11&returnDate=2026-07-21&roomAllocation=A-A-5&directFlight=1&hotelCategoryList=4,5&noRedirect=1',
chips:['Echter Kinderclub','Sommer mild ~26°C','Flughafen LPA 38 km'],
why:[['ok','Top für Felix (Betreuung & Animation)'],['ok','400 Bewertungen, sehr beliebt'],['no','Keine Kroatien-Wünsche (Insel/NP)'],['ok','Dünen & Berge für Ausflüge']],
imgs:['https://cdn1.urlaub.check24.de/size=625c440/di=3/nfc=200/source=aHR0cHM6Ly9pLnRyYXZlbGFwaS5jb20vbG9kZ2luZy85MDAwMDAwLzgxNjAwMDAvODE1ODAwMC84MTU3OTc5LzRjNjE3OTlkX3cuanBn!55230f/picture.jpg',
'https://cdn1.urlaub.check24.de/size=625c440/di=3/nfc=200/source=aHR0cDovL21lZGlhLmRldi5wYXhpbXVtLmNvbS9ob3RlbGltYWdlcy8yMjY0NTYvZDFhYzA3MGQ0OTg1Zjg3ZmI4YjM5OTA2MzAxNTMzZGQuanBn!c32a55/picture.jpg']},
{id:'iberostar',name:'Iberostar Waves Bouganville Playa',region:'kanaren',loc:'Playa de las Américas · Teneriffa',stars:4,
rate:'8,4',rlabel:'Sehr gut',bew:103,price:'Preis auf Check24',fav:false,geo:[28.074417,-16.732332],
url:'https://urlaub.check24.de/suche/angebot?hotelId=2134&extendedSearch=1&airport=HAM,HAJ&transportType=flight&days=1w&departureDate=2026-07-11&returnDate=2026-07-21&roomAllocation=A-A-5&directFlight=1&hotelCategoryList=4,5&noRedirect=1',
chips:['Am Meer','Sehr kurzer Transfer','Flughafen TFS 16 km'],
why:[['ok','Nur 16 km vom Flughafen'],['ok','Teide-Nationalpark als Ausflug'],['no','Keine Boots-/Inselkultur wie HR'],['ok','Gut bewertet']],
imgs:['https://cdn1.urlaub.check24.de/size=625c440/di=3/nfc=200/source=aHR0cDovL3Bob3Rvcy5ob3RlbGJlZHMuY29tL2dpYXRhL29yaWdpbmFsLzAwLzAwMTEzNi8wMDExMzZhX2hiX3RfMDExLmpwZw==!e1aee2/picture.jpg']},
{id:'riu',name:'Hotel Riu Madeira',region:'madeira',loc:'Caniço de Baixo · Madeira',stars:4,
rate:'8,4',rlabel:'Sehr gut',bew:752,price:'Preis auf Check24',fav:false,geo:[32.645679,-16.826868],
url:'https://urlaub.check24.de/suche/angebot?countryId=88&hotelId=8761&extendedSearch=1&airport=HAM,HAJ,BRE,RLG,LBC,GWT&transportType=flight&days=1w&departureDate=2026-07-12&returnDate=2026-07-22&roomAllocation=A-A-5&directFlight=1&hotelCategoryList=4,5&noRedirect=1',
chips:['Strand ~400 m','752 Bewertungen','Wander-Insel'],
why:[['ok','Sehr viele gute Bewertungen'],['no','Madeira = Wandern, kaum Sandstrand'],['no','Kein klassischer Badeurlaub für Felix'],['ok','Mild, grün, schöne Natur']],
imgs:['https://cdn1.urlaub.check24.de/size=625c440/di=3/nfc=200/source=aHR0cHM6Ly9pLnRyYXZlbGFwaS5jb20vbG9kZ2luZy81MDAwMDAwLzQ4MjAwMDAvNDgxMzQwMC80ODEzMzUxL2Y4ZjNkYTE3X3cuanBn!cb047b/picture.jpg',
'https://cdn1.urlaub.check24.de/size=625c440/di=3/nfc=200/source=aHR0cDovL3Bob3Rvcy5ob3RlbGJlZHMuY29tL2dpYXRhL29yaWdpbmFsLzAwLzAwNTUyOS8wMDU1MjlhX2hiX2FfMDIzLmpwZw==!406b07/picture.jpg']},
{id:'dreams',name:'Dreams Madeira Resort, Spa & Marina',region:'madeira',loc:'Caniçal · Madeira',stars:5,
rate:'8,4',rlabel:'Sehr gut',bew:111,price:'Preis auf Check24',fav:false,geo:[32.742452,-16.709186],
url:'https://urlaub.check24.de/suche/angebot?countryId=88&hotelId=30324&extendedSearch=1&airport=HAM,HAJ,BRE,RLG,LBC,GWT&transportType=flight&days=1w&departureDate=2026-07-12&returnDate=2026-07-22&roomAllocation=A-A-5&directFlight=1&hotelCategoryList=4,5&noRedirect=1',
chips:['5 Sterne','Direkte Strandlage','Marina & Spa'],
why:[['ok','Hochwertiges 5★-Resort'],['no','Ruhiger Osten eher Ruhe/Paar'],['no','Madeira kein Bade-Klassiker'],['ok','Schön für Astrid']],
imgs:['https://cdn1.urlaub.check24.de/size=625c440/di=3/nfc=200/source=aHR0cHM6Ly9pLnRyYXZlbGFwaS5jb20vbG9kZ2luZy83MDAwMDAwLzYxNTAwMDAvNjE0MTgwMC82MTQxNzIzLzI5YmZiMjE0X3cuanBn!da02c7/picture.jpg']},
{id:'sentido',name:'Sentido Galosol',region:'madeira',loc:'Caniço de Baixo · Madeira',stars:4,
rate:null,rlabel:null,bew:216,price:'Preis auf Check24',fav:false,geo:[32.642268,-16.831963],
url:'https://urlaub.check24.de/suche/angebot?countryId=88&hotelId=2205&extendedSearch=1&airport=HAM,HAJ,BRE,RLG,LBC,GWT&transportType=flight&days=1w&departureDate=2026-07-12&returnDate=2026-07-22&roomAllocation=A-A-5&directFlight=1&hotelCategoryList=4,5&noRedirect=1',
chips:['Top Lage','216 Bewertungen','Meerblick'],
why:[['ok','Beliebte Lage, Klippenbad'],['no','Kein Sandstrand'],['no','Eher ruhig, nicht kinderfokussiert'],['ok','Gutes Preisniveau']],
imgs:['https://cdn1.urlaub.check24.de/size=625c440/di=3/nfc=200/source=aHR0cHM6Ly9pLnRyYXZlbGFwaS5jb20vbG9kZ2luZy8xMDAwMDAwLzkzMDAwMC85MjI0MDAvOTIyMzE5Lzg3YjE3ODc3X3ouanBn!d36a31/picture.jpg']},
{id:'aida',name:'AIDA Cosma · Mediterrane Schätze mit Korsika',region:'kreuzfahrt',loc:'ab/bis Mallorca · 7 Nächte · 11.18.07.',stars:0,
rate:'4,8',rlabel:'Ahoi',bew:null,price:'ab 6.477 €',fav:true,geo:[39.5696,2.6502],
url:'https://www.ahoi-schiff.de/aida/routen/mediterrane-schaetze-mit-korsika-ab-mallorca/aidacosma-2026-07-11?paxe=2',
url2:'https://aida.de/buchen/CO07260711/CLASSIC/meine-reise/anreise',
chips:['4 Pers., 2 Balkonkabinen','inkl. Flug & Vollpension','La Spezia · Rom · Korsika · Barcelona'],
why:[['ok','Null Selbstfahren ideal für Astrid'],['ok','Fixer Komplettpreis, Vollpension'],['ok','Jeden Tag ein neues Ziel'],['no','Mit Felix (3) straffer Rhythmus']],
imgs:['https://files.ahoi-schiff.de/aida-cruises/s/aidacosma.webp']}
];
// AIDA route ports (for map polyline)
const ROUTE = [
{n:'Palma de Mallorca',c:[39.5696,2.6502]},
{n:'La Spezia / Florenz',c:[44.1025,9.8200]},
{n:'Rom / Civitavecchia',c:[42.0930,11.7896]},
{n:'Ajaccio (Korsika)',c:[41.9192,8.7386]},
{n:'Barcelona',c:[41.3568,2.1597]},
{n:'Palma de Mallorca',c:[39.5696,2.6502]}
];
const RECOS = [
{rank:'Empfehlung 1 · Rundum-Kroatien',opt:'amadria',
text:'Erfüllt alle Wünsche auf einmal: Krka-Nationalpark in ~30 Min, Kornati-Inseln per Boot, Shopping in Split und ein großes, kinderfreundliches Strand-Resort. Kürzeste Wege, größtes „für alle was dabei".',
price:'ab ~5.400 €',
lines:[['Pauschal (Flug HAM/HAJ + Hotel)','~4.700 €*'],['Mietwagen Kombi/SUV (10 T)','~650 €'],['Nationalparks/Inseln','Krka, Kornati, Plitvice']],
foot:'*für 4 Pers. hochgerechnet auf Check24 mit eurer Belegung prüfen.'},
{rank:'Empfehlung 2 · Entspannt ohne Fahren',opt:'aida',
text:'AIDA Cosma ab Mallorca: Florenz, Rom, Korsika und Barcelona ohne Koffer-Schleppen und ohne Mietwagen. Fixer All-in-Preis mit Vollpension, perfekt wenn Astrid mitkommt. Mit Felix etwas straffer Tagesrhythmus.',
price:'~6.480 €',
lines:[['4 Pers., 2 Balkonkabinen','inkl. Flug'],['Vollpension an Bord','inkl.'],['Mietwagen','nicht nötig (0 €)']],
foot:'LIGHT/CLASSIC ab Hannover 6.477 € / ab HH 6.4876.677 €. Landausflüge optional extra.'},
{rank:'Empfehlung 3 · Stressfrei mit Kind',opt:'tui',
text:'TUI KIDS CLUB auf Gran Canaria: der entspannteste Familienurlaub echter Kinderclub für Felix, mildes Sommerklima, kurzer Transfer. Ohne die Kroatien-Wünsche (Nationalpark/Insel), dafür maximal unkompliziert.',
price:'~4.5005.500 €',
lines:[['Pauschal (Flug + Hotel)','auf Check24 prüfen'],['Kinderclub für Felix','inkl.'],['Mietwagen optional','~500 € / 10 T']],
foot:'Preis grob für 4 Pers. Gran Canaria & Teneriffa beide in der Auswahl unten.'}
];
const byId = id => OPTIONS.find(o=>o.id===id);
const VOTER_LABEL = {till:'Till',lea:'Lea',astrid:'Astrid'};
let me_ = localStorage.getItem('voter') || null;
let STATE = {votes:{}};
/* ---------- hero ---------- */
document.getElementById('herobg').style.backgroundImage =
"url('"+byId('bluesun').imgs[0]+"')";
/* ---------- voter selector ---------- */
const whoEl = document.getElementById('who');
function renderWho(){
whoEl.innerHTML='';
['till','lea','astrid'].forEach(v=>{
const b=document.createElement('button');
b.className=(me_===v?'on '+v:'');
b.innerHTML=`<span class="av ${v}"></span>${VOTER_LABEL[v]}`;
b.onclick=()=>{me_=v;localStorage.setItem('voter',v);renderWho();renderAll();};
whoEl.appendChild(b);
});
}
/* ---------- recommendations ---------- */
function renderRecos(){
const el=document.getElementById('recos');el.innerHTML='';
RECOS.forEach(r=>{
const o=byId(r.opt);
const links=`<a class="btn" href="${o.url}" target="_blank" rel="noopener">${o.region==='kreuzfahrt'?'Ahoi-Schiff →':'Check24 →'}</a>`
+ (o.url2?`<a class="btn ghost" href="${o.url2}" target="_blank" rel="noopener">AIDA.de →</a>`:'')
+ `<a class="btn ghost" href="#${o.id}">Details ↓</a>`;
const d=document.createElement('div');d.className='reco';
d.innerHTML=`<div class="top"><div class="rank">${r.rank}</div><h3>${o.name.replace(' · Mediterrane Schätze mit Korsika','')}</h3><div class="place">📍 ${o.loc}</div></div>
<div class="body">
<p>${r.text}</p>
<div class="pricebox"><div class="big">${r.price}</div>
${r.lines.map(l=>`<div class="li"><span>${l[0]}</span><b>${l[1]}</b></div>`).join('')}
</div>
<div class="note">${r.foot}</div>
<div class="links">${links}</div>
</div>`;
el.appendChild(d);
});
}
/* ---------- regions + cards ---------- */
function avgFor(id){
let sum=0,n=0,per={};
for(const v of ['till','lea','astrid']){
const s=STATE.votes[v]&&STATE.votes[v][id];
if(s){sum+=s;n++;per[v]=s;}
}
return {avg:n?sum/n:0,n,per};
}
function cardHTML(o){
const imgs=o.imgs.map((s,i)=>`<img src="${s}" class="${i===0?'on':''}" loading="lazy" alt="${o.name}" onerror="this.remove()">`).join('');
const dots=o.imgs.length>1?`<div class="dots">${o.imgs.map((_,i)=>`<span class="dot ${i===0?'on':''}"></span>`).join('')}</div>`:'';
const nav=o.imgs.length>1?'<div class="nav l"></div><div class="nav r"></div>':'';
const rate=o.rate?`<span class="crate"><b>${o.rate}</b> ${o.rlabel}${o.bew?(' · '+o.bew+' Bew.'):''}</span>`:(o.bew?`<span class="crate">${o.bew} Bewertungen</span>`:'');
const linkLabel=o.region==='kreuzfahrt'?'Ahoi-Schiff →':'Auf Check24 →';
const link2=o.url2?`<a class="btn ghost" href="${o.url2}" target="_blank" rel="noopener" style="font-size:11.5px;padding:7px 10px">AIDA.de</a>`:'';
return `<div class="card ${o.fav?'fav':''}" id="${o.id}">
<div class="ph">${imgs}<span class="rtag" style="background:${REG[o.region].mk}">${REG[o.region].name}</span>${o.fav?'<span class="favtag">★ Empfehlung</span>':''}${nav}${dots}</div>
<div class="bd">
<h3>${o.name}</h3>
<div class="loc">📍 ${o.loc} · ${'★'.repeat(o.stars)||'🚢'}</div>
<div class="stars-row">${rate}</div>
<div class="chips">${o.chips.map(c=>`<span class="chip">${c}</span>`).join('')}</div>
<div class="why">${o.why.map(w=>`<div><span class="${w[0]}">${w[0]==='ok'?'✓':'✕'}</span> ${w[1]}</div>`).join('')}</div>
<div class="foot"><span class="price">${o.price.startsWith('ab')||o.price.startsWith('~')?o.price:('<span style=\"color:var(--mut)\">'+o.price+'</span>')}</span>
<span style="display:flex;gap:6px">${link2}<a class="btn" href="${o.url}" target="_blank" rel="noopener">${linkLabel}</a></span></div>
<div class="vote" data-opt="${o.id}"></div>
</div>
</div>`;
}
function renderRegions(){
const host=document.getElementById('regions');host.innerHTML='';
['kroatien','kanaren','madeira','kreuzfahrt'].forEach(rk=>{
const items=OPTIONS.filter(o=>o.region===rk);
if(!items.length)return;
const sec=document.createElement('section');
sec.innerHTML=`<div class="region-h"><span class="flag">${REG[rk].flag}</span><h2>${REG[rk].name}</h2><span class="cnt">${items.length} ${items.length>1?'Optionen':'Option'}</span></div>
<div class="grid">${items.map(cardHTML).join('')}</div>`;
host.appendChild(sec);
});
// carousels
document.querySelectorAll('.ph').forEach(ph=>{
const ims=ph.querySelectorAll('img');if(ims.length<2)return;
const dts=ph.querySelectorAll('.dot');let i=0;
const go=d=>{ims[i].classList.remove('on');dts[i]&&dts[i].classList.remove('on');
i=(i+d+ims.length)%ims.length;ims[i].classList.add('on');dts[i]&&dts[i].classList.add('on');};
const l=ph.querySelector('.nav.l'),r=ph.querySelector('.nav.r');
if(l)l.onclick=()=>go(-1);if(r)r.onclick=()=>go(1);
});
renderVotes();
}
/* ---------- voting widgets ---------- */
function renderVotes(){
document.querySelectorAll('.vote').forEach(box=>{
const id=box.dataset.opt;const {avg,n,per}=avgFor(id);
const mine=me_&&STATE.votes[me_]&&STATE.votes[me_][id]||0;
let stars='';
for(let s=1;s<=5;s++) stars+=`<span class="s ${s<=mine?'fill':''}" data-s="${s}"></span>`;
const minis=['till','lea','astrid'].map(v=>{
const sv=per[v];
return `<span class="vm ${v} ${sv?'':'dim'}" title="${VOTER_LABEL[v]}${sv?': '+sv+'★':' noch nicht'}">${VOTER_LABEL[v][0]}${sv?`<span class="n">${sv}</span>`:''}</span>`;
}).join('');
box.innerHTML=`<div class="lead">${me_?('Deine Bewertung als <b>'+VOTER_LABEL[me_]+'</b>:'):'Wähle oben, wer du bist, dann bewerten:'}</div>
<div class="starpick" ${me_?'':'style="opacity:.4;pointer-events:none"'}>${stars}</div>
<div class="voteinfo"><span class="avg">Schnitt: <b>${avg?avg.toFixed(1):''}</b>${n?` <span style="color:var(--mut)">(${n}/3)</span>`:''}</span><span class="voters-mini">${minis}</span></div>`;
box.querySelectorAll('.starpick .s').forEach(st=>{
st.onclick=()=>{const val=+st.dataset.s;const cur=mine;sendVote(id, val===cur?0:val);};
});
});
renderRanking();
}
/* ---------- ranking ---------- */
function renderRanking(){
const arr=OPTIONS.map(o=>({o,...avgFor(o.id)})).filter(x=>x.n>0).sort((a,b)=>b.avg-a.avg||b.n-a.n);
const el=document.getElementById('ranklist');
if(!arr.length){el.innerHTML='<div class="note" style="padding:6px 2px">Noch keine Bewertungen sobald Till, Lea oder Astrid Sterne vergeben, erscheint hier das Ranking.</div>';return;}
el.innerHTML=arr.map((x,idx)=>`<div class="rrow ${idx===0?'top1':''}">
<div class="pos">${idx===0?'🥇':idx===1?'🥈':idx===2?'🥉':(idx+1)}</div>
<div class="nm">${x.o.name.replace(' · Mediterrane Schätze mit Korsika','')}<small>${x.o.loc}</small></div>
<div class="bar"><i style="width:${(x.avg/5*100).toFixed(0)}%"></i></div>
<div class="sc">${x.avg.toFixed(1)}<small> ★ · ${x.n}/3</small></div>
</div>`).join('');
}
/* ---------- server sync ---------- */
async function loadState(){
try{const r=await fetch('/api/state');STATE=await r.json();}catch(e){STATE={votes:{}};}
renderVotes();
}
async function sendVote(option,stars){
if(!me_)return;
// optimistic
STATE.votes[me_]=STATE.votes[me_]||{};
if(stars===0)delete STATE.votes[me_][option];else STATE.votes[me_][option]=stars;
renderVotes();flashSaved();
try{const r=await fetch('/api/vote',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({voter:me_,option,stars})});STATE=await r.json();renderVotes();}catch(e){}
}
function flashSaved(){const s=document.getElementById('saved');s.classList.add('on');setTimeout(()=>s.classList.remove('on'),1400);}
document.getElementById('resetBtn').onclick=async()=>{
if(!confirm('Wirklich ALLE Bewertungen von Till, Lea und Astrid zurücksetzen?'))return;
try{const r=await fetch('/api/reset',{method:'POST'});STATE=await r.json();renderVotes();flashSaved();}catch(e){}
};
function renderAll(){renderWho();renderRecos();renderRegions();}
/* ---------- map ---------- */
function initMap(){
const map=L.map('map',{scrollWheelZoom:false}).setView([41,6],5);
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
{attribution:'© OpenStreetMap, © CARTO',maxZoom:19}).addTo(map);
const pts=[];
// cruise route line
L.polyline(ROUTE.map(r=>r.c),{color:'#2f7fd6',weight:3,opacity:.75,dashArray:'6 6'}).addTo(map);
ROUTE.slice(0,5).forEach(r=>{
L.circleMarker(r.c,{radius:4,color:'#2f7fd6',fillColor:'#2f7fd6',fillOpacity:.9,weight:1})
.addTo(map).bindTooltip(r.n,{direction:'top'});
pts.push(r.c);
});
OPTIONS.forEach(o=>{
const col=REG[o.region].mk;
const icon=L.divIcon({className:'',html:`<div style="width:${o.fav?20:15}px;height:${o.fav?20:15}px;border-radius:50%;background:${col};border:3px solid ${o.fav?'#f6b73c':'#fff'};box-shadow:0 1px 4px rgba(0,0,0,.4)"></div>`,iconSize:[20,20],iconAnchor:[10,10]});
const m=L.marker(o.geo,{icon}).addTo(map);
const lbl=o.region==='kreuzfahrt'?'Zur Route →':'Zum Angebot →';
m.bindPopup(`<div style="min-width:170px"><b>${o.name}</b><br>${o.loc}<br>${o.rate?('⭐ '+o.rate+' '+o.rlabel+'<br>'):''}<a href="${o.url}" target="_blank">${lbl}</a></div>`);
pts.push(o.geo);
});
map.fitBounds(pts,{padding:[35,35],maxZoom:6});
setTimeout(()=>map.invalidateSize(),300);
}
renderAll();
initMap();
loadState();
setInterval(loadState, 12000); // live-ish sync between the three voters
<script src="app.js" defer></script>
</body>
</html>