/* ============================================================ BAN YARO — App Core Router, State-Management, Navigation, Initialisierung. ============================================================ */ const APP_VER = '1307'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt window.APP_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator) window.APP_VERSION = APP_VERSION; const IS_STAGING = location.hostname === 'staging.banyaro.app'; // Cache-Bust-Parameter nach Update-Reload sofort entfernen. // Flag MUSS vor replaceState gesetzt werden — index.html liest es danach. window._BY_SW_RELOAD = location.search.includes('_t='); if (window._BY_SW_RELOAD) history.replaceState(null, '', '/'); const App = (() => { // ---------------------------------------------------------- // PWA INSTALL PROMPT — frühzeitig abfangen, bevor es verloren geht // ---------------------------------------------------------- let _installPrompt = null; window.addEventListener('beforeinstallprompt', e => { e.preventDefault(); _installPrompt = e; }); // ---------------------------------------------------------- // STATE — zentraler App-Zustand // ---------------------------------------------------------- const state = { user: null, // eingeloggter User (oder null) dogs: [], // Hunde des Users activeDog: null, // aktuell gewählter Hund page: 'diary', // aktive Seite }; // ---------------------------------------------------------- // SEITENDEFINITIONEN // Jede Seite: { id, title, load() } // load() wird beim ersten Aufruf einmalig ausgeführt // ---------------------------------------------------------- const pages = { welcome: { title: 'Willkommen', module: null }, onboarding: { title: 'Einrichtung', module: null, requiresAuth: true }, diary: { title: 'Tagebuch', module: null, requiresAuth: true }, health: { title: 'Gesundheit', module: null, requiresAuth: true }, 'dog-profile': { title: 'Mein Hund', module: null, requiresAuth: true }, map: { title: 'Karte', module: null }, routes: { title: 'Routen', module: null }, events: { title: 'Events', module: null }, poison: { title: 'Giftköder-Alarm', module: null }, walks: { title: 'Gassi-Treffen', module: null, requiresAuth: true, requiresPro: true }, sitting: { title: 'Sitting', module: null, requiresAuth: true }, forum: { title: 'Forum', module: null }, wiki: { title: 'Wiki', module: null }, knigge: { title: 'Knigge', module: null }, movies: { title: 'Filme', module: null }, trainingsplaene: { title: 'Trainingspläne', module: null }, uebungen: { title: 'Übungsbibliothek', module: null }, notes: { title: 'Notizblock', module: null, requiresAuth: true, requiresPro: true }, 'erste-hilfe': { title: 'Erste Hilfe', module: null }, settings: { title: 'Einstellungen', module: null }, lost: { title: 'Verlorener Hund', module: null }, friends: { title: 'Freunde', module: null, requiresAuth: true }, chat: { title: 'Nachrichten', module: null, requiresAuth: true, requiresPro: true }, social: { title: 'Social Media', module: null, requiresAuth: true }, admin: { title: 'Admin', module: null, requiresAuth: true }, moderation: { title: 'Moderation', module: null, requiresAuth: true }, impressum: { title: 'Impressum', module: null }, datenschutz: { title: 'Datenschutz', module: null }, agb: { title: 'AGB', module: null }, widget: { title: 'Widget', module: null, requiresAuth: true }, notifications: { title: 'Aktuelles', module: null, requiresAuth: true }, breeder: { title: 'Züchter-Profil', module: null }, 'breeder-editor': { title: 'Profil bearbeiten', module: null, requiresAuth: true }, 'breeder-dashboard': { title: 'Züchter-Bereich', module: null, requiresAuth: true }, litters: { title: 'Wurfverwaltung', module: null, requiresAuth: true }, wurfboerse: { title: 'Wurfbörse', module: null }, zuchthunde: { title: 'Zuchtkartei', module: null, requiresAuth: true }, laeufi: { title: 'Läufigkeit', module: null, requiresAuth: true }, 'zucht-profil': { title: 'Hunde-Profil', module: null }, gruender: { title: '100 Gründer', module: null }, partner: { title: 'Unsere Partner', module: null }, 'partner-profil': { title: 'Partner-Profil', module: null, requiresAuth: true }, 'partner-dashboard': { title: 'Partner-Bereich', module: null, requiresAuth: true }, jobs: { title: 'Wir suchen dich', module: null }, expenses: { title: 'Ausgaben', module: null, requiresAuth: true }, recalls: { title: 'Rückrufe', module: null }, adoption: { title: 'Adoption', module: null }, playdate: { title: 'Playdate', module: null, requiresAuth: true, requiresPro: true }, wetter: { title: 'Wetter', module: null }, ernaehrung: { title: 'Ernährung', module: null, requiresAuth: true, requiresPro: true }, personality: { title: 'Persönlichkeitstest', module: null }, reise: { title: 'Reise mit Hund', module: null, requiresPro: true }, hilfe: { title: 'Hilfe & FAQ', module: null }, }; // ---------------------------------------------------------- // TIER-CHECK — Frontend-Pendant zu has_pro_access() in auth.py // ---------------------------------------------------------- function _hasPro(user) { if (!user) return false; const t = user.subscription_tier || 'standard'; // _test-Tiers simulieren ihren Tier ohne Admin-Override — so sieht Admin was echte User sehen if (t.endsWith('_test')) return ['pro_test','breeder_test'].includes(t); // Normale Prüfung: Admin/Mod/Social bekommen immer Pro if (user.rolle === 'admin' || user.rolle === 'moderator') return true; if (user.is_moderator || user.is_social_media) return true; if (user.is_partner) return true; // Partner (Multiplikatoren) bekommen Pro gratis return ['pro','breeder'].includes(t); } // ---------------------------------------------------------- // AUTH GUARD — Login-Gate Texte pro Seite // ---------------------------------------------------------- const AUTH_GATE = { diary: { icon: 'book-open', text: 'Dein persönliches Hunde-Tagebuch — Fotos, Notizen, Stimmungen. Nur für dich, privat und sicher.', preview: '/img/screenshots/screen-1.jpg' }, health: { icon: 'syringe', text: 'Impfungen, Tierarztbesuche, Gewicht und Medikamente — alles an einem Ort, immer abrufbar.', preview: '/img/screenshots/screen-3.jpg' }, 'dog-profile':{ icon: 'paw-print', text: 'Erstelle ein Profil für deinen Hund mit Foto, Bio, Chip-Nr. und NFC-Tag.', preview: null }, friends: { icon: 'users', text: 'Finde Hundebesitzer in deiner Nähe und tausche dich aus.', preview: null }, chat: { icon: 'chat-circle-dots', text: 'Schreibe direkt mit anderen Hundebesitzern.', preview: null }, walks: { icon: 'paw-print', text: 'Organisiere Gassi-Treffen und triff andere Hunde.', preview: '/img/screenshots/screen-5.jpg' }, sitting: { icon: 'house-line', text: 'Finde einen Dogsitter oder biete selbst Sitting an.', preview: null }, uebungen: { icon: 'target', text: 'Über 100 Übungen mit Anleitungen — tracke deinen Trainingsfortschritt.', preview: '/img/screenshots/screen-4.jpg' }, notes: { icon: 'note-pencil', text: 'Dein persönlicher Notizblock — für alles was du nicht vergessen willst.', preview: null }, playdate: { icon: 'paw-print', text: 'Finde Spielkameraden für deinen Hund in der Nähe und verabrede ein Treffen.', preview: null }, }; // ---------------------------------------------------------- // ROUTER // ---------------------------------------------------------- function navigate(pageId, pushHistory = true, params = {}) { if (!pages[pageId]) return; // Neue Version erkannt → nur aktualisieren wenn kein Bearbeitungsfenster offen ist // UND wenn nicht erst kürzlich force-update lief (Cooldown 10 Min) — verhindert Loop // bei mehreren schnellen Deploys oder iOS-PWA-Cache-Quirks. localStorage überlebt // App-Restarts (sessionStorage wäre bei PWA-Standalone-close weg). if (window._byUpdatePending) { const modalOpen = document.querySelector('#modal-container .modal-overlay') !== null; let lastForce = 0; try { lastForce = parseInt(localStorage.getItem('by_last_force_update') || '0', 10); } catch {} const cooldownActive = (Date.now() - lastForce) < 10 * 60 * 1000; // Während einer laufenden Aufzeichnung NIE force-updaten (Datenverlust). if (!modalOpen && !cooldownActive && !window._byRecording) { window._byUpdatePending = false; sessionStorage.setItem('by_updated_to', window._byNewVersion || ''); sessionStorage.setItem('by_update_target', pageId); try { localStorage.setItem('by_last_force_update', String(Date.now())); } catch {} location.href = '/force-update'; return; } // Modal offen oder Cooldown → bei nächstem Seitenwechsel versuchen } if (window.Worlds?._visible) window.Worlds.hide(); // Worlds-Zurück-Pfeil sichtbar machen, sobald ein Logged-In-User auf // einer „echten" Seite landet (auch bei Page-zu-Page-Sprung ohne vorigen // Worlds-Aufruf, z.B. Onboarding→dog-profile). Sonst kein Weg zurück // zu Welten + FAB. Für welcome/onboarding ausblenden. const _hideBackFor = new Set(['welcome', 'onboarding']); const _backEl = document.getElementById('worlds-back'); if (_backEl) { if (state.user && !_hideBackFor.has(pageId)) { _backEl.classList.add('worlds-back-visible'); } else { _backEl.classList.remove('worlds-back-visible'); } } // destroy() der aktuellen Seite aufrufen (z.B. FABs aufräumen) const activePage = document.querySelector('.page.active'); if (activePage) { const activeId = activePage.id?.replace('page-', ''); if (activeId && pages[activeId]?.module?.destroy) pages[activeId].module.destroy(); } // Aktive Seite ausblenden activePage?.classList.remove('active'); document.querySelectorAll('.nav-item.active, .sidebar-item.active') .forEach(el => el.classList.remove('active')); // Neue Seite einblenden document.getElementById(`page-${pageId}`)?.classList.add('active'); // Navigation markieren document.querySelectorAll(`[data-page="${pageId}"]`) .forEach(el => el.classList.add('active')); // Header-Titel setzen (nur wenn kein Dog-Switcher aktiv ist) const titleEl = document.getElementById('header-title'); if (titleEl) titleEl.textContent = pages[pageId].title; // History if (pushHistory) { history.pushState({ page: pageId }, '', `#${pageId}`); } state.page = pageId; UI.scrollTop(); // Seiten-Modul lazy laden (einmalig) _loadPage(pageId, params); } async function _loadPage(pageId, params = {}) { const page = pages[pageId]; // AUTH GUARD — geschützte Seiten für nicht-eingeloggte User → Welcome if (page.requiresAuth && !state.user) { navigate('welcome', false); return; } // Pro-Guard — nur wenn User eingeloggt aber kein Pro-Zugang if (page.requiresPro && state.user && !_hasPro(state.user)) { const container = document.querySelector(`#page-${pageId} .page-body`); if (container) { container.innerHTML = `

