/* ============================================================
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:
- Mehrere Hunde verwalten
- KI-Trainer für personalisiertes Training
- Direktnachrichten & Freunde
- Gassi-Treffen, Playdate, Ernährung, Reise
- Notizblock
`;
}
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)}
${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).
//