PRIORITY_PAGES erweitert auf 10 Seiten (war 8): zusätzlich health.js, notes.js, expenses.js. admin.js raus — 233 KB, offline irrelevant. Damit funktionieren offline ohne vorherigen Besuch: Tagebuch · Gesundheit · Karte · Gassi · Erste Hilfe · Notizblock Ausgaben · Routen · Giftköder · Vermisst. Offline-Indikator Step 2 prüft jetzt alle 7 vom User genannten Seiten (diary, map, walks, erste-hilfe, notes, expenses, routes) — Pfote wird grün wenn alle im Static-Cache sind. CSS-Färbung umgestellt: nur stroke (Linie) wird grün, kein fill mehr. Pfote behält ihre offene Optik, nur die Outlines wechseln von weiß zu Grün.
1208 lines
52 KiB
JavaScript
1208 lines
52 KiB
JavaScript
/* ============================================================
|
|
BAN YARO — App Core
|
|
Router, State-Management, Navigation, Initialisierung.
|
|
============================================================ */
|
|
|
|
const APP_VER = '1085'; // ← 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
|
|
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 & 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;
|
|
// 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 = `
|
|
<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');
|
|
}
|
|
|
|
// 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 => `
|
|
<label style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-3);
|
|
border-radius:var(--radius-md);border:1.5px solid var(--c-border);
|
|
cursor:pointer;margin-bottom:var(--space-2)">
|
|
<input type="radio" name="select-dog" value="${d.id}" style="width:18px;height:18px">
|
|
${d.foto_url
|
|
? `<img src="${UI.escape(d.foto_url)}" style="width:40px;height:40px;border-radius:50%;object-fit:cover">`
|
|
: `<div style="width:40px;height:40px;border-radius:50%;background:var(--c-border);display:flex;align-items:center;justify-content:center">🐕</div>`}
|
|
<div>
|
|
<div style="font-weight:600;font-size:var(--text-sm)">${UI.escape(d.name)}</div>
|
|
${d.rasse ? `<div style="font-size:var(--text-xs);color:var(--c-text-muted)">${UI.escape(d.rasse)}</div>` : ''}
|
|
</div>
|
|
</label>`).join('');
|
|
|
|
UI.modal.open({
|
|
title: 'Haupthund auswählen',
|
|
body: `
|
|
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-4)">
|
|
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.
|
|
</p>
|
|
<form id="dog-select-form">${optionHtml}</form>`,
|
|
footer: `
|
|
<button id="dog-select-confirm" class="btn btn-primary" style="width:100%">
|
|
Auswahl bestätigen
|
|
</button>`
|
|
});
|
|
|
|
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 <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
|
|
// 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
|
|
}
|
|
});
|