Rene: QR-Stats gehören nicht ins öffentliche Profil, eigene Seite fehlte.
Neue Seite 'Partner-Bereich' (Welten-Chip 🤝 zwischen Moderation und Admin,
role:partner — sichtbar für is_partner + Admin; _mergeDefaults reicht den
Chip an bestehende Welt-Configs nach):
- Einladungscode groß + Link-kopieren-Button
- Kacheln: Registrierungen gesamt / diesen Monat / unbestätigt
- QR-Kontingente mit Einzel-Code-Status (aus partner-profil.js hierher verschoben)
- Profil-Status-Karte (Entwurf/Prüfung/frei) mit Sprung zum Editor
Backend: GET /partner/my-stats (Codes mit Zahlen + Profil-Status).
Settings-Partner-Karte: zwei Buttons (Partner-Bereich primär, Profil sekundär);
Dank-Mail-CTA zeigt auf #partner-dashboard. Suite: 52 passed.
1331 lines
58 KiB
JavaScript
1331 lines
58 KiB
JavaScript
/* ============================================================
|
|
BAN YARO — App Core
|
|
Router, State-Management, Navigation, Initialisierung.
|
|
============================================================ */
|
|
|
|
const APP_VER = '1261'; // ← 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 },
|
|
'partner-dashboard': { title: 'Partner-Bereich', module: null, requiresAuth: true },
|
|
jobs: { title: 'Wir suchen dich', module: null },
|
|
expenses: { title: 'Ausgaben', module: null, requiresAuth: true },
|
|
recalls: { title: 'Rückrufe', module: null },
|
|
adoption: { title: 'Adoption', module: null },
|
|
playdate: { title: 'Playdate', module: null, requiresAuth: true, requiresPro: true },
|
|
wetter: { title: 'Wetter', module: null },
|
|
ernaehrung: { title: 'Ernährung', module: null, requiresAuth: true, requiresPro: true },
|
|
personality: { title: 'Persönlichkeitstest', module: null },
|
|
reise: { title: 'Reise mit Hund', module: null, requiresPro: true },
|
|
hilfe: { title: 'Hilfe & FAQ', module: null },
|
|
};
|
|
|
|
// ----------------------------------------------------------
|
|
// TIER-CHECK — Frontend-Pendant zu has_pro_access() in auth.py
|
|
// ----------------------------------------------------------
|
|
function _hasPro(user) {
|
|
if (!user) return false;
|
|
const t = user.subscription_tier || 'standard';
|
|
// _test-Tiers simulieren ihren Tier ohne Admin-Override — so sieht Admin was echte User sehen
|
|
if (t.endsWith('_test')) return ['pro_test','breeder_test'].includes(t);
|
|
// Normale Prüfung: Admin/Mod/Social bekommen immer Pro
|
|
if (user.rolle === 'admin' || user.rolle === 'moderator') return true;
|
|
if (user.is_moderator || user.is_social_media) return true;
|
|
if (user.is_partner) return true; // Partner (Multiplikatoren) bekommen Pro gratis
|
|
return ['pro','breeder'].includes(t);
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
// AUTH GUARD — Login-Gate Texte pro Seite
|
|
// ----------------------------------------------------------
|
|
const AUTH_GATE = {
|
|
diary: { icon: 'book-open', text: 'Dein persönliches Hunde-Tagebuch — Fotos, Notizen, Stimmungen. Nur für dich, privat und sicher.', preview: '/img/screenshots/screen-1.jpg' },
|
|
health: { icon: 'syringe', text: 'Impfungen, Tierarztbesuche, Gewicht und Medikamente — alles an einem Ort, immer abrufbar.', preview: '/img/screenshots/screen-3.jpg' },
|
|
'dog-profile':{ icon: 'paw-print', text: 'Erstelle ein Profil für deinen Hund mit Foto, Bio, Chip-Nr. und NFC-Tag.', preview: null },
|
|
friends: { icon: 'users', text: 'Finde Hundebesitzer in deiner Nähe und tausche dich aus.', preview: null },
|
|
chat: { icon: 'chat-circle-dots', text: 'Schreibe direkt mit anderen Hundebesitzern.', preview: null },
|
|
walks: { icon: 'paw-print', text: 'Organisiere Gassi-Treffen und triff andere Hunde.', preview: '/img/screenshots/screen-5.jpg' },
|
|
sitting: { icon: 'house-line', text: 'Finde einen Dogsitter oder biete selbst Sitting an.', preview: null },
|
|
uebungen: { icon: 'target', text: 'Über 100 Übungen mit Anleitungen — tracke deinen Trainingsfortschritt.', preview: '/img/screenshots/screen-4.jpg' },
|
|
notes: { icon: 'note-pencil', text: 'Dein persönlicher Notizblock — für alles was du nicht vergessen willst.', preview: null },
|
|
playdate: { icon: 'paw-print', text: 'Finde Spielkameraden für deinen Hund in der Nähe und verabrede ein Treffen.', preview: null },
|
|
};
|
|
|
|
// ----------------------------------------------------------
|
|
// ROUTER
|
|
// ----------------------------------------------------------
|
|
function navigate(pageId, pushHistory = true, params = {}) {
|
|
if (!pages[pageId]) return;
|
|
// Neue Version erkannt → nur aktualisieren wenn kein Bearbeitungsfenster offen ist
|
|
// UND wenn nicht erst kürzlich force-update lief (Cooldown 10 Min) — verhindert Loop
|
|
// bei mehreren schnellen Deploys oder iOS-PWA-Cache-Quirks. localStorage überlebt
|
|
// App-Restarts (sessionStorage wäre bei PWA-Standalone-close weg).
|
|
if (window._byUpdatePending) {
|
|
const modalOpen = document.querySelector('#modal-container .modal-overlay') !== null;
|
|
let lastForce = 0;
|
|
try { lastForce = parseInt(localStorage.getItem('by_last_force_update') || '0', 10); } catch {}
|
|
const cooldownActive = (Date.now() - lastForce) < 10 * 60 * 1000;
|
|
// Während einer laufenden Aufzeichnung NIE force-updaten (Datenverlust).
|
|
if (!modalOpen && !cooldownActive && !window._byRecording) {
|
|
window._byUpdatePending = false;
|
|
sessionStorage.setItem('by_updated_to', window._byNewVersion || '');
|
|
sessionStorage.setItem('by_update_target', pageId);
|
|
try { localStorage.setItem('by_last_force_update', String(Date.now())); } catch {}
|
|
location.href = '/force-update';
|
|
return;
|
|
}
|
|
// Modal offen oder Cooldown → bei nächstem Seitenwechsel versuchen
|
|
}
|
|
if (window.Worlds?._visible) window.Worlds.hide();
|
|
// Worlds-Zurück-Pfeil sichtbar machen, sobald ein Logged-In-User auf
|
|
// einer „echten" Seite landet (auch bei Page-zu-Page-Sprung ohne vorigen
|
|
// Worlds-Aufruf, z.B. Onboarding→dog-profile). Sonst kein Weg zurück
|
|
// zu Welten + FAB. Für welcome/onboarding ausblenden.
|
|
const _hideBackFor = new Set(['welcome', 'onboarding']);
|
|
const _backEl = document.getElementById('worlds-back');
|
|
if (_backEl) {
|
|
if (state.user && !_hideBackFor.has(pageId)) {
|
|
_backEl.classList.add('worlds-back-visible');
|
|
} else {
|
|
_backEl.classList.remove('worlds-back-visible');
|
|
}
|
|
}
|
|
|
|
// destroy() der aktuellen Seite aufrufen (z.B. FABs aufräumen)
|
|
const activePage = document.querySelector('.page.active');
|
|
if (activePage) {
|
|
const activeId = activePage.id?.replace('page-', '');
|
|
if (activeId && pages[activeId]?.module?.destroy) pages[activeId].module.destroy();
|
|
}
|
|
|
|
// Aktive Seite ausblenden
|
|
activePage?.classList.remove('active');
|
|
document.querySelectorAll('.nav-item.active, .sidebar-item.active')
|
|
.forEach(el => el.classList.remove('active'));
|
|
|
|
// Neue Seite einblenden
|
|
document.getElementById(`page-${pageId}`)?.classList.add('active');
|
|
|
|
// Navigation markieren
|
|
document.querySelectorAll(`[data-page="${pageId}"]`)
|
|
.forEach(el => el.classList.add('active'));
|
|
|
|
// Header-Titel setzen (nur wenn kein Dog-Switcher aktiv ist)
|
|
const titleEl = document.getElementById('header-title');
|
|
if (titleEl) titleEl.textContent = pages[pageId].title;
|
|
|
|
// History
|
|
if (pushHistory) {
|
|
history.pushState({ page: pageId }, '', `#${pageId}`);
|
|
}
|
|
|
|
state.page = pageId;
|
|
UI.scrollTop();
|
|
|
|
// Seiten-Modul lazy laden (einmalig)
|
|
_loadPage(pageId, params);
|
|
}
|
|
|
|
async function _loadPage(pageId, params = {}) {
|
|
const page = pages[pageId];
|
|
|
|
// AUTH GUARD — geschützte Seiten für nicht-eingeloggte User → Welcome
|
|
if (page.requiresAuth && !state.user) {
|
|
navigate('welcome', false);
|
|
return;
|
|
}
|
|
|
|
// Pro-Guard — nur wenn User eingeloggt aber kein Pro-Zugang
|
|
if (page.requiresPro && state.user && !_hasPro(state.user)) {
|
|
const container = document.querySelector(`#page-${pageId} .page-body`);
|
|
if (container) {
|
|
container.innerHTML = `
|
|
<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 (err) {
|
|
// Echten Fehler NICHT verschlucken — sonst rätselt man bei jedem Seiten-Crash
|
|
console.error(`[page-load] ${pageId} init fehlgeschlagen:`, err);
|
|
const _offline = !navigator.onLine;
|
|
container.innerHTML = UI.emptyState({
|
|
icon: _offline ? '📡' : '⚠️',
|
|
title: pages[pageId].title,
|
|
text: _offline
|
|
? 'Diese Seite ist offline nicht verfügbar. Bitte öffne sie einmal mit Internetverbindung, damit sie gecacht wird.'
|
|
: 'Die Seite konnte nicht geladen werden. Das passiert manchmal nach einem Update.',
|
|
action: _offline ? '' :
|
|
`<button class="btn btn-primary" id="page-retry-btn">Erneut versuchen</button>`,
|
|
});
|
|
document.getElementById('page-retry-btn')?.addEventListener('click', () => {
|
|
page._loading = false;
|
|
navigate(pageId, false, params);
|
|
});
|
|
// WICHTIG: page.module NICHT auf {} setzen. Bei einem echten Fehler (Netz-Blip,
|
|
// SW-Update mitten in der Navigation, Race) würde {} die Seite für die ganze
|
|
// Session tot stellen — der Guard `if (page.module)` käme nie mehr zum Laden.
|
|
// So wird beim nächsten Aufruf neu versucht und ein transienter Fehler heilt sich.
|
|
} finally {
|
|
page._loading = false;
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
// DESKTOP WIDTH — einheitliche Breite auf großen Screens
|
|
// ----------------------------------------------------------
|
|
const _FULLSCREEN_PAGES = new Set([
|
|
'admin','map','chat','forum','wiki','ernaehrung','movies','wurfboerse',
|
|
'routes','walks','litters','zucht-profil','widget',
|
|
]);
|
|
function _applyDesktopWidth(pageId, container) {
|
|
if (window.innerWidth < 768) return;
|
|
if (_FULLSCREEN_PAGES.has(pageId)) return;
|
|
const first = container.querySelector(':scope > div');
|
|
if (first && !first.classList.contains('page-container') &&
|
|
!first.classList.contains('pc-desktop')) {
|
|
first.classList.add('pc-desktop');
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
// LOGIN GATE — wird statt Seiteninhalt angezeigt
|
|
// ----------------------------------------------------------
|
|
function _renderLoginGate(container, pageId) {
|
|
const gate = AUTH_GATE[pageId] || { icon: 'lock', text: 'Dieser Bereich ist nur für angemeldete Nutzer.' };
|
|
const title = pages[pageId]?.title || 'Dieser Bereich';
|
|
|
|
container.innerHTML = `
|
|
<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() {
|
|
// Globaler Bild-Fallback — ersetzt CSP-blockierte onerror-Attribute.
|
|
// 'error' bubbelt nicht → Capture-Phase. Greift nur bei [data-fb]/[data-fb-src].
|
|
document.addEventListener('error', e => {
|
|
const el = e.target;
|
|
if (!el || el.tagName !== 'IMG') return;
|
|
const fb = el.dataset.fb, altSrc = el.dataset.fbSrc;
|
|
if (fb === undefined && altSrc === undefined) return;
|
|
// Schritt 1: Alternative Quelle versuchen (z.B. _preview → Original / Platzhalter)
|
|
if (altSrc && !el.dataset.fbTried) {
|
|
el.dataset.fbTried = '1';
|
|
el.src = altSrc;
|
|
return;
|
|
}
|
|
// Schritt 2: terminaler Fallback
|
|
switch (fb) {
|
|
case 'hide-parent':
|
|
if (el.parentElement) el.parentElement.style.display = 'none';
|
|
break;
|
|
case 'dim-grandparent':
|
|
if (el.parentElement?.parentElement) el.parentElement.parentElement.style.opacity = '.4';
|
|
break;
|
|
case 'sibling':
|
|
el.style.display = 'none';
|
|
if (el.nextElementSibling) {
|
|
el.nextElementSibling.classList.remove('hidden'); // .hidden hat !important
|
|
el.nextElementSibling.style.display = 'flex';
|
|
}
|
|
break;
|
|
case 'show-el': {
|
|
el.style.display = 'none';
|
|
const t = el.dataset.fbEl && document.getElementById(el.dataset.fbEl);
|
|
if (t) { t.classList.remove('hidden'); t.style.display = 'flex'; } // .hidden hat !important
|
|
break;
|
|
}
|
|
case 'emoji':
|
|
if (el.parentElement) el.parentElement.innerHTML =
|
|
`<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:${el.dataset.fbSize || '2rem'}">${el.dataset.fbEmoji || '🐾'}</div>`;
|
|
break;
|
|
case 'initials': {
|
|
const sz = parseInt(el.dataset.fbSize, 10) || 40;
|
|
el.outerHTML =
|
|
`<div style="width:${sz}px;height:${sz}px;border-radius:50%;background:var(--c-primary-subtle);display:flex;align-items:center;justify-content:center;font-size:${Math.round(sz * 0.45)}px;font-weight:700;color:var(--c-primary)">${el.dataset.fbInitials || ''}</div>`;
|
|
break;
|
|
}
|
|
default: // 'hide'
|
|
el.style.display = 'none';
|
|
el.classList.add('img-broken');
|
|
}
|
|
}, true);
|
|
|
|
// Video-Vorschau bei Hover (ersetzt CSP-blockierte onmouseenter/leave).
|
|
// <video> hat keine Kinder → e.target ist das Video selbst (matches() O(1)).
|
|
document.addEventListener('mouseover', e => {
|
|
if (e.target.matches?.('[data-hover-play]')) e.target.play?.().catch(() => {});
|
|
});
|
|
document.addEventListener('mouseout', e => {
|
|
if (e.target.matches?.('[data-hover-play]')) e.target.pause?.();
|
|
});
|
|
|
|
// 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;
|
|
}
|
|
|
|
// Foto-Lightbox (Inline-onclick ist CSP-blockiert)
|
|
const lb = e.target.closest('[data-lightbox-url]');
|
|
if (lb) {
|
|
window.UI?.lightbox?.show?.([{ url: lb.dataset.lightboxUrl }], 0);
|
|
return;
|
|
}
|
|
|
|
// Externer Link in neuem Tab
|
|
const ol = e.target.closest('[data-open-url]');
|
|
if (ol) {
|
|
window.open(ol.dataset.openUrl, '_blank');
|
|
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);
|
|
// Rechtsseiten direkt verlinkt (iOS-App, App-Store-Metadaten, E-Mails,
|
|
// /impressum-Redirects) → ebenfalls in App bleiben statt /info
|
|
const _hashPage = _rawHash.split('?')[0];
|
|
const _legalPages = ['impressum', 'datenschutz', 'agb'];
|
|
if (_hashP.get('verified') || _hashP.get('token') || location.pathname.startsWith('/teilen/')
|
|
|| _legalPages.includes(_hashPage)) {
|
|
sessionStorage.setItem('by_stay_in_app', '1');
|
|
}
|
|
|
|
// Referral-Code aus URL ?ref=CODE speichern (Backup zu boot.js; localStorage
|
|
// überlebt App-Schließen, sodass die Zuordnung auch bei späterer Registrierung klappt)
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const refCode = urlParams.get('ref');
|
|
const qrToken = urlParams.get('qr');
|
|
if (refCode) {
|
|
try {
|
|
localStorage.setItem('by_ref_code', refCode.toUpperCase());
|
|
localStorage.setItem('by_ref_code_ts', String(Date.now()));
|
|
// Partner-QR-Token (Sticker/Flyer) für Einzelcode-Rückverfolgung mitspeichern
|
|
if (qrToken) localStorage.setItem('by_qr_token', qrToken);
|
|
} catch {}
|
|
// 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';
|
|
// Hash-Route auch ohne Login ansteuern — öffentliche Seiten (AGB,
|
|
// Datenschutz, Impressum, …) müssen für anonyme Besucher erreichbar sein.
|
|
// Auth-pflichtige Seiten leitet navigate() über den requiresAuth-Guard
|
|
// selbst auf 'welcome' um.
|
|
navigate(startPage, 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" data-page="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
|
|
}
|
|
});
|