// ── Translations ────────────────────────────────────────────────────────────── const TRANSLATIONS = { de: { nav: { zimmer:'Zimmer', fruehstueck:'Kaffeetheke', verfuegbarkeit:'Verfügbarkeit', galerie:'Galerie', ueber:'Über uns', kontakt:'Kontakt' }, hero: { slogan:'Wachau-Blick,\nWachauer Herz.', sub:'Zwei liebevoll geführte Doppelzimmer mit Panorama-Aussicht – mitten im UNESCO-Welterbe Wachau.', cta:'Jetzt anfragen', scroll:'Entdecken' }, zimmer: { title:'Zimmer & Preise', sub:'Zwei frisch sanierte Doppelzimmer mit allem, was Sie brauchen.', helene:{ name:'Zimmer Helene', desc:'Ruhiges Doppelzimmer mit Balkon und Blick ins Grüne. Frisch saniert, hell und gemütlich eingerichtet.' }, marille:{ name:'Zimmer Marille', desc:'Sonniges Doppelzimmer benannt nach der Wachauer Marille – warmer Charme und Balkon inklusive.' }, features:['ca. 20 m²','Balkon','Dusche & WC','WLAN','TV','Föhn','Mini-Kühlschrank','Ventilator'], priceTitle:'Saisonale Preise', priceSub:'Kaffeetheke inklusive · Mindestaufenthalt Wochenende: 2 Nächte', seasons:[ { label:'Nebensaison', dates:'Dez – Feb', tier:'A', prices:{ '1-3': 94, '4+': 90, '7+': 87 }, single:{ '1-3': 53, '4+': 50, '7+': 46 }, color:'blue' }, { label:'Vorsaison', dates:'Mai · Okt – Nov', tier:'B', prices:{ '1-3': 98, '4+': 95, '7+': 91 }, single:{ '1-3': 56, '4+': 53, '7+': 50 }, color:'green' }, { label:'Hauptsaison', dates:'Jun – Sep', tier:'C', prices:{ '1-3': 105, '4+': 101, '7+': 98 }, single:{ '1-3': 58, '4+': 55, '7+': 52 }, color:'apricot' }, ], tierLabels:{ '1-3':'1 – 3 Nächte', '4+':'ab 4 Nächte', '7+':'ab 7 Nächte' }, extras:'Weitere Konditionen', extrasItems:[ ['Check-in', 'ab 15:00 Uhr'], ['Check-out', 'bis 11:00 Uhr'], ['Einzelnutzung', 'siehe Spalte rechts'], ['Zusatzbett pro Nacht', '€ 33 (1-3 N) · € 30 (4+ N) · € 26 (7+ N)'], ['Kinder bis 3 Jahre', 'kostenlos'], ['Kinder 4 – 17 Jahre', '€ 33 (1-3 N) · € 30 (4+ N) · € 26 (7+ N)'], ], perNight:'/Nacht · 2 Personen', perNightSingle:'Einzelnutzung', note:'Preise pro Zimmer/Nacht inkl. Kaffeetheke · Ortstaxe nicht enthalten · Stand 2026' }, fruehstueck:{ title:'Die Kaffeetheke', sub:'Kaffee und Tee – jederzeit zur Selbstbedienung.', text:'Auf der Galerie wartet unsere Kaffeetheke: frischer Kaffee aus dem Vollautomaten und eine Auswahl an Tee – jederzeit zur Selbstbedienung. Ein gemütlicher Moment zum Ankommen oder Ausklingen. Für ein echtes Wachauer Frühstücks- und Genusserlebnis empfehlen wir die Heurigen und Lokale der Umgebung – wir geben Ihnen gerne unsere Tipps.', tag:'Kaffeetheke inklusive' }, aussicht:{ title:'Aussicht & Umgebung', sub:'Mitten im UNESCO-Welterbe – draußen wartet die Wachau.', tabs:['Veranstaltungen','Sehenswürdigkeiten','Sport & Freizeit'], weather:'Wetter Spitz a. d. Donau', weatherSub:'Aktuell in Spitz an der Donau', more:'Mehr Tipps & Ausflugsideen', moreLink:'https://www.spitz-wachau.at', events:['Wachauer Weinfrühling','Klingende Rieden','Sonnwendfeier','Marillenkirtag','Wachau Marathon','Spitzer Weinherbst','Erntedankfest','Tage der offenen Kellertüren','Weingartenführungen'], sehen:['Heurigenbesuche','Schifffahrtsmuseum im Schloss Erlahof','Spätgotische Kirche','Schloss zu Spitz','Ruine Hinterhaus','Tausendeimerberg','Rotes Tor','Rollfähre mit Camera obscura','Historischer Kaufmannsladen'], sport:['Wander- & Rundwanderwege (Welterbesteig Wachau)','2 Tennisplätze','Freibad & Beachvolleyball','Fahrrad & Segwayverleih','Donauradweg & Radroute südliches Waldviertel','Segway Touren in der Wachau','Kinderspielplätze'] }, verfuegbarkeit:{ title:'Zimmer-Verfügbarkeit', sub:'Auf einen Blick – wann ist Ihr Wunschzimmer frei?', helene:'Zimmer Helene', marille:'Zimmer Marille', free:'Frei', booked:'Belegt', legend:'Legende', cta:'Verfügbar? Jetzt anfragen', months:['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'], days:['Mo','Di','Mi','Do','Fr','Sa','So'], adminBtn:'Admin', adminTitle:'Verfügbarkeit verwalten', adminHint:'Datum anklicken zum Umschalten · Änderungen werden sofort gespeichert', pwLabel:'Passwort', pwBtn:'Anmelden', pwWrong:'Falsches Passwort', logout:'Abmelden' }, galerie:{ title:'Galerie', sub:'Ein Blick ins Haus.', labels:['Eingang & Außenansicht','Zimmer Helene','Zimmer Marille','Balkon & Aussicht','Kaffeetheke','Wachau-Umgebung','Mini-Kühlschrank','Schrank & TV','Ventilator'] }, ueber:{ title:'Über uns', sub:'Zwei Brüder, ein Haus, eine Leidenschaft.', text:'Patrik und Daniel Sabata haben das Haus von ihrer Tante übernommen – in Ehren gehalten, was war, und mit frischem Wind, was kommt. Hier trifft Wachauer Gastfreundschaft auf modernes Konzept. Wir freuen uns auf Sie.', patrik:'Patrik Sabata', daniel:'Daniel Sabata' }, bewertungen:{ title:'Gästestimmen', sub:'Was unsere Gäste sagen.', items:[] }, kontakt:{ title:'Kontakt & Anfrage', sub:'Schreiben Sie uns – wir antworten rasch.', name:'Ihr Name', email:'E-Mail-Adresse', phone:'Telefon (optional)', street:'Straße & Hausnummer', zip:'PLZ', city:'Ort', country:'Land', arrival:'Anreise', departure:'Abreise', room:'Zimmer', roomOpts:['Bitte wählen','Zimmer Helene','Zimmer Marille','Egal / beide'], guests:'Anzahl Personen', message:'Nachricht (optional)', send:'Anfrage senden', sent:'Vielen Dank! Wir melden uns bald bei Ihnen.', direct:'Direkt erreichbar:' }, anfahrt:{ title:'Anfahrt', sub:'Siedlung Erlahof 47, 3620 Spitz an der Donau', parking:'Kostenlose Parkplätze direkt am Haus', train:'Bahnhof Spitz/Donau: ca. 2 km (Taxi verfügbar)', bike:'Donauradweg: direkt erreichbar · Fahrradabstellplatz vorhanden' }, footer:{ tagline:'Gemütlichkeit mit Aussicht – Wachau.', impressum:'Impressum', datenschutz:'Datenschutz', rights:'© 2025 P&D Schöne Aussicht · Patrik & Daniel Sabata', social:'Folgt bald auf:' } }, en: { nav: { zimmer:'Rooms', fruehstueck:'Kaffeetheke', verfuegbarkeit:'Availability', galerie:'Gallery', ueber:'About', kontakt:'Contact' }, hero: { slogan:'Wachau Views,\nWachau Heart.', sub:'Two lovingly run double rooms with panoramic views – in the heart of UNESCO World Heritage Wachau.', cta:'Enquire Now', scroll:'Discover' }, zimmer: { title:'Rooms & Prices', sub:'Two newly renovated double rooms with everything you need.', helene:{ name:'Room Helene', desc:'Quiet double room with balcony and garden views. Freshly renovated, bright and cosy.' }, marille:{ name:'Room Marille', desc:'Sunny double room named after the Wachau apricot – warm charm and a balcony included.' }, features:['approx. 20 m²','Balcony','Shower & WC','WiFi','TV','Hairdryer','Mini-fridge','Fan'], priceTitle:'Seasonal Rates', priceSub:'Kaffeetheke included · Weekend minimum stay: 2 nights', seasons:[ { label:'Low Season', dates:'Dec – Feb', tier:'A', prices:{ '1-3': 94, '4+': 90, '7+': 87 }, single:{ '1-3': 53, '4+': 50, '7+': 46 }, color:'blue' }, { label:'Shoulder Season', dates:'May · Oct – Nov', tier:'B', prices:{ '1-3': 98, '4+': 95, '7+': 91 }, single:{ '1-3': 56, '4+': 53, '7+': 50 }, color:'green' }, { label:'High Season', dates:'Jun – Sep', tier:'C', prices:{ '1-3': 105, '4+': 101, '7+': 98 }, single:{ '1-3': 58, '4+': 55, '7+': 52 }, color:'apricot' }, ], tierLabels:{ '1-3':'1 – 3 nights', '4+':'4+ nights', '7+':'7+ nights' }, extras:'Additional terms', extrasItems:[ ['Check-in', 'from 3:00 pm'], ['Check-out', 'until 11:00 am'], ['Single occupancy', 'see right column'], ['Extra bed per night', '€ 33 (1-3 n) · € 30 (4+ n) · € 26 (7+ n)'], ['Children up to 3 yrs', 'free'], ['Children 4 – 17 yrs', '€ 33 (1-3 n) · € 30 (4+ n) · € 26 (7+ n)'], ], perNight:'/night · 2 persons', perNightSingle:'single occupancy', note:'Prices per room/night incl. Kaffeetheke · City tax not included · As of 2026' }, fruehstueck:{ title:'The Kaffeetheke', sub:'Coffee and tea – self-service, any time.', text:'On the landing you will find our Kaffeetheke: freshly brewed coffee and a selection of teas – self-service, whenever you like. A cosy spot to arrive or wind down. For a true Wachau breakfast and culinary experience, we recommend the Heurigen and spots nearby – just ask us for our tips.', tag:'Kaffeetheke included' }, aussicht:{ title:'Views & Surroundings', sub:'In the heart of UNESCO World Heritage – the Wachau awaits.', tabs:['Events','Sights','Sport & Leisure'], weather:'Weather Spitz a.d. Donau', weatherSub:'Currently in Spitz an der Donau', more:'More tips & excursion ideas', moreLink:'https://www.spitz-wachau.at', events:['Wachau Wine Spring','Klingende Rieden (vineyard concerts)','Midsummer Celebration','Apricot Festival','Wachau Marathon','Spitz Wine Autumn','Harvest Thanksgiving','Open Cellar Doors','Vineyard Tours'], sehen:['Heuriger visits','Maritime Museum at Erlahof Castle','Late Gothic Church','Spitz Castle','Hinterhaus Ruins','Tausendeimerberg','Red Gate','Car Ferry with Camera Obscura','Historic Merchant Shop'], sport:['Hiking & circular trails (Welterbesteig Wachau)','2 tennis courts','Open-air pool & beach volleyball','Bike & Segway rental','Danube Cycle Path & Southern Forest Quarter route','Segway tours in the Wachau','Children\'s playgrounds'] }, verfuegbarkeit:{ title:'Room Availability', sub:'At a glance – when is your preferred room free?', helene:'Room Helene', marille:'Room Marille', free:'Available', booked:'Booked', legend:'Legend', cta:'Available? Enquire now', months:['January','February','March','April','May','June','July','August','September','October','November','December'], days:['Mo','Tu','We','Th','Fr','Sa','Su'], adminBtn:'Admin', adminTitle:'Manage Availability', adminHint:'Click a date to toggle · Changes are saved immediately', pwLabel:'Password', pwBtn:'Login', pwWrong:'Wrong password', logout:'Log out' }, galerie:{ title:'Gallery', sub:'A look inside the house.', labels:['Entrance & Exterior','Room Helene','Room Marille','Balcony & View','Kaffeetheke','Wachau Surroundings','Mini-Fridge','Wardrobe & TV','Fan'] }, ueber:{ title:'About Us', sub:'Two brothers, one house, one passion.', text:'Patrik and Daniel Sabata took over the house from their aunt – with deep respect for what was, and fresh ideas for what is to come. Here, Wachau hospitality meets modern thinking. We look forward to welcoming you.', patrik:'Patrik Sabata', daniel:'Daniel Sabata' }, bewertungen:{ title:'Guest Reviews', sub:'What our guests say.', items:[] }, kontakt:{ title:'Contact & Enquiry', sub:'Write to us – we reply quickly.', name:'Your Name', email:'Email Address', phone:'Phone (optional)', street:'Street & No.', zip:'ZIP', city:'City', country:'Country', arrival:'Arrival', departure:'Departure', room:'Room', roomOpts:['Please select','Room Helene','Room Marille','Either / both'], guests:'Number of Guests', message:'Message (optional)', send:'Send Enquiry', sent:'Thank you! We will be in touch soon.', direct:'Reach us directly:' }, anfahrt:{ title:'Getting Here', sub:'Siedlung Erlahof 47, 3620 Spitz an der Donau', parking:'Free parking directly at the house', train:'Spitz/Donau station: approx. 2 km (taxi available)', bike:'Danube Cycle Path: directly accessible · Bicycle parking available' }, footer:{ tagline:'Cosiness with a view – Wachau.', impressum:'Imprint', datenschutz:'Privacy Policy', rights:'© 2025 P&D Schöne Aussicht · Patrik & Daniel Sabata', social:'Follow us soon on:' } } }; // ── Utilities ───────────────────────────────────────────────────────────────── const useInView = (threshold = 0.1) => { const ref = React.useRef(); const [inView, setInView] = React.useState(false); React.useEffect(() => { const obs = new IntersectionObserver(([e]) => { if (e.isIntersecting) setInView(true); }, { threshold }); if (ref.current) obs.observe(ref.current); return () => obs.disconnect(); }, []); return [ref, inView]; }; const FadeIn = ({ children, delay = 0, up = true }) => { const [ref, inView] = useInView(); return (
{children}
); }; const Img = ({ label, h = 220, bg = '#2d5a35' }) => (
{label}
); const Stars = ({ n = 5 }) => {'★'.repeat(n)}; const SectionLabel = ({ children, light }) => (

{children}

); const H2 = ({ children, light }) => (

{children}

); const Sub = ({ children, light }) => (

{children}

); // ── Logo ────────────────────────────────────────────────────────────────────── const Logo = ({ white }) => { const c = white ? '#fff' : 'var(--green)'; const c2 = white ? 'rgba(255,255,255,0.5)' : 'var(--muted)'; return (
P&D
Schöne Aussicht
); }; // ── Header ──────────────────────────────────────────────────────────────────── const Header = ({ lang, setLang, t }) => { const [scrolled, setScrolled] = React.useState(false); const [open, setOpen] = React.useState(false); React.useEffect(() => { const fn = () => setScrolled(window.scrollY > 60); window.addEventListener('scroll', fn); return () => window.removeEventListener('scroll', fn); }, []); const go = (id) => { setOpen(false); document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' }); }; const links = [['zimmer',t.nav.zimmer],['fruehstueck',t.nav.fruehstueck],['verfuegbarkeit',t.nav.verfuegbarkeit],['galerie',t.nav.galerie],['ueber',t.nav.ueber],['kontakt',t.nav.kontakt]]; const bg = scrolled || open ? 'rgba(248,246,241,0.97)' : 'transparent'; return (
{open && (
{links.map(([id, label]) => ( ))}
)}
); }; // ── Hero ────────────────────────────────────────────────────────────────────── const Hero = ({ t }) => (
Wachau – Spitz an der Donau
Spitz an der Donau · Wachau

