banyaro/backend/static/js/app.js

1102 lines
47 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ============================================================
BAN YARO — App Core
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '942'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.5.1'; // ← semantische Version, wird bei make release gesetzt
const IS_STAGING = location.hostname === 'staging.banyaro.app';
// Cache-Bust-Parameter nach Update-Reload sofort entfernen
if (location.search.includes('_t=')) 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 },
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 },
laeufi: { title: 'Läufigkeit', 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, 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
if (window._byUpdatePending) {
const modalOpen = document.querySelector('#modal-container .modal-overlay') !== null;
if (!modalOpen) {
window._byUpdatePending = false;
sessionStorage.setItem('by_updated_to', window._byNewVersion || '');
sessionStorage.setItem('by_update_target', pageId); // Zielseite nach Update
location.href = '/force-update';
return;
}
// Modal offen → beim nächsten 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 = `
<div style="max-width:420px;margin:40px auto;padding:var(--space-6) var(--space-4);text-align:center">
<div style="font-size:3rem;margin-bottom:var(--space-4)">⭐</div>
<h2 style="font-size:var(--text-xl);font-weight:800;margin:0 0 var(--space-3)">Ban Yaro Pro</h2>
<p style="color:var(--c-text-secondary);margin:0 0 var(--space-6);line-height:1.6">
Dieses Feature ist Teil von Ban Yaro Pro — verfügbar wenn wir die nächste Stufe zünden.<br>
Du wirst benachrichtigt wenn es soweit ist.
</p>
<div style="background:var(--c-bg-card);border:1px solid var(--c-border);border-radius:var(--radius-lg);
padding:var(--space-4);text-align:left;font-size:var(--text-sm);color:var(--c-text-secondary)">
<div style="font-weight:700;margin-bottom:var(--space-2);color:var(--c-text)">Ban Yaro Pro enthält:</div>
<ul style="margin:0;padding-left:var(--space-5);display:flex;flex-direction:column;gap:var(--space-1)">
<li>Mehrere Hunde verwalten</li>
<li>KI-Trainer für personalisiertes Training</li>
<li>Direktnachrichten &amp; Freunde</li>
<li>Gassi-Treffen, Playdate, Ernährung, Reise</li>
<li>Notizblock</li>
</ul>
</div>
</div>`;
}
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 = `<svg style="width:14px;height:14px;flex-shrink:0" viewBox="0 0 256 256" fill="currentColor"><path d="M236.8,188.09,149.35,36.22a24.76,24.76,0,0,0-42.7,0L19.2,188.09a23.51,23.51,0,0,0,0,23.72A24.35,24.35,0,0,0,40.55,224h174.9a24.35,24.35,0,0,0,21.33-12.19A23.51,23.51,0,0,0,236.8,188.09ZM120,104a8,8,0,0,1,16,0v40a8,8,0,0,1-16,0Zm8,88a12,12,0,1,1,12-12A12,12,0,0,1,128,192Z"/></svg>
⭐ 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;
} 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 = `
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;
min-height:60vh;padding:var(--space-6) var(--space-5);text-align:center;gap:var(--space-4)">
<!-- Preview-Screenshot (wenn vorhanden) -->
${gate.preview ? `
<div style="position:relative;width:100%;max-width:280px;border-radius:var(--radius-lg);
overflow:hidden;box-shadow:0 8px 32px rgba(0,0,0,0.15)">
<img src="${gate.preview}" alt="${UI.escape(title)}"
style="width:100%;display:block;filter:blur(3px) brightness(0.7);transform:scale(1.05)">
<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center">
<div style="background:rgba(255,255,255,0.15);backdrop-filter:blur(4px);
border-radius:var(--radius-md);padding:var(--space-3) var(--space-4);
border:1px solid rgba(255,255,255,0.3)">
<svg style="width:28px;height:28px;color:#fff;display:block;margin:0 auto" aria-hidden="true">
<use href="/icons/phosphor.svg#lock-simple"></use>
</svg>
<span style="font-size:var(--text-xs);color:#fff;font-weight:700;display:block;margin-top:4px">
Nur für Mitglieder
</span>
</div>
</div>
</div>` : `
<div style="width:64px;height:64px;border-radius:50%;
background:var(--c-primary-subtle);
display:flex;align-items:center;justify-content:center">
<svg style="width:32px;height:32px;color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#${UI.escape(gate.icon)}"></use>
</svg>
</div>`}
<!-- Text -->
<div style="max-width:300px">
<h2 style="font-size:var(--text-xl);font-weight:var(--weight-bold);
color:var(--c-text);margin:0 0 var(--space-2)">
${UI.escape(title)}
</h2>
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);
line-height:1.6;margin:0">
${UI.escape(gate.text)}
</p>
</div>
<!-- CTAs -->
<div style="display:flex;flex-direction:column;gap:var(--space-3);width:100%;max-width:280px">
<button class="btn btn-primary" id="gate-register-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#user-plus"></use></svg>
Kostenlos registrieren
</button>
<button class="btn btn-ghost" id="gate-login-btn" style="font-size:var(--text-sm)">
Schon dabei? Anmelden
</button>
</div>
<!-- Hinweis was sonst frei ist -->
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:0">
Karte, Wiki, Übungen, Erste Hilfe und mehr sind ohne Account zugänglich.
</p>
<!-- Install-Hinweis -->
<button id="gate-install-hint"
style="background:none;border:none;cursor:pointer;padding:0;
font-size:var(--text-xs);color:var(--c-primary);
display:flex;align-items:center;gap:4px;text-decoration:underline">
<svg style="width:13px;height:13px" aria-hidden="true">
<use href="/icons/phosphor.svg#download-simple"></use>
</svg>
Ban Yaro als App installieren
</button>
</div>
`;
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
? `<button class="btn ${cls} w-full" data-quick="${quick}">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${icon}"></use></svg> ${label}
</button>`
: `<button class="btn ${cls} w-full" style="opacity:0.5" data-quick="auth-${quick}">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#lock"></use></svg> ${label}
</button>`;
UI.modal.open({
title: 'Schnellmeldung',
body: `
<div class="flex flex-col gap-3">
${authBtn('diary', 'btn-primary', 'book-open', 'Tagebucheintrag schreiben')}
<button class="btn btn-danger w-full" data-quick="poison">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning-octagon"></use></svg> Giftköder melden
</button>
${authBtn('walk', 'btn-secondary', 'paw-print', 'Gassi-Treffen erstellen')}
${authBtn('lost', 'btn-secondary', 'magnifying-glass','Verlorener Hund melden')}
</div>
${!loggedIn ? `<p style="font-size:var(--text-xs);color:var(--c-text-muted);text-align:center;margin-top:var(--space-3)">
<svg class="ph-icon" style="width:12px;height:12px" aria-hidden="true"><use href="/icons/phosphor.svg#info"></use></svg>
Einige Funktionen erfordern einen Account.
</p>` : ''}
`,
});
// 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');
}
// 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);
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() {
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 _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 <a href="mailto:support@banyaro.app" style="color:#fff;font-weight:700;text-decoration:underline">support@banyaro.app</a>',
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 = `<img src="${av}" style="width:100%;height:100%;object-fit:cover;border-radius:50%">`;
} else {
btn.innerHTML = `<svg class="ph-icon" aria-hidden="true" style="width:18px;height:18px;color:var(--c-primary)">
<use href="/icons/phosphor.svg#user"></use></svg>`;
}
btn.style.borderColor = 'var(--c-primary)';
btn.title = 'Mein Profil';
} else {
btn.innerHTML = `<svg class="ph-icon" aria-hidden="true" style="width:18px;height:18px;color:var(--c-text-muted)">
<use href="/icons/phosphor.svg#user"></use></svg>
<div style="position:absolute;bottom:0;right:0;width:10px;height:10px;border-radius:50%;
background:var(--c-danger);border:2px solid var(--c-surface)"></div>`;
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: `
<div style="display:flex;flex-direction:column;align-items:center;
gap:var(--space-4);text-align:center;padding:var(--space-2) 0">
<div style="width:64px;height:64px;border-radius:50%;
background:var(--c-primary-subtle);
display:flex;align-items:center;justify-content:center">
<svg style="width:32px;height:32px;color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#dog"></use>
</svg>
</div>
<div style="max-width:300px">
<p style="margin:0 0 var(--space-3);font-size:var(--text-sm);
color:var(--c-text-secondary);line-height:1.6">
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.
</p>
<p style="margin:0;font-size:var(--text-sm);
color:var(--c-text-secondary);line-height:1.6">
Fang jetzt an und leg ein Profil für deinen Hund an.
</p>
</div>
</div>
`,
footer: `
<button type="button" class="btn btn-primary" id="onboarding-start-btn">
${UI.icon('dog')} Meinen ersten Hund anlegen
</button>
<button type="button" class="btn btn-ghost" data-modal-close>Später</button>
`,
});
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 = `
<img class="sidebar-logo-img" src="/icons/icon-180.png" alt="Ban Yaro">
<span class="sidebar-logo-text">Ban Yaro</span>`;
} else {
el.innerHTML = `<span class="header-title" id="header-title">Ban Yaro</span>`;
}
return;
}
const avHtml = d => d.foto_url
? `<img src="${UI.escape(d.foto_url)}" alt="${UI.escape(d.name)}"
style="transform:scale(${d.foto_zoom||1}) translate(${d.foto_offset_x||0}%,${d.foto_offset_y||0}%)">`
: `<span>🐕</span>`;
// Inaktive Hunde rechts
let othersHtml = '';
if (others.length === 1) {
othersHtml = `
<div class="dog-sw-others">
<div class="dog-sw-other" data-dog-id="${others[0].id}" title="${UI.escape(others[0].name)}">
${avHtml(others[0])}
</div>
</div>`;
} else if (others.length >= 2) {
const visible = others.slice(0, 3);
const extraCount = others.length - 3;
othersHtml = `
<div class="dog-sw-others">
<div class="dog-sw-stack" id="dog-sw-stack-${ctxId}">
${visible.map((d, i) => `
<div class="dog-sw-other dog-sw-other--${i}" data-dog-id="${d.id}" title="${UI.escape(d.name)}">
${avHtml(d)}
</div>`).join('')}
${extraCount > 0 ? `<div class="dog-sw-more">+${extraCount}</div>` : ''}
</div>
<div class="dog-quickpick hidden" id="dog-qp-${ctxId}">
${others.map(d => `
<div class="dog-qp-item" data-dog-id="${d.id}">
<div class="dog-qp-av">${avHtml(d)}</div>
<span>${UI.escape(d.name)}</span>
</div>`).join('')}
</div>
</div>`;
}
const titleClass = ctxId === 'sb' ? 'sidebar-logo-text' : 'header-title';
el.innerHTML = `
<div class="dog-sw-active" id="dog-sw-active-${ctxId}" title="${UI.escape(dog.name)} bearbeiten"
style="position:relative">
${avHtml(dog)}
${dog.is_guest ? `<span style="position:absolute;bottom:-2px;right:-2px;
background:var(--c-primary);color:#fff;border-radius:var(--radius-full);
font-size:8px;font-weight:700;padding:1px 4px;line-height:1.4;
border:1px solid var(--c-surface)">GAST</span>` : ''}
</div>
<span class="${titleClass} dog-sw-title" style="cursor:pointer" title="${UI.escape(dog.name)} bearbeiten">${UI.escape(dog.name)}</span>
${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: `<strong>${UI.escape(info.owner_name)}</strong> möchte das Profil von
<strong>${UI.escape(info.dog_name)}</strong> 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: `<button class="btn btn-primary" onclick="App.navigate('settings')">Anmelden</button>`,
});
}
// ----------------------------------------------------------
// 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
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
}
});