/* ============================================================ BAN YARO — App Core Router, State-Management, Navigation, Initialisierung. ============================================================ */ const APP_VER = '1116'; // ← 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 }, 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 }, 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; 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; if (!modalOpen && !cooldownActive) { 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(); // 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 { 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.' : 'Diese Seite ist noch in Entwicklung.', }); page.module = {}; } 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() { // Bottom Nav + Sidebar Klicks document.addEventListener('click', e => { const item = e.target.closest('[data-page]'); if (item) { navigate(item.dataset.page); if (item.closest('#sidebar')) _closeSidebar(); return; } // Header-User-Button → Settings if (e.target.closest('#header-user-btn')) { navigate('settings'); return; } // Sidebar-Logo → Willkommensseite (oder Hund-Profil wenn Hund aktiv) // Hinweis: dog-sw-title hat eigenen Listener; dieser Fallback greift nur // wenn kein Hund aktiv ist (statischer "Ban Yaro"-Text ohne dog-sw-title). if (e.target.closest('.sidebar-logo-text') && !e.target.closest('.dog-sw-title')) { navigate('welcome'); _closeSidebar(); return; } // Sidebar-User (kein data-page, damit keine Aktiv-Markierung) if (e.target.closest('#sidebar-user')) { navigate('settings'); _closeSidebar(); return; } // + Button (Mobile Bottom-Nav + Desktop Sidebar) if (e.target.closest('#nav-add') || e.target.closest('#sidebar-add')) { _closeSidebar(); _showQuickAdd(); return; } // Hamburger-Menü (Mobile) if (e.target.closest('#header-menu-btn')) { _toggleSidebar(); return; } // Backdrop → Sidebar schließen if (e.target.closest('#sidebar-backdrop')) { _closeSidebar(); return; } // Sidebar-Item auf Mobile → schließen nach Navigation if (e.target.closest('#sidebar .sidebar-item')) { _closeSidebar(); } }); // Browser Back/Forward window.addEventListener('popstate', e => { _closeSidebar(); const page = e.state?.page || 'diary'; navigate(page, false); }); // Hash-Navigation wird in init() nach _checkAuth() behandelt (nicht hier), // damit kein doppelter _loadPage()-Aufruf entsteht. } // ---------------------------------------------------------- // MOBILE SIDEBAR DRAWER // ---------------------------------------------------------- function _toggleSidebar() { const sidebar = document.getElementById('sidebar'); const backdrop = document.getElementById('sidebar-backdrop'); const isOpen = sidebar?.classList.contains('open'); isOpen ? _closeSidebar() : _openSidebar(); } function _openSidebar() { document.getElementById('sidebar')?.classList.add('open'); document.getElementById('sidebar-backdrop')?.classList.add('visible'); } function _closeSidebar() { document.getElementById('sidebar')?.classList.remove('open'); document.getElementById('sidebar-backdrop')?.classList.remove('visible'); } // ---------------------------------------------------------- // SCHNELL-HINZUFÜGEN (+ Button) // ---------------------------------------------------------- function _showQuickAdd() { const loggedIn = !!state.user; const authBtn = (quick, cls, icon, label) => loggedIn ? `` : ``; UI.modal.open({ title: 'Schnellmeldung', body: `
${authBtn('diary', 'btn-primary', 'book-open', 'Tagebucheintrag schreiben')} ${authBtn('walk', 'btn-secondary', 'paw-print', 'Gassi-Treffen erstellen')} ${authBtn('lost', 'btn-secondary', 'magnifying-glass','Verlorener Hund melden')}
${!loggedIn ? `

Einige Funktionen erfordern einen Account.

` : ''} `, }); // Quick-Add Aktionen document.querySelector('#modal-container').addEventListener('click', e => { const btn = e.target.closest('[data-quick]'); if (!btn) return; const action = btn.dataset.quick; UI.modal.close(); // Kurzes Delay wegen iOS Ghost-Click: nach modal.close() feuert iOS // ~300ms später ein synthetisches Click-Event an derselben Position. // Ohne Delay trifft es das neu geöffnete Modal und schließt es sofort. setTimeout(() => { if (action.startsWith('auth-')) { navigate('settings'); return; } if (action === 'diary') { navigate('diary'); setTimeout(() => pages['diary'].module?.openNew?.(), 400); } if (action === 'poison') { navigate('poison'); pages['poison'].module?.openNew?.(); } if (action === 'walk') { navigate('walks'); pages['walks'].module?.openNew?.(); } if (action === 'lost') { navigate('lost'); setTimeout(() => pages['lost'].module?.openNew?.(), 400); } }, 350); }, { once: true }); } // ---------------------------------------------------------- // AUTH // ---------------------------------------------------------- async function _checkAuth() { try { const user = await API.auth.me(); state.user = user; await _onLoggedIn(); } catch { _onLoggedOut(); } } async function _onLoggedIn() { document.getElementById('sidebar-username').textContent = state.user.name; document.getElementById('header-login-btn')?.remove(); _updateHeaderUserBtn(true); // Admin/Moderator-Item einblenden const adminItem = document.getElementById('sidebar-admin'); if (adminItem) { const isMod = state.user.rolle === 'admin' || state.user.rolle === 'moderator' || state.user.is_moderator; adminItem.style.display = isMod ? '' : 'none'; } const moderationItem = document.getElementById('sidebar-moderation'); if (moderationItem) { const isMod = state.user.rolle === 'admin' || state.user.rolle === 'moderator' || state.user.is_moderator; moderationItem.style.display = isMod ? '' : 'none'; } const isBreeder = state.user.rolle === 'breeder' || state.user.rolle === 'admin'; const breederSection = document.getElementById('sidebar-breeder-section'); if (breederSection) breederSection.style.display = isBreeder ? '' : 'none'; const socialItem = document.getElementById('sidebar-social'); if (socialItem) { const isSocial = state.user.is_social_media || state.user.rolle === 'admin'; socialItem.style.display = isSocial ? '' : 'none'; } await _loadDogs(); // Eingeloggter User ohne Hund → Onboarding-Wizard (einmalig) if (state.dogs.length === 0 && !localStorage.getItem('by_onboarding_done')) { navigate('onboarding'); } // Abo abgelaufen mit mehreren Hunden → Haupthund auswählen (nur wenn explizit 1, nicht "0" string) if (state.user.needs_dog_selection === 1 && state.dogs.length > 1) { _showDogSelectionModal(); } // Theme aus DB-Profil übernehmen (überschreibt localStorage-Wert) _applyUserTheme(state.user); // Drei Welten nach Login starten (falls noch nicht initialisiert) if (window.Worlds) window.Worlds.init(state); _showVerifyBanner(); _showAndroidBetaBanner(); _updateNotifBadge(); _updateChatBadge(); _checkNearbyAlerts(); setInterval(() => { _updateNotifBadge(); _updateChatBadge(); }, 30_000); setInterval(_checkNearbyAlerts, 5 * 60_000); // App-Heartbeat: last_seen aktualisieren (Nutzungsfrequenz für Admin) const _sendHeartbeat = () => API.post('/auth/heartbeat', {}).catch(() => {}); _sendHeartbeat(); setInterval(_sendHeartbeat, 5 * 60_000); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { _updateNotifBadge(); _updateChatBadge(); _checkNearbyAlerts(); _sendHeartbeat(); if (state.page === 'chat') { pages['chat']?.module?.refresh?.(); } } }); const pendingInvite = sessionStorage.getItem('pending_invite'); if (pendingInvite) { sessionStorage.removeItem('pending_invite'); _handleInvite(pendingInvite); } } async function _checkNearbyAlerts() { try { const pos = await new Promise((resolve, reject) => navigator.geolocation.getCurrentPosition(resolve, reject, { timeout: 5000, maximumAge: 120_000 }) ); const { latitude: lat, longitude: lon } = pos.coords; await API.get(`/alerts?lat=${lat}&lon=${lon}`); // Standort-Update für Push-Subscriptions (serverseitig in /alerts gespeichert) } catch { // Kein Standort verfügbar — ignorieren } } async function _updateNotifBadge() { if (!state.user) return; try { const b = await API.notifications.badge(); document.getElementById('chat-nav-badge')?.classList.toggle('hidden', !b.personal); } catch { /* ignorieren */ } } async function _updateChatBadge() { if (!state.user) return; try { const convs = await API.chat.conversations(); const total = convs.reduce((s, c) => s + (c.unread_count || 0), 0); const badge = document.getElementById('chat-badge'); if (badge) { badge.textContent = total; badge.style.display = total > 0 ? '' : 'none'; } } catch { /* ignorieren */ } } function _onLoggedOut() { state.user = null; state.dogs = []; state.activeDog = null; // Gecachte Module geschützter Seiten leeren, damit sie beim nächsten Login // sauber neu initialisiert werden statt den alten Zustand zu refreshen. Object.entries(pages).forEach(([, page]) => { if (page.requiresAuth) page.module = null; }); _renderDogSwitcher(); _updateHeaderUserBtn(false); window.Worlds?.hide(); document.getElementById('worlds-back')?.classList.remove('worlds-back-visible'); navigate('welcome', false); } function _applyUserTheme(user) { const theme = user?.preferred_theme; if (!theme || theme === 'system') { _syncThemeColor(); return; } localStorage.setItem('by_theme', theme); const html = document.documentElement; if (theme === 'dark') html.setAttribute('data-theme', 'dark'); else if (theme === 'light') html.setAttribute('data-theme', 'light'); _syncThemeColor(); } function _syncThemeColor() { const isAndroid = /android/i.test(navigator.userAgent); const isDark = isAndroid || document.documentElement.getAttribute('data-theme') === 'dark' || (window.matchMedia('(prefers-color-scheme: dark)').matches && document.documentElement.getAttribute('data-theme') !== 'light'); document.getElementById('meta-theme-color')?.setAttribute('content', isDark ? '#0f1623' : '#C4843A'); } function _showDogSelectionModal() { const dogs = state.dogs; const optionHtml = dogs.map(d => ` `).join(''); UI.modal.open({ title: 'Haupthund auswählen', body: `