{t.hero.slogan}

{t.hero.sub}

{t.hero.scroll}
); // ── Zimmer ──────────────────────────────────────────────────────────────────── const ZIMMER_PHOTOS = { helene: [ { src: 'photos/helene-1.jpeg', label: 'Zimmer Helene – Übersicht' }, { src: 'photos/helene-2.jpeg', label: 'Zimmer Helene – Schlafbereich & Balkon' }, { src: 'photos/helene-3.jpeg', label: 'Zimmer Helene – Bad mit Dusche' }, { src: 'photos/helene-4.jpeg', label: 'Zimmer Helene – WC & Dachfenster' }, ], marille: [ { src: 'photos/marille-1.jpeg', label: 'Zimmer Marille – Übersicht' }, { src: 'photos/marille-2.jpeg', label: 'Zimmer Marille – Schlafbereich & Wachau-Blick' }, { src: 'photos/marille-3.jpeg', label: 'Zimmer Marille – Bad mit Dusche' }, { src: 'photos/marille-4.jpeg', label: 'Zimmer Marille – WC & Dachfenster' }, ], }; const ZimmerLightbox = ({ roomKey, roomName, onClose }) => { const photos = ZIMMER_PHOTOS[roomKey]; const [idx, setIdx] = React.useState(0); React.useEffect(() => { document.body.style.overflow = 'hidden'; const fn = (e) => { if (e.key === 'Escape') onClose(); if (e.key === 'ArrowRight') setIdx(i => (i+1) % photos.length); if (e.key === 'ArrowLeft') setIdx(i => (i-1+photos.length) % photos.length); }; window.addEventListener('keydown', fn); return () => { document.body.style.overflow = ''; window.removeEventListener('keydown', fn); }; }, []); const colors = ['#2d5a35','#3a5a7a','#c47a2a']; return (

