/* ============================================================ BAN YARO — App Core Router, State-Management, Navigation, Initialisierung. ============================================================ */ const APP_VER = '654'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.3.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; 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 }, 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 }, '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 }, 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 }, widget: { title: 'Widget', module: null, requiresAuth: true }, notifications: { title: 'Aktuelles', module: null, requiresAuth: true }, breeder: { title: 'Züchter-Profil', module: null }, litters: { title: 'Wurfverwaltung', module: null, requiresAuth: true }, wurfboerse: { title: 'Wurfbörse', module: null }, zuchthunde: { title: 'Zuchtkartei', module: null, requiresAuth: true }, 'zucht-profil': { title: 'Hunde-Profil', module: null }, gruender: { title: '100 Gründer', module: null }, 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 }, wetter: { title: 'Wetter', module: null }, }; // ---------------------------------------------------------- // 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; if (window.Worlds?._visible) window.Worlds.hide(); // Aktive Seite ausblenden document.querySelector('.page.active')?.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; } 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; } 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 { container.innerHTML = UI.emptyState({ icon: '🚧', title: pages[pageId].title, text: 'Diese Seite ist noch in Entwicklung.', }); page.module = {}; } finally { page._loading = false; } } // ---------------------------------------------------------- // 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 = `
${UI.escape(gate.text)}
Karte, Wiki, Übungen, Erste Hilfe und mehr sind ohne Account zugänglich.
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 littersItem = document.getElementById('sidebar-litters'); if (littersItem) littersItem.style.display = isBreeder ? '' : 'none'; const zuchthundeItem = document.getElementById('sidebar-zuchthunde'); if (zuchthundeItem) zuchthundeItem.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'); } _showVerifyBanner(); _updateNotifBadge(); _updateChatBadge(); _checkNearbyAlerts(); setInterval(() => { _updateNotifBadge(); _updateChatBadge(); }, 30_000); setInterval(_checkNearbyAlerts, 5 * 60_000); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { _updateNotifBadge(); _updateChatBadge(); _checkNearbyAlerts(); 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() { const nav = document.getElementById('bottom-nav'); if (!nav) return; 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; const data = await API.get(`/alerts?lat=${lat}&lon=${lon}`); nav.classList.toggle('alert-poison', !!data.poison); nav.classList.toggle('alert-lost', !data.poison && !!data.lost); // Burger-Badge: Giftköder/Verlorener Hund in der Nähe document.getElementById('notif-nav-badge')?.classList.toggle('hidden', !data.poison && !data.lost); } catch { // Kein Standort verfügbar — kein Alert anzeigen } } 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); // Nicht eingeloggte User immer zur Welcome-Seite navigate('welcome', false); } 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 = `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.