Dein Abo ist ausgelaufen. Wähle einen Haupthund für deinen kostenlosen Account. Alle anderen Hunde-Profile bleiben vollständig gespeichert — du kannst sie nach einem erneuten Upgrade wieder aktivieren.

${optionHtml}
`, footer: ` ` }); document.getElementById('dog-select-confirm')?.addEventListener('click', async () => { const chosen = document.querySelector('[name="select-dog"]:checked')?.value; if (!chosen) { UI.toast.warning('Bitte einen Hund auswählen.'); return; } const btn = document.getElementById('dog-select-confirm'); btn.disabled = true; btn.textContent = '…'; try { await API.auth.selectPrimaryDog(parseInt(chosen)); state.user.needs_dog_selection = 0; state.activeDog = state.dogs.find(d => String(d.id) === chosen) || state.dogs[0]; localStorage.setItem('by_active_dog', String(state.activeDog.id)); UI.modal.close(); UI.toast.success('Haupthund festgelegt.'); _renderDogSwitcher(); } catch (e) { btn.disabled = false; btn.textContent = 'Auswahl bestätigen'; UI.toast.error(e.message || 'Fehler.'); } }); } function _showAndroidBetaBanner() { // Nur auf Android, nur einmalig, nur für eingeloggte Nutzer if (!/android/i.test(navigator.userAgent)) return; if (localStorage.getItem('by_android_beta_dismissed')) return; setTimeout(() => { UI.toast.info( '📱 Play Store Beta: Hilf uns beim Android-Test! Schreib an support@banyaro.app', 20000 ); localStorage.setItem('by_android_beta_dismissed', '1'); }, 5000); } function _showVerifyBanner() { const banner = document.getElementById('verify-banner'); if (!banner) return; if (!state.user || state.user.email_verified) { banner.style.display = 'none'; return; } const dismissed = sessionStorage.getItem('by_verify_dismissed'); if (dismissed) return; banner.style.display = 'flex'; document.getElementById('verify-resend-btn')?.addEventListener('click', async () => { await API.post('/auth/resend-verification', { email: state.user.email }); UI.toast.success('Bestätigungs-Mail erneut gesendet.'); }, { once: true }); document.getElementById('verify-banner-close')?.addEventListener('click', () => { banner.style.display = 'none'; sessionStorage.setItem('by_verify_dismissed', '1'); }, { once: true }); } function _updateHeaderUserBtn(loggedIn) { const btn = document.getElementById('header-user-btn'); const icon = document.getElementById('header-user-icon'); if (!btn) return; if (loggedIn) { const av = state.user?.avatar_url; if (av) { btn.innerHTML = ``; } else { btn.innerHTML = ``; } btn.style.borderColor = 'var(--c-primary)'; btn.title = 'Mein Profil'; } else { btn.innerHTML = `
`; btn.style.borderColor = 'var(--c-border)'; btn.title = 'Anmelden'; } } async function _loadDogs() { try { state.dogs = await API.dogs.list(); if (state.dogs.length > 0) { // Zuletzt aktiven Hund aus localStorage wiederherstellen const savedId = parseInt(localStorage.getItem('by_active_dog') || '0'); state.activeDog = state.dogs.find(d => d.id === savedId) || state.dogs[0]; } _renderDogSwitcher(); _notifyDogChange(); } catch { /* kein Hund vorhanden */ } } // ---------------------------------------------------------- // ONBOARDING — Willkommens-Modal für neue User // ---------------------------------------------------------- function _showOnboardingModal() { UI.modal.open({ title: `${UI.icon('paw-print')} Willkommen bei Ban Yaro!`, body: `