{roomName}

e.stopPropagation()} style={{ maxWidth: 820, width: '100%', position: 'relative' }}> {photos[idx].src ? {photos[idx].label} :
{photos[idx].label}
}
{/* Dots */}
{photos.map((_, i) => (

{idx+1} / {photos.length}

); }; // ── Zahlen-Counter ──────────────────────────────────────────────────────────── const useCountUp = (target, duration = 1600, start = false) => { const [val, setVal] = React.useState(0); React.useEffect(() => { if (!start) return; let raf; const t0 = performance.now(); const tick = (now) => { const p = Math.min(1, (now - t0) / duration); const eased = 1 - Math.pow(1 - p, 3); setVal(Math.round(target * eased)); if (p < 1) raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, [start, target]); return val; }; const Stat = ({ value, suffix, label, start, delay = 0 }) => { const [go, setGo] = React.useState(false); React.useEffect(() => { if (start) { const id = setTimeout(() => setGo(true), delay); return () => clearTimeout(id); } }, [start, delay]); const n = useCountUp(value, 1600, go); return (
{n}{suffix}
{label}
); }; const StatsBar = ({ lang }) => { const ref = React.useRef(null); const [start, setStart] = React.useState(false); React.useEffect(() => { const io = new IntersectionObserver((entries) => { if (entries[0].isIntersecting) { setStart(true); io.disconnect(); } }, { threshold: 0.4 }); if (ref.current) io.observe(ref.current); return () => io.disconnect(); }, []); const stats = lang === 'de' ? [{v:2,s:'',l:'Zimmer'},{v:20,s:' m²',l:'Wohnfläche'},{v:100,s:' %',l:'Wachau-Blick'},{v:2026,s:'',l:'Frisch saniert'}] : [{v:2,s:'',l:'Rooms'},{v:20,s:' m²',l:'Living space'},{v:100,s:' %',l:'Wachau view'},{v:2026,s:'',l:'Newly renovated'}]; return (
{stats.map((s, i) => )}
); }; // ── Preistabelle ────────────────────────────────────────────────────────────── const PriceTable = ({ z, colorMap }) => { const [tier, setTier] = React.useState('1-3'); const tiers = Object.keys(z.tierLabels); return (