Ban Yaro Pro

Dieses Feature ist Teil von Ban Yaro Pro — verfügbar wenn wir die nächste Stufe zünden.
Du wirst benachrichtigt wenn es soweit ist.

Ban Yaro Pro enthält:
`; } return; } // Pro-Feature-Hinweis für Admins/Mods/Manager — Banner VOR .page-body, überlebt page.module.init() const pageEl = document.getElementById(`page-${pageId}`); if (pageEl) { pageEl.querySelector('#pro-role-banner')?.remove(); if (page.requiresPro && state.user) { const t = state.user.subscription_tier || 'standard'; const isRoleBased = !t.endsWith('_test') && !['pro','breeder'].includes(t) && (state.user.rolle === 'admin' || state.user.rolle === 'moderator' || state.user.is_moderator || state.user.is_social_media); if (isRoleBased) { const banner = document.createElement('div'); banner.id = 'pro-role-banner'; banner.style.cssText = 'background:#92400e;color:#fef3c7;padding:8px 16px;font-size:12px;font-weight:600;display:flex;align-items:center;gap:8px;'; banner.innerHTML = ` ⭐ Pro-Feature — Standard-User sehen diese Seite nicht`; pageEl.insertBefore(banner, pageEl.firstChild); } } } if (page.module) { const hasParams = params && Object.keys(params).length > 0; if (hasParams) { // Re-init mit neuen Params (z.B. Chat mit bestimmter Konversation) const container = document.querySelector(`#page-${pageId} .page-body`); page.module.init?.(container, state, params); } else { page.module.refresh?.(); } return; } // Guard: verhindert doppelten Load bei gleichzeitigen navigate()-Aufrufen if (page._loading) return; page._loading = true; const container = document.querySelector(`#page-${pageId} .page-body`); if (!container) { page._loading = false; return; } // Skeleton während Laden container.innerHTML = UI.skeleton(4); try { // Seiten-Script dynamisch laden await _loadScript(`/js/pages/${pageId}.js`); const mod = window[`Page_${pageId.replace(/-/g, '_')}`]; if (mod?.init) { await mod.init(container, state, params); page.module = mod; // Desktop: erste Inhalts-Div auf Standardbreite setzen _applyDesktopWidth(pageId, container); } else { // Platzhalter wenn Seite noch nicht gebaut container.innerHTML = UI.emptyState({ icon: '🚧', title: pages[pageId].title, text: 'Diese Seite ist noch in Entwicklung.', }); page.module = {}; // verhindert erneutes Laden } } catch (err) { // Echten Fehler NICHT verschlucken — sonst rätselt man bei jedem Seiten-Crash console.error(`[page-load] ${pageId} init fehlgeschlagen:`, err); const _offline = !navigator.onLine; container.innerHTML = UI.emptyState({ icon: _offline ? '📡' : '⚠️', title: pages[pageId].title, text: _offline ? 'Diese Seite ist offline nicht verfügbar. Bitte öffne sie einmal mit Internetverbindung, damit sie gecacht wird.' : 'Die Seite konnte nicht geladen werden. Das passiert manchmal nach einem Update.', action: _offline ? '' : ``, }); document.getElementById('page-retry-btn')?.addEventListener('click', () => { page._loading = false; navigate(pageId, false, params); }); // WICHTIG: page.module NICHT auf {} setzen. Bei einem echten Fehler (Netz-Blip, // SW-Update mitten in der Navigation, Race) würde {} die Seite für die ganze // Session tot stellen — der Guard `if (page.module)` käme nie mehr zum Laden. // So wird beim nächsten Aufruf neu versucht und ein transienter Fehler heilt sich. } finally { page._loading = false; } } // ---------------------------------------------------------- // DESKTOP WIDTH — einheitliche Breite auf großen Screens // ---------------------------------------------------------- const _FULLSCREEN_PAGES = new Set([ 'admin','map','chat','forum','wiki','ernaehrung','movies','wurfboerse', 'routes','walks','litters','zucht-profil','widget', ]); function _applyDesktopWidth(pageId, container) { if (window.innerWidth < 768) return; if (_FULLSCREEN_PAGES.has(pageId)) return; const first = container.querySelector(':scope > div'); if (first && !first.classList.contains('page-container') && !first.classList.contains('pc-desktop')) { first.classList.add('pc-desktop'); } } // ---------------------------------------------------------- // LOGIN GATE — wird statt Seiteninhalt angezeigt // ---------------------------------------------------------- function _renderLoginGate(container, pageId) { const gate = AUTH_GATE[pageId] || { icon: 'lock', text: 'Dieser Bereich ist nur für angemeldete Nutzer.' }; const title = pages[pageId]?.title || 'Dieser Bereich'; container.innerHTML = `
${gate.preview ? `
${UI.escape(title)}
Nur für Mitglieder
` : `
`}