Schön, dass du dabei bist! Ban Yaro hilft dir, alles rund um deinen Hund im Blick zu behalten — Spaziergänge, Gesundheit, Termine und vieles mehr.

Fang jetzt an und leg ein Profil für deinen Hund an.

`, footer: ` `, }); document.getElementById('onboarding-start-btn')?.addEventListener('click', () => { UI.modal.close(); navigate('dog-profile'); }); } function _notifyDogChange() { Object.values(pages).forEach(p => p.module?.onDogChange?.(state.activeDog)); } // ---------------------------------------------------------- // HUNDE-SWITCHER (Header Mobile + Sidebar-Logo Desktop) // ---------------------------------------------------------- function _renderDogSwitcher() { _renderSwitcherInto(document.getElementById('header-dog-switcher'), 'hdr'); _renderSwitcherInto(document.getElementById('sidebar-dog-switcher'), 'sb'); } function _renderSwitcherInto(el, ctxId) { if (!el) return; const dog = state.activeDog; const others = state.dogs.filter(d => d.id !== dog?.id); // Fallback: kein User oder kein Hund → Standardlogo if (!state.user || !dog) { if (ctxId === 'sb') { el.innerHTML = ` Ban Yaro Ban Yaro`; } else { el.innerHTML = `Ban Yaro`; } return; } const avHtml = d => d.foto_url ? `${UI.escape(d.name)}` : `🐕`; // Inaktive Hunde rechts let othersHtml = ''; if (others.length === 1) { othersHtml = `
${avHtml(others[0])}
`; } else if (others.length >= 2) { const visible = others.slice(0, 3); const extraCount = others.length - 3; othersHtml = `
${visible.map((d, i) => `
${avHtml(d)}
`).join('')} ${extraCount > 0 ? `
+${extraCount}
` : ''}
`; } const titleClass = ctxId === 'sb' ? 'sidebar-logo-text' : 'header-title'; el.innerHTML = `
${avHtml(dog)} ${dog.is_guest ? `GAST` : ''}
${UI.escape(dog.name)} ${othersHtml}`; // Klick aktiver Avatar → Welcome-Seite; Klick Name → Hund-Profil el.querySelector(`#dog-sw-active-${ctxId}`)?.addEventListener('click', () => { navigate('welcome'); }); el.querySelector(`.dog-sw-title`)?.addEventListener('click', () => { navigate('dog-profile'); if (ctxId === 'sb') _closeSidebar(); }); // 1 anderer Hund → direkter Tausch if (others.length === 1) { el.querySelector('.dog-sw-other')?.addEventListener('click', () => { setActiveDog(others[0].id); }); } // 2+ andere Hunde → Stack klick öffnet Quickpicker if (others.length >= 2) { const stack = el.querySelector(`#dog-sw-stack-${ctxId}`); const qp = el.querySelector(`#dog-qp-${ctxId}`); stack?.addEventListener('click', e => { e.stopPropagation(); // Alle anderen Quickpicker schließen document.querySelectorAll('.dog-quickpick').forEach(q => { if (q !== qp) q.classList.add('hidden'); }); qp?.classList.toggle('hidden'); }); el.querySelectorAll('.dog-qp-item').forEach(item => { item.addEventListener('click', e => { e.stopPropagation(); setActiveDog(parseInt(item.dataset.dogId)); qp?.classList.add('hidden'); }); }); } } // Quickpicker schließen bei Klick außerhalb document.addEventListener('click', () => { document.querySelectorAll('.dog-quickpick').forEach(q => q.classList.add('hidden')); }); function setActiveDog(dogId) { const dog = state.dogs.find(d => d.id === dogId); if (!dog || dog.id === state.activeDog?.id) return; state.activeDog = dog; localStorage.setItem('by_active_dog', String(dogId)); // SW-Cache für hund-spezifische Daten invalidieren navigator.serviceWorker?.controller?.postMessage({ type: 'INVALIDATE_CACHE', paths: ['/api/training/progress', '/api/training/plan-progress', '/api/training/suggestions', `/api/dogs/${dogId}/welcome-dashboard`], }); _renderDogSwitcher(); _notifyDogChange(); } // ---------------------------------------------------------- // INITIALISIERUNG // ---------------------------------------------------------- async function init() { _syncThemeColor(); // Statusleisten-Farbe sofort setzen // Spezielle Hash-Parameter → in App bleiben (kein /info-Redirect) const _rawHash = location.hash.replace('#', ''); const _hashQuery = _rawHash.split('?')[1] || ''; const _hashP = new URLSearchParams(_hashQuery); if (_hashP.get('verified') || _hashP.get('token') || location.pathname.startsWith('/teilen/')) { sessionStorage.setItem('by_stay_in_app', '1'); } // Referral-Code aus URL ?ref=CODE speichern const urlParams = new URLSearchParams(window.location.search); const refCode = urlParams.get('ref'); if (refCode) { sessionStorage.setItem('by_ref_code', refCode.toUpperCase()); // URL bereinigen ohne Reload history.replaceState({}, '', window.location.pathname + window.location.hash); } _bindNavigation(); // Nach stillem Update: Toast + zur ursprünglichen Zielseite navigieren const updatedTo = sessionStorage.getItem('by_updated_to'); if (updatedTo) { sessionStorage.removeItem('by_updated_to'); const target = sessionStorage.getItem('by_update_target'); sessionStorage.removeItem('by_update_target'); setTimeout(() => { UI.toast?.success(`App auf v${updatedTo} aktualisiert`); if (target && pages[target]) navigate(target, false); }, 800); } try { localStorage.removeItem('by_wissen_open'); } catch (_) {} _initVersionCheck(); await _checkAuth(); // Einladungslink /teilen/{token} → direkt annehmen const inviteMatch = location.pathname.match(/^\/teilen\/([A-Za-z0-9_-]+)$/); if (inviteMatch) { const token = inviteMatch[1]; navigate('diary', false); _handleInvite(token); return; } // Erste Seite laden: Hash aus URL oder Standard 'diary'. const rawHash = location.hash.replace('#', ''); const [hashPage, hashQuery] = rawHash.split('?'); const hashParams = {}; if (hashQuery) { new URLSearchParams(hashQuery).forEach((v, k) => { hashParams[k] = isNaN(v) ? v : Number(v); }); } // Passwort-Reset: #reset-password?token=xxx if (hashPage === 'reset-password' && hashParams.token) { sessionStorage.setItem('by_reset_token', hashParams.token); history.replaceState(null, '', '/'); navigate('settings', false); return; } // E-Mail-Verifikation: Redirect von /api/auth/verify-email/{token} if (hashParams.verified === '1' || hashParams.verified === 1) { if (state.user) state.user.email_verified = 1; document.getElementById('verify-banner')?.style?.setProperty('display', 'none'); UI.toast.success('E-Mail-Adresse erfolgreich bestätigt!'); history.replaceState(null, '', '/'); } else if (hashParams.verified === 'error') { UI.toast.error('Ungültiger oder abgelaufener Bestätigungs-Link.'); history.replaceState(null, '', '/'); } const startPage = (hashPage && pages[hashPage]) ? hashPage : 'welcome'; navigate(state.user ? startPage : 'welcome', false, hashParams); if (window.Worlds && state.user) window.Worlds.init(state); } async function _handleInvite(token) { try { const info = await API.sharing.info(token); if (info.accepted_at) { UI.toast.success(`Du hast bereits Zugriff auf ${info.dog_name}.`); history.replaceState(null, '', '/'); return; } if (!state.user) { sessionStorage.setItem('pending_invite', token); history.replaceState(null, '', '/'); navigate('settings', false); UI.toast.info('Bitte melde dich an, um die Einladung anzunehmen.'); return; } const ok = await UI.modal.confirm({ message: `${UI.escape(info.owner_name)} möchte das Profil von ${UI.escape(info.dog_name)} mit dir teilen (${info.role === 'editor' ? 'Lesen & Schreiben' : 'Nur lesen'}). Möchtest du die Einladung annehmen?`, }); if (!ok) { history.replaceState(null, '', '/'); return; } await API.sharing.accept(token); state.dogs = await API.dogs.list(); const newDog = state.dogs.find(d => d.name === info.dog_name); if (newDog) { state.activeDog = newDog; localStorage.setItem('by_active_dog', String(newDog.id)); _renderDogSwitcher(); } history.replaceState(null, '', '/'); UI.toast.success(`${UI.escape(info.dog_name)} wurde deiner Liste hinzugefügt!`); } catch (e) { UI.toast.error(e.message || 'Einladungslink ungültig.'); history.replaceState(null, '', '/'); } } // ---------------------------------------------------------- // AUTH-GATE HELPER — einheitlicher "Bitte anmelden"-Block // ---------------------------------------------------------- function requireAuth(container, { icon = 'user', text = 'Melde dich an, um diese Funktion zu nutzen.' } = {}) { container.innerHTML = UI.emptyState({ icon: UI.icon(icon), title: 'Anmelden erforderlich', text, action: ``, }); } // ---------------------------------------------------------- // VERSION-CHECK — stilles Auto-Update beim nächsten Seitenwechsel function _initVersionCheck() { /* X-App-Version Header in api.js übernimmt das */ } // ---------------------------------------------------------- // ÖFFENTLICHE API // (andere Module können App.state, App.navigate etc. nutzen) // ---------------------------------------------------------- function callModule(pageId, method, ...args) { navigate(pageId); setTimeout(() => pages[pageId]?.module?.[method]?.(...args), 500); } return { init, navigate, callModule, state, setActiveDog, hasPro: (user) => _hasPro(user ?? state.user), renderDogSwitcher: _renderDogSwitcher, getInstallPrompt: () => _installPrompt, requireAuth, showOnboarding: _showOnboardingModal, updateNotifBadge: _updateNotifBadge, checkNearbyAlerts: _checkNearbyAlerts, loadScript: _loadScript }; })(); window.App = App; // Worlds kann App.navigate() aufrufen // App starten // Prioritäts-Seiten im Hintergrund vorladen (1s nach Start) window.addEventListener('load', () => { setTimeout(() => { if (!navigator.onLine) return; // Page-Scripts cachen [ 'admin','erste-hilfe','diary','map','walks','routes','poison','lost', 'expenses','wetter','forum','health','uebungen','trainingsplaene','notes', ].forEach(page => { const key = `Page_${page.replace(/-/g,'_')}`; if (!window[key]) fetch(`/js/pages/${page}.js?v=${APP_VER}`).catch(() => {}); }); }, 1000); }); document.addEventListener('DOMContentLoaded', () => { App.init(); if (IS_STAGING) { document.title = '⚗️ ' + document.title; // Nach App.init() Styles direkt setzen — sonst überschreibt init sie const _applyStaging = () => { const nav = document.getElementById('bottom-nav'); if (!nav) return; nav.style.cssText += ';background:#2d1b69!important;border-top-color:#7c3aed!important;box-shadow:0 -2px 12px rgba(124,58,237,0.4)!important'; nav.querySelectorAll('.nav-item-label').forEach(el => el.style.color = 'rgba(196,181,253,0.75)'); nav.querySelectorAll('.plus-btn, .nav-item-center button').forEach(el => el.style.background = '#7c3aed'); }; _applyStaging(); setTimeout(_applyStaging, 400); // nochmal nach vollständigem Render } });