{z.priceTitle}

{z.priceSub}

{tiers.map((tk) => ( ))}
{z.seasons.map((s, i) => (
{s.label} · {s.tier}
{s.dates}
€ {s.prices[tier]}
{z.perNight}
{z.perNightSingle} € {s.single[tier]}
))}
▸ {z.extras}

{z.note}

); }; const Zimmer = ({ t }) => { const z = t.zimmer; const [lightbox, setLightbox] = React.useState(null); // 'helene' | 'marille' | null const colorMap = { green: 'var(--green)', blue: 'var(--blue)', apricot: 'var(--apricot)' }; const roomKeys = ['helene', 'marille']; return (
{lightbox && setLightbox(null)}/>}
{t.nav.zimmer}

{z.title}

{z.sub}
{[z.helene, z.marille].map((room, i) => (
setLightbox(roomKeys[i])} style={{ background: '#fff', borderRadius: 8, overflow: 'hidden', boxShadow: '0 2px 20px rgba(0,0,0,0.06)', transition: 'transform 0.3s, box-shadow 0.3s', cursor: 'pointer' }} onMouseEnter={e => { e.currentTarget.style.transform = 'translateY(-4px)'; e.currentTarget.style.boxShadow = '0 8px 36px rgba(0,0,0,0.1)'; }} onMouseLeave={e => { e.currentTarget.style.transform = 'none'; e.currentTarget.style.boxShadow = '0 2px 20px rgba(0,0,0,0.06)'; }}>
{ZIMMER_PHOTOS[roomKeys[i]][0].src ? {room.name} : }
e.currentTarget.style.background = 'rgba(0,0,0,0.18)'} onMouseLeave={e => e.currentTarget.style.background = 'rgba(0,0,0,0)'}>
Fotos ansehen

{room.name}

{room.desc}

{z.features.map((f, j) => {f})}
))}
); }; // ── Frühstück ───────────────────────────────────────────────────────────────── const Fruehstueck = ({ t }) => { const f = t.fruehstueck; return (
{t.nav.fruehstueck}