${UI.escape(title)}

${UI.escape(gate.text)}

Karte, Wiki, Übungen, Erste Hilfe und mehr sind ohne Account zugänglich.

`; container.querySelector('#gate-login-btn')?.addEventListener('click', () => { navigate('settings'); }); container.querySelector('#gate-register-btn')?.addEventListener('click', () => { navigate('settings'); }); container.querySelector('#gate-install-hint')?.addEventListener('click', () => { navigate('welcome'); }); } function _loadScript(src) { // Versionierter URL damit SW/Browser-Cache bei jedem Deploy invalidiert wird const versioned = `${src}?v=${APP_VER}`; return new Promise((resolve, reject) => { if (document.querySelector(`script[src="${versioned}"]`)) { resolve(); return; } const s = document.createElement('script'); s.src = versioned; s.onload = resolve; s.onerror = reject; document.head.appendChild(s); }); } // ---------------------------------------------------------- // NAVIGATION EVENTS // ---------------------------------------------------------- function _bindNavigation() { // Globaler Bild-Fallback — ersetzt CSP-blockierte onerror-Attribute. // 'error' bubbelt nicht → Capture-Phase. Greift nur bei [data-fb]/[data-fb-src]. document.addEventListener('error', e => { const el = e.target; if (!el || el.tagName !== 'IMG') return; const fb = el.dataset.fb, altSrc = el.dataset.fbSrc; if (fb === undefined && altSrc === undefined) return; // Schritt 1: Alternative Quelle versuchen (z.B. _preview → Original / Platzhalter) if (altSrc && !el.dataset.fbTried) { el.dataset.fbTried = '1'; el.src = altSrc; return; } // Schritt 2: terminaler Fallback switch (fb) { case 'hide-parent': if (el.parentElement) el.parentElement.style.display = 'none'; break; case 'dim-grandparent': if (el.parentElement?.parentElement) el.parentElement.parentElement.style.opacity = '.4'; break; case 'sibling': el.style.display = 'none'; if (el.nextElementSibling) { el.nextElementSibling.classList.remove('hidden'); // .hidden hat !important el.nextElementSibling.style.display = 'flex'; } break; case 'show-el': { el.style.display = 'none'; const t = el.dataset.fbEl && document.getElementById(el.dataset.fbEl); if (t) { t.classList.remove('hidden'); t.style.display = 'flex'; } // .hidden hat !important break; } case 'emoji': if (el.parentElement) el.parentElement.innerHTML = `
${el.dataset.fbEmoji || '🐾'}
`; break; case 'initials': { const sz = parseInt(el.dataset.fbSize, 10) || 40; el.outerHTML = `
${el.dataset.fbInitials || ''}
`; break; } default: // 'hide' el.style.display = 'none'; el.classList.add('img-broken'); } }, true); // Video-Vorschau bei Hover (ersetzt CSP-blockierte onmouseenter/leave). //