/* ============================================================
BAN YARO — Einstellungen / Account
Login, Registrierung, Logout, Account-Info.
============================================================ */
window.Page_settings = (() => {
let _container = null;
let _appState = null;
let _mode = 'login'; // 'login' | 'register'
// ----------------------------------------------------------
// HUNDEPASSPHRASE — sicheres Passwort aus Hundewelt
// ----------------------------------------------------------
const _PW_WOERTER = [
// Rassen
'Labrador','Pudel','Beagle','Husky','Dackel','Spitz','Mops','Boxer',
'Collie','Setter','Pointer','Retriever','Shepherd','Terrier','Welpe',
// Körper & Natur
'Pfote','Schwanz','Schnauze','Schnurrbart','Fell','Nase','Ohr',
// Aktivität
'Gassi','Laufen','Bellen','Springen','Graben','Schnüffeln','Spielen',
'Apportieren','Schwimmen','Hecheln','Wackeln','Toben',
// Gegenstände
'Leckerli','Leine','Halsband','Ball','Napf','Knochen','Frisbee',
'Körbchen','Bürste','Leine','Stöckchen','Kauspielzeug',
// Orte & Personen
'Wiese','Wald','Park','Bach','Pfütze','Tierarzt','Züchter',
// Eigenschaften
'Treu','Tapfer','Mutig','Flauschig','Verspielt','Neugierig',
'Wachsam','Flink','Sanft','Lieb',
// Geräusche & Aktionen
'Wuff','Jaulen','Schnuppern','Wedeln','Gähnen','Strecken',
// Futter
'Trockenfutter','Nassfutter','Kausnack','Futternapf',
];
function _genPassphrase() {
const pick = () => _PW_WOERTER[Math.floor(Math.random() * _PW_WOERTER.length)];
const num = Math.floor(Math.random() * 90) + 10; // 2-stellig
// 3 zufällige Wörter + Zahl, mit Bindestrich
const words = [];
while (words.length < 3) {
const w = pick();
if (!words.includes(w)) words.push(w);
}
return words.join('-') + '-' + num;
}
// ----------------------------------------------------------
// INIT / REFRESH
// ----------------------------------------------------------
async function init(container, appState, params = {}) {
_container = container;
_appState = appState;
_render();
if (params.tab === 'login') setTimeout(() => _renderAuth('login'), 50);
if (params.tab === 'register') setTimeout(() => _renderAuth('register'), 50);
// Frischen User-State laden damit Badges (is_founder, is_partner) aktuell sind
if (_appState.user) {
try {
const fresh = await API.auth.me();
Object.assign(_appState.user, fresh);
_render();
} catch {}
}
}
async function refresh() {
_render();
if (_appState?.user) {
try {
const fresh = await API.auth.me();
Object.assign(_appState.user, fresh);
_render();
} catch {}
}
}
// ----------------------------------------------------------
// ABO & TARIF
// ----------------------------------------------------------
function _tierCard(u) {
const tier = u.subscription_tier || 'standard';
const rolle = u.rolle || 'user';
const isAdmin = rolle === 'admin' || rolle === 'moderator';
const isPro = ['pro','pro_test'].includes(tier);
const isBreeder = ['breeder','breeder_test'].includes(tier) || rolle === 'breeder';
const isStandard = !isAdmin && !isPro && !isBreeder;
const _badge = (label, color) =>
`${label} `;
const _upgradeBtn = (id, label, price, color) =>
`
${label}
${price}
`;
const expires = u.subscription_expires_at;
const cancelled = u.subscription_cancelled_at;
const expiresDate = expires ? new Date(expires).toLocaleDateString('de-DE', {day:'numeric',month:'long',year:'numeric'}) : null;
const isPaid = (isPro || isBreeder) && !tier.endsWith('_test') && !isAdmin;
const _expiryInfo = () => {
if (!isPaid) return '';
if (cancelled && !expiresDate) {
return `
Gekündigt — läuft bis Ablauf des bezahlten Zeitraums
`;
}
if (!expiresDate) return '';
const color = cancelled ? '#e65100' : 'var(--c-text-secondary)';
const text = cancelled
? `Gekündigt — läuft bis ${expiresDate}`
: `Aktiv bis ${expiresDate}`;
return `${text}
`;
};
const _cancelBtn = () => {
if (!isPaid || cancelled) return '';
return `
Abo kündigen
`;
};
let statusHtml = '';
let actionsHtml = '';
if (isAdmin) {
statusHtml = _badge('Admin', '#6366f1');
} else if (isBreeder) {
statusHtml = _badge(cancelled ? 'Züchter (gekündigt)' : 'Züchter aktiv', '#C4843A');
} else if (isPro) {
statusHtml = _badge(cancelled ? 'Pro (gekündigt)' : 'Pro aktiv', '#16a34a');
actionsHtml = `
${!cancelled ? _upgradeBtn('settings-upgrade-breeder-btn','Züchter werden','49 €/Jahr','#C4843A') : ''}
`;
} else {
statusHtml = _badge('Kostenlos', '#888');
actionsHtml = `
${_upgradeBtn('settings-upgrade-pro-btn','Ban Yaro Pro','29 €/Jahr','#16a34a')}
${_upgradeBtn('settings-upgrade-breeder-btn','Züchter','49 €/Jahr','#C4843A')}
`;
}
return `
Aktueller Tarif:
${statusHtml}
${_expiryInfo()}
${actionsHtml}
${_cancelBtn()}
`;
}
function _showUpgradeModal(tier) {
const isPro = tier === 'pro';
const label = isPro ? 'Ban Yaro Pro' : 'Züchter';
const price = isPro ? '29 €/Jahr' : '49 €/Jahr';
const color = isPro ? '#16a34a' : '#C4843A';
const _group = (title, items) => `
${title}
${items.map(f => `
✓
${f}
`).join('')}
`;
const featureList = isPro
? _group('Deine Hunde', [
'Bis zu 10 Hunde gleichzeitig verwalten',
'Getrennte Trainingsfortschritte, Gesundheits- und Ernährungsdaten je Hund',
])
+ _group('Community & Alltag', [
'Gassi-Treffen: Fotos und Rasse der Teilnehmer sichtbar, Fotos nach dem Treffen hochladen',
'Direktnachrichten & Chat mit anderen Hundebesitzern',
'Playdate: Spielkameraden in der Nähe finden und verabreden',
])
+ _group('Tools & Wissen', [
'Ernährung: Kalorienbedarf-Rechner, BARF-Guide, Giftliste, KI-Ernährungsberater',
'Reise-Checkliste & EU-Länder-Einreiseregeln',
'Notizblock mit KI-Muster-Analyse',
'Erweiterte Karten-Layer (Wandern, Radfahren, Satellit)',
'Alle künftigen Pro-Features inklusive',
])
: `
✓ Alle Pro-Features inklusive —
mehrere Hunde, Ernährung, Gassi-Community, Chat, Playdate, Reise, Karten-Layer
`
+ _group('Zucht-Management', [
'Zuchtkartei: Stammdaten, Gesundheitstests (HD, ED, OCD, Augen, Herz, Patella, ZTP), Gentests (MDR1, PRA, DM, vWD)',
'Wurfverwaltung: Welpen, Gewichtsverlauf, Fotos, automatisch ausgefüllter Kaufvertrag',
'Warteliste: Interessenten mit Präferenzen (Geschlecht, Farbe, Verwendungszweck) pro Zuchthündin',
'Läufigkeit & Trächtigkeit: Zykluskalender, Progesterontests, Deckdaten, Meilensteinberechnung',
'Wurf-Buchstabe und Wurf-Name (z. B. A-Wurf, „Vatertags-Wurf")',
])
+ _group('KI & Analyse', [
'KI-Züchter-Assistent: Wurfankündigungen schreiben, Genetik-Erklärung für Käufer, Paarungsanalyse',
'Stammbaum bis 4 Generationen mit klickbaren Knoten',
'Inzucht-Koeffizient (Wright\'s Formel, Ampel-Bewertung, Probeverpaarung)',
'Tierschutz-Check automatisch bei jeder Verpaarung',
'KI-Jahresbericht mit Trends und Empfehlungen',
])
+ _group('Sichtbarkeit & Export', [
'Öffentliches Züchter-Profil unter banyaro.app/breeder/{zwingername}',
'Wurfbörse: Würfe öffentlich ankündigen, Käufer schreiben direkt an',
'Datenexport als HTML-Dossier und ODS-Tabelle (LibreOffice / Excel)',
'Privater Züchter-Bereich mit Zwingername und Logo',
]);
const inputStyle = `width:100%;box-sizing:border-box;padding:var(--space-2) var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
font-size:var(--text-sm);font-family:inherit;background:var(--c-surface);color:var(--c-text)`;
const breederForm = isPro ? '' : `
`;
UI.modal.open({
title: `${label} freischalten`,
body: `
${price}
Einmaliger Jahresbeitrag Kündigung jederzeit möglich
${featureList}
Wir schalten deinen Account manuell frei — innerhalb von 24 Stunden.
Wir melden uns mit den Zahlungsdetails per E-Mail.
${!_appState.user?.billing_address ? `
💡 Tipp: Trag deine Rechnungsadresse im Profil ein — dann können wir die Rechnung vollständig ausstellen.
` : ''}
Ich habe die AGB gelesen und stimme ihnen zu.
Ich stimme zu, dass mein Zugang sofort nach Freischaltung beginnt, und bestätige,
dass ich damit mein 14-tägiges Widerrufsrecht verliere (§ 356 Abs. 4 BGB).
${breederForm}
`,
footer: `
Abbrechen
Anfrage senden
`
});
const agbBox = document.getElementById('agb-checkbox');
const widerrufBox = document.getElementById('widerruf-checkbox');
const sendBtn = document.getElementById('upgrade-request-send-btn');
if (sendBtn) sendBtn.disabled = true;
const _checkBtns = () => {
if (sendBtn) sendBtn.disabled = !(agbBox?.checked && widerrufBox?.checked);
};
agbBox?.addEventListener('change', _checkBtns);
widerrufBox?.addEventListener('change', _checkBtns);
document.getElementById('upgrade-request-send-btn')?.addEventListener('click', async () => {
const btn = document.getElementById('upgrade-request-send-btn');
if (!btn) return;
// Züchter: Formular validieren + als FormData senden
if (!isPro) {
const form = document.getElementById('breeder-upgrade-form');
if (form && !form.reportValidity()) return;
if (form) {
const fd = new FormData(form);
fd.set('vdh_mitglied', form.querySelector('[name="vdh_mitglied"]').checked ? '1' : '0');
// Pflichtfelder aus Form übernehmen falls leer → leere Strings senden
if (!fd.get('verein')) fd.set('verein', '');
if (!fd.get('stadt')) fd.set('stadt', '');
btn.disabled = true;
btn.textContent = 'Wird gesendet…';
try {
await API.breeder.apply(fd);
} catch (e) {
if (!e.message?.includes('bereits')) {
btn.disabled = false;
btn.textContent = 'Anfrage senden';
UI.toast.error(e.message || 'Fehler beim Einreichen.');
return;
}
}
}
} else {
btn.disabled = true;
btn.textContent = 'Wird gesendet…';
}
try {
const widerrufAt = new Date().toLocaleString('de-DE');
const res = await API.auth.upgradeRequest(tier, `[Widerrufsrecht akzeptiert am ${widerrufAt}]`);
UI.modal.close();
if (res.already) {
UI.toast.info('Deine Anfrage liegt bereits vor — wir melden uns bald.');
} else {
UI.toast.success('Anfrage gesendet! Wir melden uns per E-Mail.');
}
if (!isPro) _loadBreederCard();
} catch (e) {
btn.disabled = false;
btn.textContent = 'Anfrage senden';
UI.toast.error(e.message || 'Fehler beim Senden.');
}
});
}
function _showCancelModal() {
const u = _appState.user;
const tier = u?.subscription_tier || 'standard';
const label = { pro: 'Ban Yaro Pro', breeder: 'Züchter' }[tier] || tier;
const expires = u?.subscription_expires_at;
const expiresDate = expires
? new Date(expires).toLocaleDateString('de-DE', {day:'numeric',month:'long',year:'numeric'})
: null;
UI.modal.open({
title: `${label} kündigen`,
body: `
${expiresDate ? `
Dein Abo läuft noch bis ${expiresDate} — du hast bis dahin vollen Zugriff.
` : ''}
✓ Alle deine Daten (Tagebuch, Gesundheit, Notizen) bleiben vollständig erhalten
✓ Deine Hunde-Profile bleiben gespeichert
✓ Du kannst jederzeit wieder upgraden
${_appState.dogs?.length > 1
? `
⚠ Du hast mehrere Hunde — nach dem Ablauf wählst du einen als Haupthund
`
: ''}
`,
footer: `
Abbrechen
Jetzt kündigen
`
});
document.getElementById('cancel-sub-confirm-btn')?.addEventListener('click', async () => {
const btn = document.getElementById('cancel-sub-confirm-btn');
if (!btn) return;
btn.disabled = true;
btn.textContent = '…';
try {
await API.auth.cancelSubscription();
// User-State aktualisieren
const fresh = await API.auth.me();
Object.assign(_appState.user, fresh);
UI.modal.close();
const tier = fresh.subscription_tier || '';
const label = { pro: 'Pro', breeder: 'Züchter' }[tier] || tier;
const exp = fresh.subscription_expires_at;
const expFmt = exp
? new Date(exp).toLocaleDateString('de-DE', {day:'numeric',month:'long',year:'numeric'})
: null;
UI.toast.success(
expFmt
? `Kündigung bestätigt — ${label} läuft noch bis ${expFmt}.`
: 'Kündigung bestätigt. Eine Bestätigungsmail wurde gesendet.'
);
_render();
} catch (e) {
btn.disabled = false;
btn.textContent = 'Jetzt kündigen';
UI.toast.error(e.message || 'Fehler beim Kündigen.');
}
});
}
// ----------------------------------------------------------
// RENDER
// ----------------------------------------------------------
function _render() {
if (_appState.user) {
_renderAccount();
} else {
_renderAuth(_mode);
}
}
// ----------------------------------------------------------
// EINGELOGGT — Account-Übersicht
// ----------------------------------------------------------
async function _renderAccount() {
const u = _appState.user;
// Avatar: Bild oder Buchstabe
const avatarInner = u.avatar_url
? ` `
: UI.escape(u.name.charAt(0).toUpperCase());
// Mitglied seit
const memberSince = (() => {
if (!u.created_at) return '';
const d = new Date(u.created_at);
return d.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
})();
// Erfahrungs-Labels
const erfahrungLabel = {
einsteiger: 'Einsteiger (erster Hund)',
erfahren: 'Erfahrener Hundehalter',
trainer: 'Trainer / Ausbilder',
zuechter: 'Züchter',
};
_container.innerHTML = `
${UI.escape(u.name)}
${UI.escape(u.email)}
${u.email_verified
? ` `
: `Nicht bestätigt `}
${(() => {
// Tier-Source-of-Truth ist subscription_tier (was auch
// _tierCard und has_pro_access nutzen). Das alte is_premium-
// Flag wird bei Admin-Upgrade nicht immer mitgezogen — daher
// hier den Tier nehmen.
const _tier = u.subscription_tier || 'standard';
const _rolle = u.rolle || 'user';
const _isAdminLike = _rolle === 'admin' || _rolle === 'moderator';
const _isBreeder = _tier === 'breeder' || _tier === 'breeder_test' || _rolle === 'breeder';
const _isPro = _tier === 'pro' || _tier === 'pro_test';
if (_isAdminLike) {
return `
Admin
`;
}
if (_isBreeder) {
return `
Züchter
`;
}
if (_isPro) {
return `
Ban Yaro Pro
`;
}
return `Kostenlos `;
})()}
${u.is_founder
? `
${u.founder_number ? `Gründer #${u.founder_number}` : 'Gründer'}
`
: u.is_founder_pending
? `
Gründer-Platz reserviert
` : ''}
${u.is_partner
? `
Partner
` : ''}
Mein Profil
Profil bearbeiten
${memberSince
? `
Mitglied seit ${UI.escape(memberSince)}
`
: ''}
${u.bio
? `
${UI.escape(u.bio)}
`
: ''}
${u.wohnort
? `
📍 ${UI.escape(u.wohnort)}
`
: ''}
${u.erfahrung && erfahrungLabel[u.erfahrung]
? `
${UI.escape(erfahrungLabel[u.erfahrung])}
`
: ''}
${u.social_link
? `
`
: ''}
${!u.bio && !u.wohnort && !u.erfahrung && !u.social_link
? `
Noch kein Profil ausgefüllt.
`
: ''}
Abmelden
Meine Daten exportieren (DSGVO Art. 20)
Konto löschen
${_tierCard(u)}
Dark Mode
Erscheinungsbild der App
System
Hell
Dunkel
${/SamsungBrowser/i.test(navigator.userAgent) ? `
Samsung Internet Tipps:
• Farben: Einstellungen → Webseitenansicht → Dark Mode deaktivieren.
• Vollbild: Einstellungen → Display → Navigationsleiste → Wischgesten aktivieren.
` : ''}
${(() => {
const tier = u.subscription_tier || 'standard';
const hasPro = ['pro','breeder'].includes(tier) ||
u.rolle === 'admin' || u.rolle === 'moderator' ||
u.is_moderator || u.is_social_media ||
tier.endsWith('_test');
if (!hasPro) return '';
return `
`;
})()}
7 Songs zum Behalten 🎸
Das ganze Album als Download — Deutsch, Englisch oder „neo"
(Electro-Remix). Behalten & teilen ausdrücklich erwünscht.
${UI.icon('arrow-square-out')} Freunde werben — dauerhafter Rabatt
10 Freunde → 20% · 20 Freunde → 30% · 50 Freunde → 50% — dauerhaft auf Ban Yaro Pro.
Lade…
Ban Yaro · banyaro.app
Deine Daten liegen auf einem eigenen Server in Deutschland.
v${typeof APP_VERSION !== 'undefined' ? APP_VERSION : '1.0.0'} (${typeof APP_VER !== 'undefined' ? APP_VER : '—'})
${UI.icon('arrows-clockwise')} Auf Update prüfen
`;
// Verstorbene Hunde in Erinnerungen-Sektion laden
API.get('/dogs/verstorben').then(dogs => {
const el = document.getElementById('settings-erinnerungen-wrap');
if (!el || !dogs.length) return;
el.innerHTML = dogs.map(d => {
const av = d.foto_url
? ` `
: `
`;
const jahr = d.verstorben_am ? d.verstorben_am.slice(0, 4) : '';
return `
`;
}).join('');
el.querySelectorAll('.settings-erinnerung-btn').forEach(btn => {
btn.addEventListener('click', () => _openGedenkseite(
parseInt(btn.dataset.dogId), btn.dataset.dogName
));
});
}).catch(() => {});
// OSM-Account-Verknüpfung (Modell A) — Status laden + Buttons verdrahten
(function _osmLink() {
const el = document.getElementById('settings-osm-body');
if (!el) return;
API.get('/osm-auth/status').then(st => {
if (st.linked) {
el.innerHTML = `
Verknüpft als ${UI.escape(st.osm_name)}
…
Verknüpfung trennen
`;
el.querySelector('#settings-osm-unlink').addEventListener('click', async () => {
try { await API.post('/osm-auth/unlink', {}); } catch (e) {}
_osmLink();
});
// Gamification-Zähler
API.get('/osm-contrib/status').then(cs => {
const c = document.getElementById('settings-osm-count');
if (!c) return;
const n = cs.verified_count || 0;
const next = n >= cs.pro_at ? 0 : (n < 10 ? 10 - n : cs.pro_at - n);
c.innerHTML = `🐾 ${n} hundefreundliche Orte eingetragen`
+ (next ? ` · noch ${next} bis ${n < 10 ? 'zum Kartograf-Badge' : '1 Jahr Pro'}` : ' · Ziel erreicht! 🎉');
}).catch(() => { const c = document.getElementById('settings-osm-count'); if (c) c.textContent=''; });
} else {
el.innerHTML = `
Du kennst die hundefreundlichen Orte besser als jede Karte. Verknüpfe deinen
kostenlosen OpenStreetMap-Account und trag mit einem Tap ein, wo dein Hund
willkommen war – das hilft jedem Hundehalter nach dir. Kostenlos, gemeinnützig,
keine Werbung.
OSM-Konto verknüpfen
Noch kein OSM-Konto? Was ist das?
OpenStreetMap ist die freie Weltkarte – von Menschen gemacht, gehört allen, keine Werbung, kein Datenverkauf. Mit einem kostenlosen Konto trägst du hundefreundliche Orte ein, die jeder Hundehalter sieht.
So geht's:
Konto erstellen (Benutzername + E-Mail – kein Klarname nötig)
Bestätigungs-E-Mail anklicken
Hier zurück → „OSM-Konto verknüpfen"
${st.sandbox ? `
⚠️ Testphase: Dies ist eine Test-Karte. Deine Einträge verändern die echte OpenStreetMap noch nicht.
` : ''}
Kostenloses OSM-Konto erstellen
`;
el.querySelector('#settings-osm-link').addEventListener('click', async () => {
try {
const r = await API.get('/osm-auth/authorize');
if (r.authorize_url) window.location.href = r.authorize_url;
} catch (e) {
UI.toast?.('OSM-Anbindung noch nicht konfiguriert.');
}
});
}
}).catch(() => { el.innerHTML = 'OSM-Status nicht verfügbar.
'; });
})();
// Achievements laden (Streak + Stats + Badges)
API.get('/achievements/me').then(a => {
const statsEl = document.getElementById('settings-stats-body');
const badgesEl = document.getElementById('settings-badges-body');
if (!statsEl) return;
const s = a.stats || {}, streak = a.streak || {};
const stat = (val, label) => `
`;
statsEl.innerHTML =
stat((s.total_km ?? 0) + ' km', 'gelaufen') +
stat(s.routen ?? 0, 'Routen') +
stat(s.pois ?? 0, 'POIs') +
stat('#' + (a.rang ?? '–'), 'Rang');
const streakEl = document.getElementById('settings-streak');
if (streakEl) {
const cur = streak.current || 0, mx = streak.max || 0;
streakEl.innerHTML = cur > 0
? `🔥
${cur} Tage Streak
${mx > cur ? `Best: ${mx} ` : ''}`
: `🔥 Noch kein Streak — heute aktiv werden! `;
}
// Lifetime-km Balken mit Meilenstein-Markierungen
const lifetimeEl = document.getElementById('settings-lifetime-km');
if (lifetimeEl) {
const km = s.total_km ?? 0;
const MILESTONES = [
{ km: 100, label: '100', badge: '100-km-Club', color: '#cd7f32' },
{ km: 500, label: '500', badge: '500-km-Wanderer', color: '#94a3b8' },
{ km: 1000, label: '1k', badge: 'Tausend-km-Held', color: '#f59e0b' },
{ km: 5000, label: '5k', badge: 'Ultraläufer', color: '#cbd5e1' },
];
const maxKm = 5000;
const pct = Math.min(km / maxKm * 100, 100);
const nextM = MILESTONES.find(m => km < m.km);
const reachedM = MILESTONES.filter(m => km >= m.km);
const lastBadge = reachedM.length ? reachedM[reachedM.length - 1] : null;
const markers = MILESTONES.map(m => {
const pos = (m.km / maxKm * 100).toFixed(1);
const reached = km >= m.km;
return `
${m.label}
`;
}).join('');
lifetimeEl.innerHTML = `
🐾 Lebenswerk-km
${km} km
${nextM
? `
Noch ${(nextM.km - km).toLocaleString('de-DE')} km
bis ${nextM.badge}
`
: `
Ultraläufer-Legende erreicht! 🏆
`}
`;
}
if (badgesEl && a.categories) {
// Foto-Hintergründe für bestimmte Badge-Kategorien
const _BADGE_PHOTOS = {
'schnee_held': '/img/banyaro/winter_schnee.webp',
'jahreszeiten': '/img/banyaro/herbst_bach.webp',
'wetter_tapfer': '/img/banyaro/fruehling_playdate.webp',
};
// SVG-Schild für jede Kategorie (mit optionalem Foto-Hintergrund)
const shield = (color, dark, emoji, opacity = 1, catId = '') => {
const photo = _BADGE_PHOTOS[catId];
const clipId = `clip_${catId || Math.random().toString(36).slice(2)}`;
const path = 'M30 3 L57 15 L57 38 Q57 60 30 70 Q3 60 3 38 L3 15 Z';
if (photo && opacity === 1) {
return `
${emoji}
`;
}
return `
${emoji}
`;
};
badgesEl.innerHTML = (a.categories || []).map(cat => {
const cur = cat.current_tier;
const nxt = cat.next_tier;
const val = cat.current_value;
// Alle Stufen als kleine Punkte
const dots = (cat.alle_stufen || []).map(s =>
`
`
).join('');
// Aktuelles Schild
const shieldSvg = cur
? shield(cur.color, cur.dark, cat.emoji, 1, cat.id)
: shield('#9ca3af', '#6b7280', cat.emoji, 0.5, cat.id);
// Fortschrittsbalken
const progressBar = nxt ? `
${val}${cat.einheit} / ${nxt.schwelle}${cat.einheit} → ${UI.escape(nxt.name)}
` : `
Höchste Stufe erreicht! 🎉
`;
return `
${shieldSvg}
${UI.escape(cat.name)}
${cur ? `${UI.escape(cur.name)} ` : ''}
${dots}
${progressBar}
`;
}).join('');
}
// Neue Badges als Toast
if (a.new_badges?.length) {
a.new_badges.forEach(b => {
UI.toast.success(`${b.emoji} ${b.name} — ${b.tier} freigeschaltet!`);
});
}
}).catch(() => {
const el = document.getElementById('settings-stats-body');
if (el) el.innerHTML = '–
';
});
// Avatar-Hover-Overlay
// E-Mail-Verifikation: Chip → erneut senden
document.getElementById('settings-verify-chip')?.addEventListener('click', async () => {
await API.post('/auth/resend-verification', {});
UI.toast.success('Bestätigungs-Mail gesendet — bitte prüfe dein Postfach.');
});
const avatarBtn = document.getElementById('settings-avatar-btn');
const avatarOverlay = avatarBtn?.querySelector('.avatar-overlay');
if (avatarBtn && avatarOverlay) {
avatarBtn.addEventListener('mouseenter', () => { avatarOverlay.style.opacity = '1'; });
avatarBtn.addEventListener('mouseleave', () => { avatarOverlay.style.opacity = '0'; });
}
// Avatar-Upload
avatarBtn?.addEventListener('click', () => {
document.getElementById('settings-avatar-input')?.click();
});
document.getElementById('settings-avatar-input')?.addEventListener('change', async e => {
const file = e.target.files?.[0];
if (!file) return;
try {
const fd = new FormData();
fd.append('file', file);
const res = await API.post('/profile/avatar', fd);
_appState.user.avatar_url = res.avatar_url;
UI.toast.success('Avatar aktualisiert.');
_render();
} catch {
UI.toast.error('Avatar-Upload fehlgeschlagen.');
}
});
// Profil bearbeiten
document.getElementById('settings-profile-edit-btn')?.addEventListener('click', () => {
const u = _appState.user;
const inputStyle = `width:100%;box-sizing:border-box;padding:var(--space-2) var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
font-size:var(--text-sm);font-family:inherit;
background:var(--c-surface);color:var(--c-text)`;
const erfahrungOpts = [
['', 'Bitte wählen...'],
['einsteiger', 'Einsteiger (erster Hund)'],
['erfahren', 'Erfahrener Hundehalter'],
['trainer', 'Trainer / Ausbilder'],
['zuechter', 'Züchter'],
].map(([val, label]) =>
`${UI.escape(label)} `
).join('');
const sichtbarkeitOpts = [
['public', 'Öffentlich'],
['friends', 'Nur Freunde'],
['private', 'Privat'],
].map(([val, label]) =>
`${UI.escape(label)} `
).join('');
UI.modal.open({
title: 'Profil bearbeiten',
body: `
`,
footer: `
Speichern
Abbrechen
`,
});
document.getElementById('profile-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = document.querySelector('[form="profile-form"]');
const fd = UI.formData(e.target);
await UI.asyncButton(btn, async () => {
const updated = await API.patch('/profile', {
real_name: fd.real_name || '',
bio: fd.bio || '',
wohnort: fd.wohnort || '',
erfahrung: fd.erfahrung || '',
social_link: fd.social_link || '',
profil_sichtbarkeit: fd.profil_sichtbarkeit || 'public',
billing_address: fd.billing_address || '',
geburtstag: fd.geburtstag || '',
});
Object.assign(_appState.user, updated);
window.Worlds?.refresh?.(_appState); // Welten neu rendern (z.B. Geburtstags-Greeting)
UI.modal.close?.();
UI.toast.success('Profil gespeichert.');
_render();
});
});
});
document.getElementById('settings-check-update')?.addEventListener('click', async () => {
const btn = document.getElementById('settings-check-update');
if (!('serviceWorker' in navigator)) {
UI.toast.info('Service Worker nicht verfügbar.');
return;
}
if (btn) btn.textContent = 'Prüfe…';
try {
// Versionsnummer direkt vom API-Endpunkt holen
const r = await fetch('/api/version', { cache: 'no-store' });
// r.ok prüfen: SW antwortet offline mit 503+JSON → json() wirft nicht und
// serverVersion=undefined meldete fälschlich „Ban Yaro ist aktuell".
if (!r.ok) throw new Error(`version ${r.status}`);
const { version: serverVersion } = await r.json();
const localVersion = typeof APP_VER !== 'undefined' ? APP_VER : '0';
const reg = await navigator.serviceWorker.getRegistration();
reg?.update().catch(() => {}); // kein await — kann hängen
if (serverVersion && serverVersion !== localVersion) {
if (reg?.waiting) reg.waiting.postMessage({ type: 'SKIP_WAITING' });
UI.toast.info(`Update auf v${serverVersion} — Seite wird neu geladen…`);
setTimeout(() => location.replace('/?_t=' + Date.now()), 1500);
} else if (reg?.waiting) {
reg.waiting.postMessage({ type: 'SKIP_WAITING' });
UI.toast.success('Update wird installiert…');
setTimeout(() => location.replace('/?_t=' + Date.now()), 1500);
} else {
UI.toast.success(`Ban Yaro ist aktuell — Build ${localVersion}`);
}
} catch {
UI.toast.error('Update-Prüfung fehlgeschlagen.');
} finally {
if (btn) btn.innerHTML = UI.icon('arrows-clockwise') + ' Auf Update prüfen';
}
});
document.getElementById('settings-upgrade-pro-btn')?.addEventListener('click', () => {
_showUpgradeModal('pro');
});
document.getElementById('settings-upgrade-breeder-btn')?.addEventListener('click', () => {
_showUpgradeModal('breeder');
});
document.getElementById('settings-cancel-sub-btn')?.addEventListener('click', () => {
_showCancelModal();
});
document.getElementById('settings-worlds-btn')?.addEventListener('click', () => {
if (window.Worlds?._openConfigModal) window.Worlds._openConfigModal();
else if (window.Worlds) window.Worlds.openConfig?.();
});
document.getElementById('settings-hilfe-btn')?.addEventListener('click', () => {
App.navigate('hilfe');
});
document.getElementById('settings-feedback-btn')?.addEventListener('click', () => {
const sel = (id) => document.getElementById(id);
const inputStyle = 'width:100%;padding:10px 12px;border:1.5px solid var(--c-border);border-radius:var(--radius-md);background:var(--c-bg-card);color:var(--c-text);font-size:var(--text-sm);box-sizing:border-box';
UI.modal.open({
title: 'Feedback geben',
body: `
Kategorie
💡 Idee / Wunsch
🐛 Bug / Fehler
🎉 Lob
💬 Sonstiges
Deine Nachricht
`,
footer: `
Absenden
Abbrechen
`,
});
sel('feedback-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = sel('feedback-submit-btn');
const kat = sel('feedback-kat')?.value;
const text = sel('feedback-text')?.value?.trim();
if (!text) { UI.toast.error('Bitte schreib etwas.'); return; }
await UI.asyncButton(btn, async () => {
await API.post('/feedback', { kategorie: kat, text });
UI.modal.close?.();
UI.toast.success('Vielen Dank für dein Feedback!');
});
});
});
document.getElementById('settings-logout-btn')?.addEventListener('click', async () => {
const ok = await UI.modal.confirm({
title : 'Abmelden?',
message: 'Du wirst aus deinem Konto abgemeldet.',
confirmText: 'Abmelden',
});
if (!ok) return;
try {
await API.auth.logout();
} catch { /* cookie wird trotzdem gelöscht */ }
_appState.user = null;
_appState.dogs = [];
_appState.activeDog = null;
UI.toast.info('Du wurdest abgemeldet.');
_render();
});
document.getElementById('settings-export-btn')?.addEventListener('click', async () => {
const btn = document.getElementById('settings-export-btn');
await UI.asyncButton(btn, async () => {
try {
const resp = await fetch('/api/profile/export', {
credentials: 'include',
headers: { 'Authorization': `Bearer ${localStorage.getItem('by_token') || ''}` },
});
if (!resp.ok) throw new Error('Export fehlgeschlagen.');
const data = await resp.json();
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `banyaro-export-${new Date().toISOString().slice(0,10)}.json`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
UI.toast.success('Export heruntergeladen.');
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Export.');
}
});
});
document.getElementById('settings-delete-account-btn')?.addEventListener('click', async () => {
const ok = await UI.modal.confirm({
title: 'Konto unwiderruflich löschen?',
message: 'Alle deine Daten (Tagebuch, Gesundheit, Training, Fotos) werden dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.',
confirmText: 'Ja, Konto löschen',
danger: true,
});
if (!ok) return;
try {
await API.del('/profile/account');
_appState.user = null; _appState.dogs = []; _appState.activeDog = null;
UI.toast.info('Dein Konto wurde gelöscht.');
App.navigate('welcome');
} catch {
UI.toast.error('Konto konnte nicht gelöscht werden. Bitte versuche es erneut.');
}
});
document.getElementById('settings-install-btn')?.addEventListener('click', () => {
App.navigate('welcome', true, { install: true });
});
document.getElementById('settings-push-btn')?.addEventListener('click', async () => {
try {
await API.subscribeToPush();
UI.toast.success('Push-Benachrichtigungen aktiviert.');
} catch {
UI.toast.warning('Push-Benachrichtigungen konnten nicht aktiviert werden.');
}
});
document.getElementById('settings-calendar-btn')?.addEventListener('click', async () => {
try {
const { token } = await API.webcal.getToken();
const url = `webcal://${location.host}/api/webcal/${token}.ics`;
const httpsUrl = `https://${location.host}/api/webcal/${token}.ics`;
UI.modal.open({
title: `${UI.icon('calendar-dots')} Kalender abonnieren`,
body: `
Abonniere deinen persönlichen Ban-Yaro-Kalender. Er enthält Impf-Erinnerungen,
Läufigkeits-Termine, Events und Gassi-Treffen — immer aktuell.
${httpsUrl}
Tipp: iOS → Einstellungen › Kalender › Accounts › Account hinzufügen › Andere › Kalenderabo
`,
});
document.getElementById('cal-copy-btn')?.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(httpsUrl);
UI.toast.success('URL kopiert.');
} catch {
UI.toast.warning('Kopieren nicht möglich — URL oben manuell kopieren.');
}
});
} catch(err) {
console.error('Kalender-Fehler:', err);
UI.toast.error('Kalender-Token konnte nicht geladen werden: ' + (err?.message || err));
}
});
document.getElementById('select-theme')?.addEventListener('change', async e => {
const val = e.target.value;
localStorage.setItem('by_theme', val); // lokaler Cache für schnellen Start
const html = document.documentElement;
if (val === 'dark') html.setAttribute('data-theme', 'dark');
else if (val === 'light') html.setAttribute('data-theme', 'light');
else html.removeAttribute('data-theme');
UI.toast.info(
val === 'dark' ? 'Dark Mode aktiviert.' :
val === 'light' ? 'Hell-Modus aktiviert.' :
'Theme folgt der Systemeinstellung.'
);
try {
await API.patch('/profile', { preferred_theme: val });
if (_appState?.user) _appState.user.preferred_theme = val;
} catch { /* ignorieren — localStorage-Fallback greift */ }
});
document.getElementById('toggle-pocket-mode')?.addEventListener('change', e => {
localStorage.setItem('by_pocket_mode', String(e.target.checked));
UI.toast.info(e.target.checked
? 'Pocket-Modus aktiviert — Bildschirm bleibt bei Aufzeichnung an.'
: 'Pocket-Modus deaktiviert.');
});
// DWD-Regenvorhersage (Default AN) — ausgewertet in pages/map.js _dwdEnabled()
document.getElementById('toggle-dwd-radar')?.addEventListener('change', e => {
const on = e.target.checked;
localStorage.setItem('by_dwd_radar', on ? '1' : '0');
const track = document.getElementById('toggle-dwd-radar-track');
const thumb = document.getElementById('toggle-dwd-radar-thumb');
if (track) track.style.background = on ? 'var(--c-primary)' : 'var(--c-border)';
if (thumb) thumb.style.left = on ? '22px' : '2px';
UI.toast.info(on
? 'DWD-Regenvorhersage aktiviert — 2h-Vorhersage im Regenradar (Deutschland).'
: 'DWD-Regenvorhersage deaktiviert — Regenradar nutzt RainViewer.');
});
document.getElementById('toggle-notes-ki')?.addEventListener('change', async e => {
const enabled = e.target.checked;
const track = document.getElementById('toggle-notes-ki-track');
const thumb = document.getElementById('toggle-notes-ki-thumb');
if (track) track.style.background = enabled ? 'var(--c-primary)' : 'var(--c-border)';
if (thumb) thumb.style.left = enabled ? '22px' : '2px';
try {
await API.patch('/profile', { notes_ki_enabled: enabled ? 1 : 0 });
_appState.user.notes_ki_enabled = enabled ? 1 : 0;
UI.toast.success(enabled ? 'KI-Notiz-Assistent aktiviert.' : 'KI-Notiz-Assistent deaktiviert.');
} catch (err) {
UI.toast.error(err?.message || 'Einstellung konnte nicht gespeichert werden.');
// Revert UI
e.target.checked = !enabled;
if (track) track.style.background = !enabled ? 'var(--c-primary)' : 'var(--c-border)';
if (thumb) thumb.style.left = !enabled ? '22px' : '2px';
}
});
document.getElementById('toggle-gassi-stunde')?.addEventListener('change', async e => {
const enabled = e.target.checked;
const track = document.getElementById('toggle-gassi-stunde-track');
const thumb = document.getElementById('toggle-gassi-stunde-thumb');
if (track) track.style.background = enabled ? 'var(--c-primary)' : 'var(--c-border)';
if (thumb) thumb.style.left = enabled ? '22px' : '2px';
try {
await API.patch('/profile', { gassi_stunde_push: enabled ? 1 : 0 });
_appState.user.gassi_stunde_push = enabled ? 1 : 0;
UI.toast.success(enabled ? 'Goldene Gassi-Stunde aktiviert.' : 'Goldene Gassi-Stunde deaktiviert.');
} catch (err) {
UI.toast.error(err?.message || 'Einstellung konnte nicht gespeichert werden.');
// Revert UI
e.target.checked = !enabled;
if (track) track.style.background = !enabled ? 'var(--c-primary)' : 'var(--c-border)';
if (thumb) thumb.style.left = !enabled ? '22px' : '2px';
}
});
_loadReferral();
_loadBreederCard();
}
// ----------------------------------------------------------
// ZÜCHTER-CARD — asynchron laden und in Slot rendern
// ----------------------------------------------------------
async function _loadBreederCard() {
const slot = document.getElementById('breeder-card-slot');
if (!slot) return;
let status = null;
try {
status = await API.breeder.status();
} catch {
// API nicht verfügbar — Card weglassen
return;
}
const { rolle, breeder_status, profile } = status;
const inputStyle = `width:100%;box-sizing:border-box;padding:var(--space-2) var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
font-size:var(--text-sm);font-family:inherit;
background:var(--c-surface);color:var(--c-text)`;
let statusBadge = '';
let actionBlock = '';
if (rolle === 'breeder' || rolle === 'admin') {
// Verifizierte Züchter/Admins: alles Inhaltliche (Profil, KI-Assistenz,
// Würfe, Zuchtkartei) lebt im Züchter-Bereich — hier nur der Verweis.
slot.innerHTML = '';
return;
} else if (breeder_status === 'pending') {
statusBadge = `
${UI.icon('hourglass')} Antrag wird geprüft
`;
} else if (breeder_status === 'rejected') {
statusBadge = `
${UI.icon('x-circle')} Abgelehnt
`;
actionBlock = `
Du kannst einen neuen Antrag stellen.
${UI.icon('arrow-counter-clockwise')} Neu beantragen
`;
} else {
// Kein Antrag, kein Profil — Card ausblenden (Upgrade-Flow läuft über Abo & Tarif)
slot.innerHTML = '';
return;
}
slot.innerHTML = `
${statusBadge}
${actionBlock}
`;
// Button-Handler binden
slot.querySelector('#breeder-reapply-btn')?.addEventListener('click', () => _showUpgradeModal('breeder'));
document.getElementById('breeder-apply-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = document.getElementById('breeder-apply-submit');
await UI.asyncButton(btn, async () => {
const form = e.target;
const fd = new FormData(form);
// Checkbox-Wert normalisieren
fd.set('vdh_mitglied', form.querySelector('[name="vdh_mitglied"]').checked ? '1' : '0');
await API.breeder.apply(fd);
UI.modal.close?.();
UI.toast.success('Antrag eingereicht. Du wirst benachrichtigt sobald er geprüft wurde.');
// Card neu laden
_loadBreederCard();
});
});
}
// ----------------------------------------------------------
// REFERRAL — Einladungslink laden und rendern
// ----------------------------------------------------------
async function _loadReferral() {
const el = document.getElementById('referral-body');
if (!el) return;
try {
const r = await API.auth.referral();
const TIERS = [{t:10,d:20},{t:20,d:30},{t:50,d:50}];
const currentTier = r.discount_pct;
const next = r.next_tier;
// Fortschrittsbalken-Berechnung
let barPct = 0, barLabel = '';
if (!next) {
barPct = 100;
barLabel = 'Maximaler Rabatt erreicht!';
} else {
const prevT = TIERS.find(t => t.d === currentTier)?.t || 0;
barPct = Math.round(((r.count - prevT) / (next.count - prevT)) * 100);
barLabel = `Noch ${next.count - r.count} ${next.count - r.count === 1 ? 'Person' : 'Personen'} bis ${next.discount}% Rabatt`;
}
el.innerHTML = `
${TIERS.map(({t, d}) => {
const reached = r.count >= t;
return `
${d}%
ab ${t} Freunden
${reached ? `
✓ Erreicht
` : ''}
`;
}).join('')}
${UI.icon('users')} ${r.count} ${r.count === 1 ? 'Freund geworben' : 'Freunde geworben'}
${currentTier > 0 ? `${currentTier}% Rabatt aktiv ` : ''}
${barLabel}
${r.link}
${UI.icon('arrow-square-out')} Teilen
Der Rabatt gilt für dich auf Ban Yaro Pro — dauerhaft und automatisch.
`;
document.getElementById('ref-share-btn')?.addEventListener('click', async () => {
const msg = `Ich bin bei Ban Yaro — der coolsten Hunde-App! Registrier dich mit meinem Link und wir wachsen zusammen 🐾`;
if (navigator.share) {
navigator.share({ title: 'Ban Yaro', text: msg, url: r.link }).catch(() => {});
} else {
await navigator.clipboard.writeText(r.link);
UI.toast.success('Link kopiert!');
}
});
await App.loadScript('/js/qrcode.min.js');
new QRCode(document.getElementById('ref-qr'), {
text: r.link, width: 140, height: 140,
colorDark: '#000000', colorLight: '#ffffff',
correctLevel: QRCode.CorrectLevel.H,
});
} catch { el.innerHTML = ''; }
}
// ----------------------------------------------------------
// GEDENKSEITE — für verstorbene Hunde
// ----------------------------------------------------------
async function _openGedenkseite(dogId, dogName) {
UI.modal.open({ title: `Erinnerungen an ${UI.escape(dogName)}`, body: `
` });
let data;
try { data = await API.get(`/dogs/${dogId}/gedenkseite`); }
catch { UI.modal.close(); return; }
const d = data;
const av = d.dog.foto_url
? ` `
: `
`;
const photoGrid = d.photos?.length ? `
${d.photos.map(url => `
`).join('')}
` : '';
const statsHtml = `
${d.km_total ? `
${d.km_total}
km zusammen
` : ''}
${d.diary_count ? `
${d.diary_count}
Tagebucheinträge
` : ''}
${d.gemeinsam_tage ? `
${d.gemeinsam_tage}
gemeinsame Tage
` : ''}
`;
const passed = d.dog.verstorben_am;
const passedStr = passed
? new Date(passed).toLocaleDateString('de-DE', { day: 'numeric', month: 'long', year: 'numeric' })
: '';
UI.modal.open({
title: `Erinnerungen an ${UI.escape(d.dog.name)}`,
body: `
${av}
${UI.escape(d.dog.name)}
${passedStr ? `
${passedStr}
` : ''}
${statsHtml}
${photoGrid}
Der Schmerz über den Verlust eines Hundes ist real und tief. Lass dich trauern — die Erinnerungen bleiben immer bei dir.
${d.ki_abschied ? `
"${UI.escape(d.ki_abschied)}"
` : ''}
`,
});
}
// ----------------------------------------------------------
// NICHT EINGELOGGT — Login / Registrierung
// ----------------------------------------------------------
function _renderVerifyPending(email) {
_container.innerHTML = `
E-Mail bestätigen
Wir haben einen Bestätigungslink an
${email}
gesendet.
Bitte prüfe dein Postfach und klicke auf den Link, um dein Konto zu aktivieren.
Danach kannst du dich hier anmelden.
Link erneut senden
Anderes Konto / Anmelden
`;
document.getElementById('verify-resend-btn2')?.addEventListener('click', async function() {
this.disabled = true;
this.textContent = 'Gesendet …';
try {
await API.post('/auth/resend-verification', { email });
UI.toast.success('Bestätigungs-Mail erneut gesendet.');
} catch {
UI.toast.error('Fehler beim Senden — bitte versuche es später erneut.');
}
});
document.getElementById('verify-back-btn')?.addEventListener('click', () => _renderAuth('login'));
}
function _renderAuth(mode) {
// Passwort-Reset über Link aus E-Mail
const resetToken = sessionStorage.getItem('by_reset_token');
if (resetToken) {
sessionStorage.removeItem('by_reset_token');
_renderResetPassword(resetToken);
return;
}
_mode = mode;
_container.innerHTML = `
Ban Yaro
Alles rund um deinen Hund
Anmelden
Registrieren
${mode === 'login' ? _loginFormHTML() : _registerFormHTML()}
`;
document.getElementById('tab-login')
?.addEventListener('click', () => _renderAuth('login'));
document.getElementById('tab-register')
?.addEventListener('click', () => _renderAuth('register'));
if (mode === 'login') {
_bindLoginForm();
} else {
_bindRegisterForm();
}
}
function _loginFormHTML() {
return `
E-Mail
Anmelden
Passwort vergessen?
`;
}
function _registerFormHTML() {
return `
E-Mail
Konto erstellen
Mit der Registrierung stimmst du unseren Datenschutzhinweisen zu.
Deine Daten werden ausschließlich auf unserem Server gespeichert.
`;
}
function _bindPwToggle(inputId, btnId) {
const input = document.getElementById(inputId);
const btn = document.getElementById(btnId);
if (!input || !btn) return;
btn.addEventListener('click', () => {
const visible = input.type === 'text';
input.type = visible ? 'password' : 'text';
btn.setAttribute('aria-label', visible ? 'Passwort anzeigen' : 'Passwort verbergen');
btn.querySelector('use').setAttribute('href',
visible ? '/icons/phosphor.svg#eye' : '/icons/phosphor.svg#eye-slash');
});
}
function _bindLoginForm() {
_bindPwToggle('login-pw', 'login-pw-toggle');
document.getElementById('forgot-pw-link')?.addEventListener('click', () => {
const id = 'forgot-pw-modal';
UI.modal.open({
title: 'Passwort zurücksetzen',
body: `
Gib deine E-Mail-Adresse ein. Du erhältst einen Link zum Zurücksetzen deines Passworts.
E-Mail
`,
footer: `
Abbrechen
Link senden `,
});
document.getElementById(id)?.addEventListener('submit', async e => {
e.preventDefault();
const btn = document.querySelector(`[form="${id}"]`);
const email = document.getElementById('forgot-pw-email').value.trim();
await UI.asyncButton(btn, async () => {
await API.post('/auth/forgot-password', { email });
UI.modal.close();
UI.toast.success('Falls ein Account existiert, haben wir dir einen Link geschickt.');
});
});
});
document.getElementById('auth-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = e.target.querySelector('[type="submit"]');
const fd = UI.formData(e.target);
await UI.asyncButton(btn, async () => {
let result;
try {
result = await API.auth.login(fd.email, fd.password);
} catch (err) {
if (err.message === 'EMAIL_NOT_VERIFIED') {
_renderVerifyPending(fd.email);
return;
}
throw err;
}
localStorage.setItem('by_token', result.token);
// User-Daten laden
_appState.user = await API.auth.me();
document.getElementById('sidebar-username').textContent = _appState.user.name;
// Hunde laden
try {
_appState.dogs = await API.dogs.list();
_appState.activeDog = _appState.dogs[0] || null;
} catch { /* keine Hunde = okay */ }
document.getElementById('header-login-btn')?.remove();
UI.toast.success(`Willkommen zurück, ${_appState.user.name}!`);
// Push-Benachrichtigungen anbieten wenn noch nicht entschieden
if (typeof Notification !== 'undefined' && Notification.permission === 'default') {
_offerPushNotifications();
}
// Nach Login: Welten initialisieren oder Onboarding (mit Skip-Option)
if (_appState.activeDog) {
window.Worlds?.init(_appState);
} else {
App.navigate('onboarding');
}
});
});
}
function _bindRegisterForm() {
_bindPwToggle('register-pw', 'register-pw-toggle');
// Hundepassphrase-Generator initialisieren
const phraseEl = document.getElementById('pw-gen-phrase');
const pwInput = document.getElementById('register-pw');
if (phraseEl) {
const _refresh = () => { phraseEl.textContent = _genPassphrase(); };
_refresh();
document.getElementById('pw-gen-new')?.addEventListener('click', _refresh);
document.getElementById('pw-gen-use')?.addEventListener('click', () => {
const phrase = phraseEl.textContent;
pwInput.value = phrase;
pwInput.type = 'text'; // sichtbar machen
document.getElementById('register-pw-toggle')
?.querySelector('use')
?.setAttribute('href', '/icons/phosphor.svg#eye-slash');
// kurzes visuelles Feedback
const btn = document.getElementById('pw-gen-use');
btn.textContent = '✓ Übernommen';
btn.style.background = 'var(--c-success)';
setTimeout(() => {
btn.textContent = 'Übernehmen';
btn.style.background = 'var(--c-primary)';
}, 1500);
});
}
// Referral-Code aus localStorage lesen (30-Tage-Ablauf) bzw. löschen.
const _storedRefCode = () => {
try {
const code = localStorage.getItem('by_ref_code') || '';
if (!code) return '';
const ts = parseInt(localStorage.getItem('by_ref_code_ts') || '0', 10);
if (ts && Date.now() - ts > 30 * 24 * 3600 * 1000) { _clearRefCode(); return ''; }
return code;
} catch { return ''; }
};
const _clearRefCode = () => {
try { localStorage.removeItem('by_ref_code'); localStorage.removeItem('by_ref_code_ts'); } catch {}
};
// Partner-Code live validieren
const partnerInput = document.getElementById('reg-partner-code');
const partnerHint = document.getElementById('reg-partner-hint');
let _partnerValid = false;
if (partnerInput) {
// Vorausfüllen falls via Referral-Link gesetzt (localStorage, überlebt App-Schließen)
const stored = _storedRefCode();
if (stored) partnerInput.value = stored;
let _debounce = null;
partnerInput.addEventListener('input', () => {
const code = partnerInput.value.trim().toUpperCase();
partnerInput.value = code;
clearTimeout(_debounce);
partnerHint.style.display = 'none';
_partnerValid = false;
if (code.length < 3) return;
_debounce = setTimeout(async () => {
try {
const info = await API.get(`/partner/codes/${encodeURIComponent(code)}/info`);
if (info.redeemable) {
partnerHint.textContent = info.grants_founder
? `✓ Gültiger Code von "${info.label}" — du erhältst eine lebenslange Gründer-Lizenz!`
: `✓ Gültiger Einladungscode von "${info.label}"`;
partnerHint.style.cssText = 'display:block;background:var(--c-success-bg,#f0fdf4);color:var(--c-success,#16a34a);padding:var(--space-2) var(--space-3);border-radius:var(--radius-sm);font-size:var(--text-xs)';
_partnerValid = true;
} else {
partnerHint.textContent = 'Dieser Code ist bereits vollständig eingelöst.';
partnerHint.style.cssText = 'display:block;background:#fef2f2;color:#dc2626;padding:var(--space-2) var(--space-3);border-radius:var(--radius-sm);font-size:var(--text-xs)';
}
} catch {
partnerHint.textContent = 'Code nicht gefunden.';
partnerHint.style.cssText = 'display:block;color:var(--c-text-muted);font-size:var(--text-xs)';
}
}, 500);
});
}
document.getElementById('auth-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = e.target.querySelector('[type="submit"]');
const fd = UI.formData(e.target);
if (!fd.name?.trim()) {
UI.toast.warning('Bitte einen Namen eingeben.');
return;
}
if ((fd.password || '').length < 8) {
UI.toast.warning('Passwort muss mindestens 8 Zeichen lang sein.');
return;
}
await UI.asyncButton(btn, async () => {
const partnerCode = (fd.partner_code || '').trim().toUpperCase() || undefined;
const refCode = _storedRefCode();
const finalCode = partnerCode || refCode || undefined;
// QR-Token mitschicken — Backend ordnet ihn nur zu, wenn er zum Code passt
const qrToken = (() => { try { return localStorage.getItem('by_qr_token') || undefined; } catch { return undefined; } })();
const result = await API.auth.register(fd.email, fd.password, fd.name.trim(), finalCode, qrToken);
if (refCode) _clearRefCode();
try { localStorage.removeItem('by_qr_token'); } catch {}
if (result.pending_verification) {
_renderVerifyPending(fd.email);
return;
}
});
});
}
// ----------------------------------------------------------
// PUSH-BENACHRICHTIGUNGEN ANBIETEN (nach Login)
// ----------------------------------------------------------
function _offerPushNotifications() {
// Kleiner Toast-Banner mit Ja-Button — nicht-invasiv
const toastEl = document.createElement('div');
toastEl.id = 'push-offer-banner';
toastEl.style.cssText = [
'position:fixed',
'bottom:calc(var(--nav-h, 64px) + var(--space-3))',
'left:50%',
'transform:translateX(-50%)',
'background:var(--c-surface)',
'border:1.5px solid var(--c-border)',
'border-radius:var(--radius-lg)',
'box-shadow:var(--shadow-lg)',
'padding:var(--space-3) var(--space-4)',
'display:flex',
'align-items:center',
'gap:var(--space-3)',
'font-size:var(--text-sm)',
'z-index:1100',
'max-width:340px',
'width:calc(100% - var(--space-8))',
].join(';');
toastEl.innerHTML = `
Push-Benachrichtigungen aktivieren?
Ja
`;
document.body.appendChild(toastEl);
const remove = () => toastEl.remove();
document.getElementById('push-offer-yes')?.addEventListener('click', async () => {
remove();
try {
await API.subscribeToPush();
UI.toast.success('Push-Benachrichtigungen aktiviert.');
} catch {
UI.toast.warning('Push-Benachrichtigungen konnten nicht aktiviert werden.');
}
});
document.getElementById('push-offer-no')?.addEventListener('click', remove);
// Automatisch ausblenden nach 12 Sekunden
setTimeout(remove, 12000);
}
// ----------------------------------------------------------
// PASSWORT ZURÜCKSETZEN
// ----------------------------------------------------------
function _renderResetPassword(token) {
_container.innerHTML = `
Neues Passwort
Wähle ein sicheres Passwort für deinen Account.
Neues Passwort
🐾 Passwort-Vorschlag
↺ neu
Übernehmen
Passwort speichern
`;
_bindPwToggle('reset-pw-input', 'reset-pw-toggle');
const phraseEl = document.getElementById('reset-gen-phrase');
const pwInput = document.getElementById('reset-pw-input');
const _refresh = () => { phraseEl.textContent = _genPassphrase(); };
_refresh();
document.getElementById('reset-gen-new')?.addEventListener('click', _refresh);
document.getElementById('reset-gen-use')?.addEventListener('click', () => {
pwInput.value = phraseEl.textContent;
pwInput.type = 'text';
});
document.getElementById('reset-pw-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = e.target.querySelector('[type="submit"]');
const password = document.getElementById('reset-pw-input').value;
await UI.asyncButton(btn, async () => {
const res = await API.post('/auth/reset-password', { token, password });
if (res?.ok) {
UI.toast.success('Passwort geändert! Du kannst dich jetzt anmelden.');
_renderAuth('login');
}
});
});
}
// ----------------------------------------------------------
// HELPER
// ----------------------------------------------------------
// ----------------------------------------------------------
// PUBLIC
// ----------------------------------------------------------
return { init, refresh };
})();