{f.title}

{f.sub}

{f.text}

{f.tag}
{f.title}
); }; // ── Aussicht ────────────────────────────────────────────────────────────────── const Aussicht = ({ t, lang }) => { const a = t.aussicht; const [tab, setTab] = React.useState(0); const lists = [a.events, a.sehen, a.sport]; const icons = [['🍷','🎵','🌅','🍑','🏃','🍂','🌾','🚪','🌿'], ['🍺','⚓','⛪','🏰','🏚️','🍇','🚪','⛴️','🏪'], ['🥾','🎾','🏊','🚲','🚴','🛴','🎪']]; return (
Wachau

{a.title}

{a.sub}
{/* Tabbed content */}
{a.tabs.map((label, i) => ( ))}
    {lists[tab].map((item, i) => (
  • {icons[tab][i] || '·'} {item}
  • ))}
{a.more} ↗
{/* Map + weather */}
); }; // ── Cookie-Consent System ──────────────────────────────────────────────────── const CONSENT_KEY = 'pd_consent_v2'; const CONSENT_VERSION = 2; const ConsentContext = React.createContext({ necessary: true, external: false, set: () => {} }); const useConsent = () => React.useContext(ConsentContext); const loadConsent = () => { try { const raw = JSON.parse(localStorage.getItem(CONSENT_KEY)); if (raw && raw.version === CONSENT_VERSION) return raw; } catch {} return null; }; const ConsentProvider = ({ children, lang }) => { const [consent, setConsent] = React.useState(() => loadConsent() || { necessary: true, external: false, decided: false, version: CONSENT_VERSION }); const [open, setOpen] = React.useState(() => !loadConsent()); const save = (next) => { const data = { ...consent, ...next, decided: true, version: CONSENT_VERSION }; setConsent(data); localStorage.setItem(CONSENT_KEY, JSON.stringify(data)); setOpen(false); }; React.useEffect(() => { window.__openConsent = () => setOpen(true); return () => { delete window.__openConsent; }; }, []); return ( setOpen(true) }}> {children} {open && setOpen(false)}/>} ); }; const btnPrimary = { background: 'var(--green)', color: '#fff', border: 'none', borderRadius: 5, padding: '10px 20px', fontFamily: 'var(--font-body)', fontSize: 13, fontWeight: 600, cursor: 'pointer', letterSpacing: '0.02em' }; const btnSecondary = { background: 'none', border: '1px solid var(--border)', color: 'var(--text)', borderRadius: 5, padding: '10px 16px', fontFamily: 'var(--font-body)', fontSize: 13, fontWeight: 500, cursor: 'pointer' }; const CategoryRow = ({ title, desc, on, onChange, disabled, label }) => (
{title}
{desc}
{disabled ? ( ✓ {label} ) : ( )}
); const ConsentBanner = ({ consent, onSave, lang, canDismiss, onClose }) => { const [showDetails, setShowDetails] = React.useState(false); const [external, setExternal] = React.useState(consent.external); const txt = lang === 'de' ? { title: 'Datenschutz-Einstellungen', intro: 'Diese Website nutzt nur das technisch Notwendige. Für die interaktive Karte und Live-Wetter werden Daten an externe Anbieter (OpenStreetMap, Open-Meteo) übertragen – nur mit Ihrer Zustimmung.', necessary: 'Notwendig', necessaryDesc: 'Speichert Ihre Spracheinstellungen und die Anzeige des Verfügbarkeitskalenders lokal in Ihrem Browser. Kein Tracking.', always: 'Immer aktiv', external: 'Externe Dienste', externalDesc: 'Karte (OpenStreetMap) und Live-Wetter (Open-Meteo). Beim Aktivieren wird Ihre IP-Adresse an diese Anbieter übertragen.', acceptAll: 'Alle akzeptieren', rejectAll: 'Nur Notwendige', save: 'Auswahl speichern', details: 'Einstellungen', backLink: 'Zurück', moreInfo: 'Datenschutzerklärung' } : { title: 'Privacy Settings', intro: 'This site only uses what is technically necessary. The interactive map and live weather transmit data to external providers (OpenStreetMap, Open-Meteo) — only with your consent.', necessary: 'Necessary', necessaryDesc: 'Stores your language settings and availability calendar locally in your browser. No tracking.', always: 'Always active', external: 'External services', externalDesc: 'Map (OpenStreetMap) and live weather (Open-Meteo). Enabling these transmits your IP address.', acceptAll: 'Accept all', rejectAll: 'Necessary only', save: 'Save selection', details: 'Customize', backLink: 'Back', moreInfo: 'Privacy policy' }; return (
{canDismiss && ( )}
🍪

{txt.title}

{txt.intro}

{showDetails && (
)}
{!showDetails ? ( <> ) : ( <> )}
); }; // ── Consent-Wetter (Open-Meteo) ─────────────────────────────────────────────── const WEATHER_ICONS = { 0:'☀️', 1:'🌤️', 2:'⛅', 3:'☁️', 45:'🌫️', 48:'🌫️', 51:'🌦️', 53:'🌦️', 55:'🌦️', 61:'🌧️', 63:'🌧️', 65:'🌧️', 71:'🌨️', 73:'🌨️', 75:'❄️', 80:'🌦️', 81:'🌧️', 82:'⛈️', 95:'⛈️', 96:'⛈️', 99:'⛈️' }; const WEATHER_TEXT_DE = { 0:'Klar', 1:'Heiter', 2:'Wolkig', 3:'Bedeckt', 45:'Nebel', 48:'Nebel', 51:'Nieselregen', 53:'Nieselregen', 55:'Nieselregen', 61:'Leichter Regen', 63:'Regen', 65:'Starker Regen', 71:'Schneefall', 73:'Schneefall', 75:'Starker Schnee', 80:'Regenschauer', 81:'Regenschauer', 82:'Heftige Schauer', 95:'Gewitter', 96:'Gewitter', 99:'Gewitter' }; const WEATHER_TEXT_EN = { 0:'Clear', 1:'Mainly clear', 2:'Partly cloudy', 3:'Overcast', 45:'Foggy', 48:'Foggy', 51:'Drizzle', 53:'Drizzle', 55:'Drizzle', 61:'Light rain', 63:'Rain', 65:'Heavy rain', 71:'Snow', 73:'Snow', 75:'Heavy snow', 80:'Rain showers', 81:'Rain showers', 82:'Violent showers', 95:'Thunderstorm', 96:'Thunderstorm', 99:'Thunderstorm' }; const ConsentWeather = ({ a, lang }) => { const consent = useConsent(); const allowed = consent.external; const [state, setState] = React.useState('idle'); const [data, setData] = React.useState(null); const load = async () => { setState('loading'); try { const res = await fetch('https://api.open-meteo.com/v1/forecast?latitude=48.3683&longitude=15.4136¤t=temperature_2m,weather_code,wind_speed_10m&timezone=Europe%2FVienna'); const j = await res.json(); setData(j.current); setState('loaded'); } catch { setState('error'); } }; React.useEffect(() => { if (allowed && state === 'idle') load(); }, [allowed]); if (state === 'loaded' && data) { const code = data.weather_code; const desc = (lang === 'de' ? WEATHER_TEXT_DE : WEATHER_TEXT_EN)[code] || '–'; return (
{WEATHER_ICONS[code] || '🌡️'}
{a.weather}
{Math.round(data.temperature_2m)} °C
{desc} · {Math.round(data.wind_speed_10m)} km/h
); } return (
🌤️
{a.weather}
{lang === 'de' ? 'Aktuelle Wetterdaten von Open-Meteo' : 'Current weather from Open-Meteo'}
); }; // ── Consent-Map (OpenStreetMap) ─────────────────────────────────────────────── const ConsentMap = ({ lang = 'de' }) => { const consent = useConsent(); const [load, setLoad] = React.useState(false); React.useEffect(() => { if (consent.external) setLoad(true); }, [consent.external]); if (load) { return (