banyaro/backend/static/js/pages/settings.js
rene 70af387147 Feature: User-Feedback, Regen-Uhrzeit im Wetter-Chip, Admin-Karten klickbar (SW by-v833)
- Feedback-Modal im Settings (Kategorie + Text → E-Mail an support@banyaro.app)
- Wetter-Chip (Karte + Gassi-Score): zeigt nächste Regenstunde ab ≥20% Wahrscheinlichkeit
- Gassi-Score-Chip: zweizeilige Wetter-Info, linksbündig, volle Chipbreite
- Admin-Übersicht: Stat-Karten anklickbar → navigiert direkt zum jeweiligen Tab
- ui.js: visualViewport-Listener hebt Modal über Tastatur (alle Modals)
- api.js: Pydantic v2 Array-Detail korrekt als Fehlermeldung extrahiert
- map.js: Wetter-Fallback über watchPosition wenn getCurrentPosition scheitert
- Update-Loop-Fix: index.html ?v= synchron mit APP_VER halten (alle 4 Stellen)
2026-05-10 12:52:55 +02:00

2024 lines
97 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ============================================================
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 {}
}
}
// ----------------------------------------------------------
// 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
? `<img src="${_esc(u.avatar_url)}" alt="Avatar"
style="width:56px;height:56px;border-radius:50%;object-fit:cover;display:block">`
: _esc(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 = `
<div class="page-container" style="box-sizing:border-box;overflow-x:hidden">
<div class="card" style="padding:var(--space-5);margin-bottom:var(--space-4)">
<div style="display:flex;align-items:center;gap:var(--space-4)">
<div id="settings-avatar-btn" class="by-avatar-circle">
${avatarInner}
<div style="position:absolute;inset:0;background:rgba(0,0,0,0.25);
display:flex;align-items:center;justify-content:center;
opacity:0;transition:opacity .15s"
class="avatar-overlay">
<svg style="width:20px;height:20px;color:#fff" fill="currentColor" viewBox="0 0 256 256">
<use href="/icons/phosphor.svg#camera"></use>
</svg>
</div>
</div>
<input type="file" id="settings-avatar-input" accept="image/*"
style="display:none">
<div>
<div style="font-weight:700;font-size:var(--text-lg)">${_esc(u.name)}</div>
<div style="display:flex;align-items:center;gap:var(--space-2);color:var(--c-text-secondary);font-size:var(--text-sm)">
${_esc(u.email)}
${u.email_verified
? `<svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px;color:#22c55e" title="Bestätigt"><use href="/icons/phosphor.svg#check-circle"></use></svg>`
: `<span id="settings-verify-chip"
style="font-size:10px;background:#fef3c7;color:#d97706;padding:1px 7px;
border-radius:999px;cursor:pointer;white-space:nowrap"
title="E-Mail noch nicht bestätigt">Nicht bestätigt</span>`}
</div>
<div style="display:flex;flex-wrap:wrap;gap:var(--space-1);margin-top:var(--space-1)">
${u.is_premium
? `<span class="badge badge-primary">
<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#star"></use></svg> Ban Yaro Plus
</span>`
: `<span class="badge" style="color:var(--c-text-secondary)">Kostenlos</span>`}
${u.is_founder
? `<span class="badge" style="background:#7c3aed;color:#fff;cursor:pointer" data-page="gruender">
<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#key"></use></svg>
${u.founder_number ? `Gründer #${u.founder_number}` : 'Gründer'}
</span>`
: u.is_founder_pending
? `<span class="badge" style="background:#f59e0b;color:#fff;cursor:pointer" data-page="dog-profile"
title="Hunde-Profil anlegen um Gründer-Platz zu sichern">
<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#hourglass"></use></svg>
Gründer-Platz reserviert
</span>` : ''}
${u.is_partner
? `<span class="badge" style="background:#0ea5e9;color:#fff">
<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#handshake"></use></svg> Partner
</span>` : ''}
</div>
</div>
</div>
</div>
<!-- Mein Profil -->
<div class="card" style="margin-bottom:var(--space-4)">
<div style="padding:var(--space-3) var(--space-4);
font-size:var(--text-xs);font-weight:600;
color:var(--c-text-secondary);text-transform:uppercase;
letter-spacing:0.05em;border-bottom:1px solid var(--c-border);
display:flex;align-items:center;justify-content:space-between">
<span>Mein Profil</span>
<button id="settings-profile-edit-btn"
class="btn btn-ghost"
style="font-size:var(--text-xs);padding:var(--space-1) var(--space-2)">
Profil bearbeiten
</button>
</div>
<div style="padding:var(--space-4);display:flex;flex-direction:column;gap:var(--space-3)">
${memberSince
? `<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
Mitglied seit ${_esc(memberSince)}
</div>`
: ''}
${u.bio
? `<div style="font-size:var(--text-sm)">${_esc(u.bio)}</div>`
: ''}
${u.wohnort
? `<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
📍 ${_esc(u.wohnort)}
</div>`
: ''}
${u.erfahrung && erfahrungLabel[u.erfahrung]
? `<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
${_esc(erfahrungLabel[u.erfahrung])}
</div>`
: ''}
${u.social_link
? `<div style="font-size:var(--text-sm)">
<a href="${_esc(u.social_link)}" target="_blank" rel="noopener"
style="color:var(--c-primary)">${_esc(u.social_link)}</a>
</div>`
: ''}
${!u.bio && !u.wohnort && !u.erfahrung && !u.social_link
? `<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
Noch kein Profil ausgefüllt.
</div>`
: ''}
</div>
</div>
<div class="card" id="settings-stats-card" style="margin-bottom:var(--space-4)">
<div class="by-card-section-header">Aktivität</div>
<div id="settings-stats-body" style="padding:var(--space-4);display:flex;justify-content:space-around">
<div style="color:var(--c-text-muted);font-size:var(--text-sm)">Lädt…</div>
</div>
<div id="settings-streak" style="display:flex;align-items:center;gap:8px;
padding:0 var(--space-4) var(--space-3);flex-wrap:wrap"></div>
<div id="settings-lifetime-km" style="border-top:1px solid var(--c-border)"></div>
</div>
<!-- Züchter-Profil Slot -->
<div id="breeder-card-slot"></div>
<div class="card" style="margin-bottom:var(--space-4)">
<div class="by-card-section-header">Trophäen</div>
<div id="settings-badges-body" style="padding:var(--space-4)">
<div style="color:var(--c-text-muted);font-size:var(--text-sm)">Lädt…</div>
</div>
</div>
<div class="card" style="margin-bottom:var(--space-4)">
<div class="card-body" style="padding:0">
<div class="sidebar-item" data-page="dog-profile"
style="padding:var(--space-4);border-radius:0;border-bottom:1px solid var(--c-border)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>
<span>Hunde-Profile</span>
<span style="margin-left:auto;color:var(--c-text-secondary)"></span>
</div>
<div class="sidebar-item" id="settings-push-btn"
style="padding:var(--space-4);border-radius:0;border-bottom:1px solid var(--c-border)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#bell"></use></svg>
<span>Push-Benachrichtigungen</span>
<span style="margin-left:auto;color:var(--c-text-secondary)"></span>
</div>
<div class="sidebar-item" id="settings-calendar-btn"
style="padding:var(--space-4);border-radius:0;border-bottom:1px solid var(--c-border)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#calendar-dots"></use></svg>
<span>Kalender abonnieren</span>
<span style="margin-left:auto;color:var(--c-text-secondary)"></span>
</div>
<div class="sidebar-item" id="settings-worlds-btn"
style="padding:var(--space-4);border-radius:0;border-bottom:1px solid var(--c-border);cursor:pointer">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#squares-four"></use></svg>
<span>Welten einrichten</span>
<span style="margin-left:auto;color:var(--c-text-secondary)"></span>
</div>
<div class="sidebar-item" id="settings-hilfe-btn"
style="padding:var(--space-4);border-radius:0;border-bottom:1px solid var(--c-border);cursor:pointer">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#question"></use></svg>
<span>Hilfe &amp; FAQ</span>
<span style="margin-left:auto;color:var(--c-text-secondary)"></span>
</div>
<div class="sidebar-item" id="settings-feedback-btn"
style="padding:var(--space-4);border-radius:0;border-bottom:1px solid var(--c-border);cursor:pointer">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#chat-dots"></use></svg>
<span>Feedback geben</span>
<span style="margin-left:auto;color:var(--c-text-secondary)"></span>
</div>
${!_appState.user?.subscription_tier || _appState.user.subscription_tier === 'standard' || _appState.user.subscription_tier === 'standard_test' ? `
<div style="margin:var(--space-3) 0;padding:var(--space-3) var(--space-4);
background:rgba(196,132,58,0.1);border-radius:var(--radius-md);
font-size:var(--text-xs);color:var(--c-text-secondary)">
⭐ <strong>Ban Yaro Pro</strong> kommt bald — mehr Features, mehrere Hunde.
</div>
` : ''}
<div style="padding:var(--space-3) var(--space-4);border-top:1px solid var(--c-border)">
<button id="settings-logout-btn"
style="width:100%;display:flex;align-items:center;justify-content:center;
gap:var(--space-2);padding:var(--space-3) var(--space-4);
border-radius:var(--radius-md);border:1.5px solid var(--c-danger);
background:transparent;color:var(--c-danger);
font-size:var(--text-sm);font-weight:600;cursor:pointer;
transition:background 0.15s">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#sign-out"></use></svg>
Abmelden
</button>
<button id="settings-delete-account-btn"
style="width:100%;margin-top:var(--space-2);display:flex;align-items:center;justify-content:center;
gap:var(--space-2);padding:var(--space-2) var(--space-4);
border-radius:var(--radius-md);border:none;
background:none;color:var(--c-text-muted);
font-size:var(--text-xs);cursor:pointer">
<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#trash"></use></svg>
Konto löschen
</button>
</div>
</div>
</div>
<div class="card" style="margin-bottom:var(--space-4)">
<div class="by-card-section-header">
App-Einstellungen
</div>
<div class="card-body" style="padding:0">
<!-- Dark-Mode-Auswahl -->
<div class="settings-toggle-row">
<svg class="ph-icon" aria-hidden="true" style="width:1.25rem;height:1.25rem"><use href="/icons/phosphor.svg#moon"></use></svg>
<div class="settings-toggle-label">
<div style="font-weight:500">Dark Mode</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
Erscheinungsbild der App
</div>
</div>
<select id="select-theme"
style="padding:var(--space-1) var(--space-2);
border:1.5px solid var(--c-border);
border-radius:var(--radius-md);
background:var(--c-surface);
color:var(--c-text);
font-size:var(--text-sm);
font-family:inherit;
cursor:pointer">
<option value="system" ${(u.preferred_theme||localStorage.getItem('by_theme')||'system') === 'system' ? 'selected' : ''}>System</option>
<option value="light" ${(u.preferred_theme||localStorage.getItem('by_theme')) === 'light' ? 'selected' : ''}>Hell</option>
<option value="dark" ${(u.preferred_theme||localStorage.getItem('by_theme')) === 'dark' ? 'selected' : ''}>Dunkel</option>
</select>
</div>
${/SamsungBrowser/i.test(navigator.userAgent) ? `
<div style="margin:6px 0 4px;padding:10px 12px;border-radius:var(--radius-md);
background:var(--c-warning-subtle,rgba(245,158,11,0.12));
border:1px solid rgba(245,158,11,0.3);
font-size:var(--text-xs);color:var(--c-text-secondary);line-height:1.5">
<strong style="color:var(--c-warning,#f59e0b)">Samsung Internet Tipps:</strong><br>
• Farben: <em>Einstellungen → Webseitenansicht → Dark Mode</em> deaktivieren.<br>
• Vollbild: <em>Einstellungen → Display → Navigationsleiste → Wischgesten</em> aktivieren.
</div>` : ''}
<!-- KI-Notiz-Assistent -->
<div class="settings-toggle-row">
<svg class="ph-icon" aria-hidden="true" style="width:1.25rem;height:1.25rem"><use href="/icons/phosphor.svg#brain"></use></svg>
<div class="settings-toggle-label">
<div style="font-weight:500">KI-Notiz-Assistent</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
Erkennt Muster in deinen Notizen und macht Vorschläge
</div>
</div>
<label style="position:relative;display:inline-block;width:44px;height:24px;flex-shrink:0">
<input type="checkbox" id="toggle-notes-ki"
style="opacity:0;width:0;height:0;position:absolute"
${u.notes_ki_enabled ? 'checked' : ''}>
<span style="position:absolute;cursor:pointer;inset:0;border-radius:12px;
background:var(--c-border);transition:.2s"
id="toggle-notes-ki-track"></span>
<span id="toggle-notes-ki-thumb"
style="position:absolute;top:2px;left:${u.notes_ki_enabled ? '22px' : '2px'};
width:20px;height:20px;border-radius:50%;
background:#fff;transition:.2s;
box-shadow:0 1px 3px rgba(0,0,0,.3)"></span>
</label>
</div>
<!-- Goldene Gassi-Stunde -->
<div class="settings-toggle-row" style="border-bottom:none">
<svg class="ph-icon" aria-hidden="true" style="width:1.25rem;height:1.25rem"><use href="/icons/phosphor.svg#paw-print"></use></svg>
<div class="settings-toggle-label">
<div style="font-weight:500">Goldene Gassi-Stunde täglich</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
Täglich um 07:00 Uhr: bestes Wetterfenster für den Gassi-Gang
</div>
</div>
<label style="position:relative;display:inline-block;width:44px;height:24px;flex-shrink:0">
<input type="checkbox" id="toggle-gassi-stunde"
style="opacity:0;width:0;height:0;position:absolute"
${u.gassi_stunde_push ? 'checked' : ''}>
<span style="position:absolute;cursor:pointer;inset:0;border-radius:12px;
background:${u.gassi_stunde_push ? 'var(--c-primary)' : 'var(--c-border)'};transition:.2s"
id="toggle-gassi-stunde-track"></span>
<span id="toggle-gassi-stunde-thumb"
style="position:absolute;top:2px;left:${u.gassi_stunde_push ? '22px' : '2px'};
width:20px;height:20px;border-radius:50%;
background:#fff;transition:.2s;
box-shadow:0 1px 3px rgba(0,0,0,.3)"></span>
</label>
</div>
</div>
</div>
<!-- App empfehlen -->
<div class="card" style="margin-bottom:var(--space-5)" id="referral-card">
<div style="padding:var(--space-4);border-bottom:1px solid var(--c-border)">
<div style="font-weight:600;margin-bottom:2px">${UI.icon('arrow-square-out')} Freunde werben — dauerhafter Rabatt</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
10 Freunde → 20% · 20 Freunde → 30% · 50 Freunde → 50% — lebenslang, sobald Bezahlfunktionen aktiv sind.
</div>
</div>
<div id="referral-body" style="padding:var(--space-4)">Lade…</div>
</div>
<!-- App installieren -->
<div class="card" style="margin-bottom:var(--space-4)">
<div class="by-card-section-header">
App installieren
</div>
<div class="card-body" style="padding:0">
<div class="sidebar-item" id="settings-install-btn"
style="padding:var(--space-4);border-radius:0;cursor:pointer">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#download-simple"></use></svg>
<span>Installations-Anleitung</span>
<span style="margin-left:auto;color:var(--c-text-secondary)"></span>
</div>
</div>
</div>
<!-- App-Version & Update -->
<div style="text-align:center;color:var(--c-text-secondary);font-size:var(--text-xs)">
Ban Yaro · banyaro.app<br>
Deine Daten liegen auf einem eigenen Server in Deutschland.
<div style="margin-top:var(--space-3);display:flex;align-items:center;
justify-content:center;gap:var(--space-2);flex-wrap:wrap">
<span style="background:var(--c-surface-2);border:1px solid var(--c-border);
border-radius:100px;padding:2px 10px;font-family:monospace;
font-size:10px;color:var(--c-text-muted)">
v${typeof APP_VERSION !== 'undefined' ? APP_VERSION : '1.0.0'} <span style="opacity:0.5;font-size:10px">(${typeof APP_VER !== 'undefined' ? APP_VER : '—'})</span>
</span>
<button id="settings-check-update" style="background:none;border:none;cursor:pointer;
font-size:var(--text-xs);color:var(--c-primary);padding:2px 6px;
border-radius:var(--radius-sm)">
${UI.icon('arrows-clockwise')} Auf Update prüfen
</button>
</div>
</div>
</div>
`;
// 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) => `
<div style="text-align:center">
<div style="font-size:1.3rem;font-weight:700;color:var(--c-text)">${val}</div>
<div style="font-size:10px;color:var(--c-text-secondary);text-transform:uppercase;letter-spacing:.05em;margin-top:2px">${label}</div>
</div>`;
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
? `<span style="font-size:1.3rem">🔥</span>
<span style="font-weight:700;font-size:1.05rem">${cur} Tage Streak</span>
${mx > cur ? `<span style="color:var(--c-text-muted);font-size:11px;margin-left:auto">Best: ${mx}</span>` : ''}`
: `<span style="color:var(--c-text-muted);font-size:var(--text-sm)">🔥 Noch kein Streak — heute aktiv werden!</span>`;
}
// 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 `<div title="${m.badge}" style="position:absolute;left:${pos}%;top:-4px;transform:translateX(-50%);
width:12px;height:12px;border-radius:50%;border:2px solid ${m.color};
background:${reached ? m.color : 'var(--c-bg)'};z-index:2">
</div>
<div style="position:absolute;left:${pos}%;top:12px;transform:translateX(-50%);
font-size:9px;color:${reached ? m.color : 'var(--c-text-muted)'};font-weight:600;
white-space:nowrap">${m.label}</div>`;
}).join('');
lifetimeEl.innerHTML = `
<div style="padding:var(--space-3) var(--space-4) 0;
display:flex;justify-content:space-between;align-items:center">
<span style="font-size:var(--text-xs);font-weight:600;color:var(--c-text-secondary);
text-transform:uppercase;letter-spacing:.05em">🐾 Lebenswerk-km</span>
<span style="font-size:var(--text-sm);font-weight:700;color:var(--c-primary)">${km} km</span>
</div>
<div style="padding:var(--space-3) var(--space-4) var(--space-4)">
<div style="position:relative;height:8px;background:var(--c-border);border-radius:4px;
overflow:visible;margin-bottom:22px">
<div style="position:absolute;left:0;top:0;height:100%;width:${pct}%;
background:linear-gradient(90deg,#10b981,#0ea5e9);
border-radius:4px;z-index:1;transition:width .6s"></div>
${markers}
</div>
${nextM
? `<div style="font-size:11px;color:var(--c-text-muted)">
Noch <strong>${(nextM.km - km).toLocaleString('de-DE')}&nbsp;km</strong>
bis <span style="color:${nextM.color};font-weight:600">${nextM.badge}</span>
</div>`
: `<div style="font-size:11px;color:var(--c-primary);font-weight:600">
Ultraläufer-Legende erreicht! 🏆
</div>`}
</div>`;
}
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 `<svg viewBox="0 0 60 72" xmlns="http://www.w3.org/2000/svg"
style="width:56px;height:56px;filter:drop-shadow(0 2px 8px rgba(0,0,0,.4))">
<defs>
<clipPath id="${clipId}"><path d="${path}"/></clipPath>
</defs>
<image href="${photo}" x="0" y="0" width="60" height="72"
clip-path="url(#${clipId})" preserveAspectRatio="xMidYMid slice"/>
<path d="${path}" fill="rgba(0,0,0,0.28)"/>
<path d="${path}" fill="none" stroke="rgba(255,255,255,.4)" stroke-width="1.5"/>
<text x="30" y="43" text-anchor="middle" dominant-baseline="middle"
font-size="22" style="user-select:none">${emoji}</text>
</svg>`;
}
return `<svg viewBox="0 0 60 72" xmlns="http://www.w3.org/2000/svg"
style="width:56px;height:56px;filter:drop-shadow(0 2px 6px ${color}66)">
<defs>
<linearGradient id="g_${color.replace('#','')}" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="${color}"/>
<stop offset="100%" stop-color="${dark}"/>
</linearGradient>
</defs>
<path d="${path}" fill="url(#g_${color.replace('#','')})" opacity="${opacity}"/>
<path d="${path}" fill="none" stroke="rgba(255,255,255,.25)" stroke-width="1.5"/>
<text x="30" y="43" text-anchor="middle" dominant-baseline="middle"
font-size="22" style="user-select:none">${emoji}</text>
</svg>`;
};
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 =>
`<div title="${_esc(s.name)}" style="width:8px;height:8px;border-radius:50%;
background:${s.earned ? s.color : 'var(--c-border)'}"></div>`
).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 ? `
<div style="font-size:10px;color:var(--c-text-muted);margin-top:4px">
${val}${cat.einheit} / ${nxt.schwelle}${cat.einheit}${_esc(nxt.name)}
</div>
<div style="height:4px;background:var(--c-border);border-radius:2px;margin-top:4px;overflow:hidden">
<div style="height:100%;width:${cat.progress}%;background:${nxt.color};border-radius:2px;transition:width .4s"></div>
</div>` : `
<div style="font-size:10px;color:var(--c-primary);font-weight:600;margin-top:4px">
Höchste Stufe erreicht! 🎉
</div>`;
return `
<div style="display:flex;gap:14px;align-items:flex-start;padding:12px 0;
border-bottom:1px solid var(--c-border)">
${shieldSvg}
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:2px">
<span style="font-weight:700;font-size:var(--text-sm)">${_esc(cat.name)}</span>
${cur ? `<span style="font-size:10px;font-weight:600;padding:1px 6px;border-radius:999px;
background:${cur.color};color:${cur.text}">${_esc(cur.name)}</span>` : ''}
</div>
<div style="display:flex;gap:4px;margin-bottom:6px">${dots}</div>
${progressBar}
</div>
</div>`;
}).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 = '<div style="color:var(--c-text-muted);font-size:var(--text-sm)"></div>';
});
// 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]) =>
`<option value="${_esc(val)}" ${u.erfahrung === val ? 'selected' : ''}>${_esc(label)}</option>`
).join('');
const sichtbarkeitOpts = [
['public', 'Öffentlich'],
['friends', 'Nur Freunde'],
['private', 'Privat'],
].map(([val, label]) =>
`<option value="${_esc(val)}" ${(u.profil_sichtbarkeit || 'public') === val ? 'selected' : ''}>${_esc(label)}</option>`
).join('');
UI.modal.open({
title: 'Profil bearbeiten',
body: `
<form id="profile-form" style="display:flex;flex-direction:column;gap:var(--space-4)">
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Echter Name (privat)</label>
<input name="real_name" type="text" maxlength="80"
placeholder="z. B. Maria Müller"
value="${_esc(u.real_name || '')}"
style="${inputStyle}">
</div>
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Bio</label>
<textarea name="bio" maxlength="300" rows="4"
placeholder="Kurze Vorstellung (max. 300 Zeichen)"
style="${inputStyle};resize:vertical">${_esc(u.bio || '')}</textarea>
</div>
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Wohnort</label>
<input name="wohnort" type="text" maxlength="60"
placeholder="z.B. München"
value="${_esc(u.wohnort || '')}"
style="${inputStyle}">
</div>
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Erfahrung</label>
<select name="erfahrung" style="${inputStyle}">${erfahrungOpts}</select>
</div>
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Social-Link</label>
<input name="social_link" type="url" maxlength="120"
placeholder="https://instagram.com/dein-hundeaccount"
value="${_esc(u.social_link || '')}"
style="${inputStyle}">
</div>
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Profil-Sichtbarkeit</label>
<select name="profil_sichtbarkeit" style="${inputStyle}">${sichtbarkeitOpts}</select>
</div>
</form>
`,
footer: `
<div class="w3-btn-stack">
<button type="submit" form="profile-form" class="btn btn-primary" style="width:100%">Speichern</button>
<button type="button" class="btn btn-secondary" data-modal-close>Abbrechen</button>
</div>
`,
});
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',
});
Object.assign(_appState.user, updated);
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' });
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-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: `
<form id="feedback-form" style="display:flex;flex-direction:column;gap:var(--space-4)">
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Kategorie</label>
<select id="feedback-kat" name="kategorie" style="${inputStyle}">
<option value="idee">💡 Idee / Wunsch</option>
<option value="bug">🐛 Bug / Fehler</option>
<option value="lob">🎉 Lob</option>
<option value="sonstiges">💬 Sonstiges</option>
</select>
</div>
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Deine Nachricht</label>
<textarea id="feedback-text" name="text" rows="5" maxlength="2000"
placeholder="Was möchtest du uns mitteilen?"
style="${inputStyle};resize:vertical"></textarea>
</div>
</form>
`,
footer: `
<div class="w3-btn-stack">
<button type="submit" form="feedback-form" id="feedback-submit-btn" class="btn btn-primary" style="width:100%">Absenden</button>
<button type="button" class="btn btn-secondary" data-modal-close>Abbrechen</button>
</div>
`,
});
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-delete-account-btn')?.addEventListener('click', async () => {
const ok = await UI.modal.confirm({
title: 'Konto unwiderruflich löschen?',
body: '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: `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-4)">
Abonniere deinen persönlichen Ban-Yaro-Kalender. Er enthält Impf-Erinnerungen,
Läufigkeits-Termine, Events und Gassi-Treffen — immer aktuell.
</p>
<div style="background:var(--c-bg);border-radius:var(--radius-md);
padding:var(--space-3) var(--space-4);
font-size:var(--text-xs);color:var(--c-text-secondary);
word-break:break-all;margin-bottom:var(--space-4)">
${httpsUrl}
</div>
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
<a href="${url}"
class="btn btn-primary"
style="text-align:center">
${UI.icon('calendar-dots')} In Kalender-App öffnen
</a>
<button class="btn btn-secondary" id="cal-copy-btn">
${UI.icon('clipboard-text')} URL kopieren
</button>
</div>
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-4)">
Tipp: iOS → Einstellungen Kalender Accounts Account hinzufügen Andere Kalenderabo
</p>
`,
});
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.');
});
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();
}
// ----------------------------------------------------------
// KI-Toggle-Zeile (Hilfsfunktion für Züchter-Card)
// ----------------------------------------------------------
function _kiToggleRow(key, label, user) {
const active = user[key] !== 0;
return `
<div style="display:flex;justify-content:space-between;align-items:center;
padding:var(--space-2) 0;font-size:var(--text-sm)">
<span>${_esc(label)}</span>
<button class="by-toggle ki-toggle-btn" data-key="${_esc(key)}"
data-active="${active ? '1' : '0'}"
style="position:relative;display:inline-block;width:44px;height:24px;
border:none;border-radius:12px;cursor:pointer;flex-shrink:0;
background:${active ? 'var(--c-primary)' : 'var(--c-border)'};
transition:background .2s">
<span class="by-toggle-thumb"
style="position:absolute;top:2px;left:${active ? '22px' : '2px'};
width:20px;height:20px;border-radius:50%;
background:#fff;transition:left .2s;
box-shadow:0 1px 3px rgba(0,0,0,.3)"></span>
</button>
</div>`;
}
// ----------------------------------------------------------
// 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') {
statusBadge = `<span class="badge badge-primary" style="background:var(--c-success);color:#fff">
${UI.icon('check-circle')} ${rolle === 'admin' ? 'Admin — alle Züchter-Features verfügbar' : 'Verifizierter Züchter'}
</span>`;
actionBlock = `
<div style="margin-top:var(--space-3);font-size:var(--text-sm);display:flex;flex-direction:column;gap:var(--space-1)">
${profile?.zwingername ? `<div style="color:var(--c-text-secondary)">Zwinger: <strong>${_esc(profile.zwingername)}</strong></div>` : ''}
${profile?.rasse_text ? `<div style="color:var(--c-text-secondary)">Rasse: <strong>${_esc(profile.rasse_text)}</strong></div>` : ''}
</div>
${rolle === 'breeder' && profile ? `
<button class="btn btn-secondary btn-sm" id="breeder-edit-profile-btn" style="margin-top:var(--space-3)">
${UI.icon('pencil-simple')} Profil bearbeiten
</button>` : ''}
${rolle === 'admin' && !profile ? `
<button class="btn btn-primary btn-sm" id="breeder-admin-create-btn" style="margin-top:var(--space-3)">
${UI.icon('plus')} Admin-Züchterprofil anlegen
</button>` : ''}
${rolle === 'admin' && profile ? `
<button class="btn btn-secondary btn-sm" id="breeder-edit-profile-btn" style="margin-top:var(--space-3)">
${UI.icon('pencil-simple')} Profil bearbeiten
</button>` : ''}
${profile ? `
<div style="margin-top:var(--space-4);padding-top:var(--space-3);border-top:1px solid var(--c-border)">
<div style="font-size:var(--text-xs);font-weight:600;color:var(--c-text-secondary);
text-transform:uppercase;letter-spacing:0.05em;margin-bottom:var(--space-3)">
KI-Züchter-Assistenz
</div>
${_kiToggleRow('ki_zucht_wurfankuendigung', 'Wurfankündigungen schreiben', _appState.user || {})}
${_kiToggleRow('ki_zucht_genetik', 'Genetik-Erklärung für Käufer', _appState.user || {})}
${_kiToggleRow('ki_zucht_paarung', 'Paarungsanalyse', _appState.user || {})}
${_kiToggleRow('ki_zucht_beschreibung', 'Hunde-Beschreibungen', _appState.user || {})}
${_kiToggleRow('ki_zucht_jahresbericht', 'Jahresauswertung', _appState.user || {})}
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-2)">
${UI.icon('info')} Der Tierschutz-Check läuft immer automatisch und ist nicht abschaltbar.
</div>
</div>` : ''}`;
} else if (breeder_status === 'pending') {
statusBadge = `<span class="badge" style="background:#f59e0b;color:#fff">
${UI.icon('hourglass')} Antrag wird geprüft
</span>`;
} else if (breeder_status === 'rejected') {
statusBadge = `<span class="badge" style="background:var(--c-danger);color:#fff">
${UI.icon('x-circle')} Abgelehnt
</span>`;
actionBlock = `
<div style="margin-top:var(--space-3)">
<button class="btn btn-secondary btn-sm" id="breeder-reapply-btn">
${UI.icon('arrow-counter-clockwise')} Neu beantragen
</button>
</div>`;
} else {
actionBlock = `
<div style="margin-top:var(--space-3)">
<button class="btn btn-primary btn-sm" id="breeder-apply-btn">
${UI.icon('certificate')} Züchter werden
</button>
</div>`;
}
slot.innerHTML = `
<div class="card" style="margin-bottom:var(--space-4)">
<div class="by-card-section-header">Züchter-Profil</div>
<div style="padding:var(--space-4)">
${statusBadge}
${actionBlock}
</div>
</div>`;
// Button-Handler binden
const applyBtn = slot.querySelector('#breeder-apply-btn');
const reapplyBtn = slot.querySelector('#breeder-reapply-btn');
if (applyBtn || reapplyBtn) {
(applyBtn || reapplyBtn).addEventListener('click', () => _openBreederApplyModal());
}
slot.querySelector('#breeder-edit-profile-btn')?.addEventListener('click', () =>
_openBreederEditModal(profile)
);
slot.querySelector('#breeder-admin-create-btn')?.addEventListener('click', async (e) => {
const btn = e.currentTarget;
btn.disabled = true;
btn.textContent = 'Wird angelegt…';
try {
await API.breeder.adminCreateProfile();
UI.toast.success('Admin-Züchterprofil angelegt. Bitte Seite neu laden.');
_loadBreederCard();
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Anlegen.');
btn.disabled = false;
btn.innerHTML = `${UI.icon('plus')} Admin-Züchterprofil anlegen`;
}
});
// KI-Toggle-Handler
slot.querySelectorAll('.ki-toggle-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const key = btn.dataset.key;
const active = btn.dataset.active === '1';
const newVal = active ? 0 : 1;
// Optimistisches UI-Update
btn.dataset.active = newVal ? '1' : '0';
btn.style.background = newVal ? 'var(--c-primary)' : 'var(--c-border)';
const thumb = btn.querySelector('.by-toggle-thumb');
if (thumb) thumb.style.left = newVal ? '22px' : '2px';
try {
const updated = await API.patch('/profile', { [key]: newVal });
if (_appState?.user) _appState.user[key] = newVal;
UI.toast.success(newVal ? 'KI-Feature aktiviert.' : 'KI-Feature deaktiviert.');
} catch (err) {
// Revert
btn.dataset.active = active ? '1' : '0';
btn.style.background = active ? 'var(--c-primary)' : 'var(--c-border)';
if (thumb) thumb.style.left = active ? '22px' : '2px';
UI.toast.error(err?.message || 'Einstellung konnte nicht gespeichert werden.');
}
});
});
}
// ----------------------------------------------------------
// ZÜCHTER-PROFIL BEARBEITEN MODAL
// ----------------------------------------------------------
function _openBreederEditModal(profile) {
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)`;
UI.modal.open({
title: `${UI.icon('pencil-simple')} Züchter-Profil bearbeiten`,
body: `
<form id="breeder-edit-form" style="display:flex;flex-direction:column;gap:var(--space-4)">
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Zwingername</label>
<input name="zwingername" type="text" maxlength="100" style="${inputStyle}"
value="${_esc(profile?.zwingername || '')}">
</div>
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Rasse</label>
<input name="rasse_text" type="text" maxlength="100" style="${inputStyle}"
value="${_esc(profile?.rasse_text || '')}">
</div>
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Zuchtverein</label>
<input name="verein" type="text" maxlength="100" style="${inputStyle}"
value="${_esc(profile?.verein || '')}">
</div>
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Stadt</label>
<input name="stadt" type="text" maxlength="80" style="${inputStyle}"
value="${_esc(profile?.stadt || '')}">
</div>
<div style="display:flex;align-items:center;gap:var(--space-3)">
<input name="vdh_mitglied" type="checkbox" id="edit-breeder-vdh"
style="width:18px;height:18px;cursor:pointer;flex-shrink:0"
${profile?.vdh_mitglied ? 'checked' : ''}>
<label for="edit-breeder-vdh" style="font-size:var(--text-sm);cursor:pointer">VDH-Mitglied</label>
</div>
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Website (optional)</label>
<input name="website" type="url" maxlength="200" style="${inputStyle}"
value="${_esc(profile?.website || '')}" placeholder="https://mein-zwinger.de">
</div>
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Beschreibung (optional)</label>
<textarea name="beschreibung" maxlength="500" rows="3"
style="${inputStyle};resize:vertical">${_esc(profile?.beschreibung || '')}</textarea>
</div>
</form>`,
footer: `
<div style="display:flex;gap:var(--space-2);width:100%">
<button type="submit" form="breeder-edit-form" class="btn btn-primary flex-1" id="breeder-edit-submit">Speichern</button>
<button type="button" class="btn btn-secondary" data-modal-close>Abbrechen</button>
</div>`,
});
document.getElementById('breeder-edit-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = document.getElementById('breeder-edit-submit');
await UI.asyncButton(btn, async () => {
const form = e.target;
const data = {
zwingername: form.zwingername.value.trim() || undefined,
rasse_text: form.rasse_text.value.trim() || undefined,
verein: form.verein.value.trim() || undefined,
stadt: form.stadt.value.trim() || undefined,
vdh_mitglied: form.vdh_mitglied.checked ? 1 : 0,
website: form.website.value.trim() || undefined,
beschreibung: form.beschreibung.value.trim() || undefined,
};
await API.breeder.updateProfile(data);
UI.modal.close?.();
UI.toast.success('Profil aktualisiert.');
_loadBreederCard();
});
});
}
// ----------------------------------------------------------
// ZÜCHTER-ANTRAG MODAL
// ----------------------------------------------------------
function _openBreederApplyModal() {
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)`;
UI.modal.open({
title: `${UI.icon('certificate')} Züchter-Antrag stellen`,
body: `
<form id="breeder-apply-form" style="display:flex;flex-direction:column;gap:var(--space-4)">
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">
Zwingername <span style="color:var(--c-danger)">*</span>
</label>
<input name="zwingername" type="text" maxlength="100" required
placeholder="z. B. vom Sonnenfeld"
style="${inputStyle}">
</div>
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">
Rasse <span style="color:var(--c-danger)">*</span>
</label>
<input name="rasse_text" type="text" maxlength="100" required
placeholder="z. B. Labrador Retriever"
style="${inputStyle}">
</div>
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">
Zuchtverein <span style="color:var(--c-danger)">*</span>
</label>
<input name="verein" type="text" maxlength="100" required
placeholder="z. B. DLRG, VDH, BCD"
style="${inputStyle}">
</div>
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">
Stadt <span style="color:var(--c-danger)">*</span>
</label>
<input name="stadt" type="text" maxlength="80" required
placeholder="z. B. München"
style="${inputStyle}">
</div>
<div style="display:flex;align-items:center;gap:var(--space-3)">
<input name="vdh_mitglied" type="checkbox" id="breeder-vdh"
style="width:18px;height:18px;cursor:pointer;flex-shrink:0">
<label for="breeder-vdh" style="font-size:var(--text-sm);cursor:pointer">
VDH-Mitglied
</label>
</div>
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">
Website (optional)
</label>
<input name="website" type="url" maxlength="200"
placeholder="https://mein-zwinger.de"
style="${inputStyle}">
</div>
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">
Beschreibung (optional)
</label>
<textarea name="beschreibung" maxlength="500" rows="3"
placeholder="Kurze Beschreibung deines Zwingers"
style="${inputStyle};resize:vertical"></textarea>
</div>
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">
Dokument hochladen <span style="color:var(--c-danger)">*</span>
</label>
<input name="dokument" type="file" id="breeder-doc-input" required
accept=".pdf,.jpg,.jpeg,.png,.webp"
style="font-size:var(--text-sm);width:100%;box-sizing:border-box">
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-1)">
Zuchtbuch-Eintrag, Vereinsmitgliedschaft o.ä. (PDF, JPG, PNG, WebP)
</div>
</div>
</form>
`,
footer: `
<div class="w3-btn-stack">
<button type="submit" form="breeder-apply-form" class="btn btn-primary" id="breeder-apply-submit"
style="width:100%">Antrag einreichen</button>
<button type="button" class="btn btn-secondary" data-modal-close>Abbrechen</button>
</div>
`,
});
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 = `
<!-- Tier-Übersicht -->
<div style="display:flex;gap:var(--space-2);margin-bottom:var(--space-4)">
${TIERS.map(({t, d}) => {
const reached = r.count >= t;
return `<div style="flex:1;padding:var(--space-2) var(--space-1);border-radius:var(--radius-md);
text-align:center;border:2px solid ${reached ? '#7c3aed' : 'var(--c-border)'};
background:${reached ? 'rgba(124,58,237,.08)' : 'var(--c-surface-2)'}">
<div style="font-size:var(--text-lg);font-weight:800;color:${reached ? '#7c3aed' : 'var(--c-text-muted)'}">
${d}%
</div>
<div style="font-size:10px;color:var(--c-text-muted)">ab ${t} Freunden</div>
${reached ? `<div style="font-size:10px;font-weight:700;color:#7c3aed">✓ Erreicht</div>` : ''}
</div>`;
}).join('')}
</div>
<!-- Zähler + Fortschritt -->
<div style="margin-bottom:var(--space-4)">
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:var(--space-1)">
<span style="font-size:var(--text-sm);font-weight:600">
${UI.icon('users')} <strong>${r.count}</strong> ${r.count === 1 ? 'Freund geworben' : 'Freunde geworben'}
</span>
${currentTier > 0 ? `<span style="font-size:var(--text-xs);font-weight:700;color:#7c3aed">${currentTier}% Rabatt aktiv</span>` : ''}
</div>
<div style="background:var(--c-surface-2);border-radius:var(--radius-full);height:10px;overflow:hidden">
<div style="background:linear-gradient(90deg,#7c3aed,#a855f7);width:${barPct}%;height:100%;
border-radius:var(--radius-full);transition:width .5s ease"></div>
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-1)">${barLabel}</div>
</div>
<!-- Link + QR -->
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-3)">
<div style="flex:1;min-width:0;background:var(--c-surface-2);border-radius:var(--radius-md);
padding:var(--space-2) var(--space-3);font-family:monospace;font-size:var(--text-sm);
overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${r.link}</div>
<button class="btn btn-primary btn-sm" id="ref-share-btn">${UI.icon('arrow-square-out')} Teilen</button>
</div>
<div style="display:flex;justify-content:center;margin-bottom:var(--space-1)">
<div style="position:relative;width:140px;height:140px">
<div id="ref-qr" style="width:140px;height:140px;border-radius:var(--radius-md);overflow:hidden"></div>
<img src="/icons/icon-180.png" alt=""
style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);
width:32px;height:32px;border-radius:7px;border:2px solid #fff">
</div>
</div>
<p style="font-size:var(--text-xs);color:var(--c-text-muted);text-align:center;margin:0">
Der Rabatt gilt für dich sobald Bezahlfunktionen aktiv sind, dauerhaft und automatisch.
</p>
`;
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 = ''; }
}
// ----------------------------------------------------------
// NICHT EINGELOGGT — Login / Registrierung
// ----------------------------------------------------------
function _renderVerifyPending(email) {
_container.innerHTML = `
<div style="max-width:380px;margin:0 auto;padding:var(--space-6) 0;text-align:center">
<div style="margin-bottom:var(--space-5)">
<img src="/icons/icon-180.png" alt="Ban Yaro"
style="width:72px;height:72px;border-radius:var(--radius-lg);margin-bottom:var(--space-3)">
<h1 style="font-size:var(--text-2xl);font-weight:700;margin:0">E-Mail bestätigen</h1>
</div>
<div style="background:var(--c-surface-2);border-radius:var(--radius-lg);
padding:var(--space-5);margin-bottom:var(--space-4);text-align:left">
<p style="margin:0 0 var(--space-2)">
Wir haben einen Bestätigungslink an<br>
<strong>${email}</strong><br>
gesendet.
</p>
<p style="margin:0;color:var(--c-text-secondary);font-size:var(--text-sm)">
Bitte prüfe dein Postfach und klicke auf den Link, um dein Konto zu aktivieren.
Danach kannst du dich hier anmelden.
</p>
</div>
<button id="verify-resend-btn2" class="btn btn-ghost w-full"
style="margin-bottom:var(--space-3)">
Link erneut senden
</button>
<button id="verify-back-btn" class="btn btn-ghost w-full"
style="color:var(--c-text-muted);font-size:var(--text-sm)">
Anderes Konto / Anmelden
</button>
</div>
`;
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 = `
<div style="max-width:380px;width:100%;margin:0 auto;padding:var(--space-6) 0;box-sizing:border-box">
<!-- Logo -->
<div style="text-align:center;margin-bottom:var(--space-6)">
<img src="/icons/icon-180.png" alt="Ban Yaro"
style="width:72px;height:72px;border-radius:var(--radius-lg);
display:block;margin:0 auto var(--space-3)">
<h1 style="font-size:var(--text-2xl);font-weight:700;margin:0;text-align:center">Ban Yaro</h1>
<p style="color:var(--c-text-secondary);margin:var(--space-1) 0 0;text-align:center">
Alles rund um deinen Hund
</p>
</div>
<!-- Tab-Toggle -->
<div style="display:flex;background:var(--c-surface-2);
border-radius:var(--radius-md);padding:3px;
margin-bottom:var(--space-5)">
<button id="tab-login"
class="btn flex-1 ${mode === 'login' ? 'btn-primary' : 'btn-ghost'}"
style="border-radius:calc(var(--radius-md) - 2px)">
Anmelden
</button>
<button id="tab-register"
class="btn flex-1 ${mode === 'register' ? 'btn-primary' : 'btn-ghost'}"
style="border-radius:calc(var(--radius-md) - 2px)">
Registrieren
</button>
</div>
${mode === 'login' ? _loginFormHTML() : _registerFormHTML()}
</div>
`;
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 `
<form id="auth-form" autocomplete="on" novalidate>
<div class="form-group">
<label class="form-label">E-Mail</label>
<input class="form-control" type="email" name="email"
placeholder="deine@email.de" autocomplete="email" required>
</div>
<div class="form-group">
<label class="form-label">Passwort</label>
<div style="position:relative">
<input class="form-control" type="password" name="password" id="login-pw"
placeholder="Passwort" autocomplete="current-password" required
style="padding-right:var(--space-10)">
<button type="button" id="login-pw-toggle"
class="btn btn-ghost btn-icon"
aria-label="Passwort anzeigen"
style="position:absolute;right:var(--space-1);top:50%;transform:translateY(-50%);
color:var(--c-text-muted);padding:var(--space-2)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#eye"></use></svg>
</button>
</div>
</div>
<button type="submit" class="btn btn-primary w-full" style="margin-top:var(--space-2)">
Anmelden
</button>
<p style="text-align:center;margin-top:var(--space-3);font-size:var(--text-xs)">
<button type="button" id="forgot-pw-link"
class="btn btn-ghost"
style="font-size:var(--text-xs);color:var(--c-text-muted);padding:0">
Passwort vergessen?
</button>
</p>
</form>
`;
}
function _registerFormHTML() {
return `
<form id="auth-form" autocomplete="on" novalidate>
<div class="form-group">
<label class="form-label">Benutzername</label>
<input class="form-control" type="text" name="name"
placeholder="z. B. bellas_mama" autocomplete="username" required
pattern="^\\S+$" title="Kein Leerzeichen erlaubt">
<p style="margin:var(--space-1) 0 0;font-size:var(--text-xs);color:var(--c-text-secondary)">
Dieser Name ist öffentlich sichtbar und wird bei deinen Beiträgen, Pins und Kommentaren angezeigt.
</p>
</div>
<div class="form-group">
<label class="form-label">E-Mail</label>
<input class="form-control" type="email" name="email"
placeholder="deine@email.de" autocomplete="email" required>
</div>
<div class="form-group">
<label class="form-label">Passwort</label>
<div style="position:relative">
<input class="form-control" type="password" name="password" id="register-pw"
placeholder="Mindestens 8 Zeichen" autocomplete="new-password"
minlength="8" required style="padding-right:var(--space-10)">
<button type="button" id="register-pw-toggle"
class="btn btn-ghost btn-icon"
aria-label="Passwort anzeigen"
style="position:absolute;right:var(--space-1);top:50%;transform:translateY(-50%);
color:var(--c-text-muted);padding:var(--space-2)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#eye"></use></svg>
</button>
</div>
<!-- Hundepassphrase-Generator -->
<div id="pw-gen-box" style="margin-top:var(--space-2);padding:var(--space-3);
background:var(--c-surface-2);border-radius:var(--radius-md);
border-left:3px solid var(--c-primary)">
<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-2)">
<span style="font-size:var(--text-xs);font-weight:700;color:var(--c-text-secondary);
text-transform:uppercase;letter-spacing:.05em">🐾 Passwort-Vorschlag</span>
<button type="button" id="pw-gen-new"
style="margin-left:auto;font-size:var(--text-xs);color:var(--c-primary);
background:none;border:none;cursor:pointer;padding:0;font-weight:600">
neu
</button>
</div>
<div style="display:flex;align-items:center;gap:var(--space-2)">
<code id="pw-gen-phrase"
style="flex:1;font-size:var(--text-sm);font-weight:700;
color:var(--c-text);letter-spacing:.02em;word-break:break-all"></code>
<button type="button" id="pw-gen-use"
style="flex-shrink:0;font-size:var(--text-xs);font-weight:600;
padding:4px 10px;border-radius:var(--radius-md);
background:var(--c-primary);color:#fff;border:none;cursor:pointer">
Übernehmen
</button>
</div>
<div style="font-size:10px;color:var(--c-text-muted);margin-top:var(--space-1)">
Sichere Passphrase aus der Hundewelt leicht zu merken, schwer zu knacken.
</div>
</div>
</div>
<div class="form-group" style="margin-top:var(--space-2)">
<label class="form-label" style="font-size:var(--text-xs)">
Einladungscode <span style="color:var(--c-text-muted);font-weight:400">(optional)</span>
</label>
<input class="form-control" type="text" name="partner_code" id="reg-partner-code"
placeholder="z. B. HUNDEBLOG" autocomplete="off"
style="text-transform:uppercase;font-family:monospace;letter-spacing:.08em">
<div id="reg-partner-hint" style="display:none;margin-top:var(--space-1);font-size:var(--text-xs);
padding:var(--space-2) var(--space-3);border-radius:var(--radius-sm)"></div>
</div>
<button type="submit" class="btn btn-primary w-full" style="margin-top:var(--space-2)">
Konto erstellen
</button>
<p style="text-align:center;font-size:var(--text-xs);
color:var(--c-text-secondary);margin-top:var(--space-3)">
Mit der Registrierung stimmst du unseren Datenschutzhinweisen zu.<br>
Deine Daten werden ausschließlich auf unserem Server gespeichert.
</p>
</form>
`;
}
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: `
<form id="${id}" style="display:flex;flex-direction:column;gap:var(--space-3)">
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0">
Gib deine E-Mail-Adresse ein. Du erhältst einen Link zum Zurücksetzen deines Passworts.
</p>
<div>
<label class="form-label">E-Mail</label>
<input class="form-control" id="forgot-pw-email" type="email"
placeholder="deine@email.de" autocomplete="email" required>
</div>
</form>`,
footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" form="${id}" type="submit">Link senden</button>`,
});
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);
});
}
// 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 sessionStorage gesetzt
const stored = sessionStorage.getItem('by_ref_code') || '';
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 = sessionStorage.getItem('by_ref_code') || '';
const finalCode = partnerCode || refCode || undefined;
const result = await API.auth.register(fd.email, fd.password, fd.name.trim(), finalCode);
if (refCode) sessionStorage.removeItem('by_ref_code');
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 = `
<svg class="ph-icon" aria-hidden="true" style="flex-shrink:0;color:var(--c-primary)">
<use href="/icons/phosphor.svg#bell-ringing"></use>
</svg>
<span style="flex:1;line-height:1.4">Push-Benachrichtigungen aktivieren?</span>
<button id="push-offer-yes" class="btn btn-primary" style="font-size:var(--text-xs);padding:var(--space-1) var(--space-3);flex-shrink:0">Ja</button>
<button id="push-offer-no" class="btn btn-ghost btn-icon" aria-label="Schließen" style="flex-shrink:0">
<svg class="ph-icon" style="width:16px;height:16px" aria-hidden="true"><use href="/icons/phosphor.svg#x"></use></svg>
</button>
`;
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 = `
<div style="max-width:380px;margin:0 auto;padding:var(--space-6) 0">
<div style="text-align:center;margin-bottom:var(--space-6)">
<img src="/icons/icon-180.png" alt="Ban Yaro"
style="width:72px;height:72px;border-radius:var(--radius-lg);margin-bottom:var(--space-3)">
<h1 style="font-size:var(--text-2xl);font-weight:700;margin:0">Neues Passwort</h1>
<p style="color:var(--c-text-secondary);margin:var(--space-1) 0 0">
Wähle ein sicheres Passwort für deinen Account.
</p>
</div>
<form id="reset-pw-form" style="display:flex;flex-direction:column;gap:var(--space-4)">
<div>
<label class="form-label">Neues Passwort</label>
<div style="position:relative">
<input class="form-control" type="password" id="reset-pw-input"
placeholder="Mindestens 8 Zeichen" autocomplete="new-password"
minlength="8" required style="padding-right:var(--space-10)">
<button type="button" id="reset-pw-toggle"
class="btn btn-ghost btn-icon"
style="position:absolute;right:var(--space-1);top:50%;transform:translateY(-50%);
color:var(--c-text-muted);padding:var(--space-2)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#eye"></use></svg>
</button>
</div>
<!-- Hundepassphrase-Generator -->
<div style="margin-top:var(--space-2);padding:var(--space-3);
background:var(--c-surface-2);border-radius:var(--radius-md);
border-left:3px solid var(--c-primary)">
<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-2)">
<span style="font-size:var(--text-xs);font-weight:700;color:var(--c-text-secondary);
text-transform:uppercase;letter-spacing:.05em">🐾 Passwort-Vorschlag</span>
<button type="button" id="reset-gen-new"
style="margin-left:auto;font-size:var(--text-xs);color:var(--c-primary);
background:none;border:none;cursor:pointer;padding:0;font-weight:600">
↺ neu
</button>
</div>
<div style="display:flex;align-items:center;gap:var(--space-2)">
<code id="reset-gen-phrase"
style="flex:1;font-size:var(--text-sm);font-weight:700;
color:var(--c-text);letter-spacing:.02em;word-break:break-all"></code>
<button type="button" id="reset-gen-use"
class="btn btn-sm btn-secondary" style="flex-shrink:0">
Übernehmen
</button>
</div>
</div>
</div>
<button type="submit" class="btn btn-primary w-full">
Passwort speichern
</button>
</form>
</div>
`;
_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
// ----------------------------------------------------------
function _esc(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ----------------------------------------------------------
// PUBLIC
// ----------------------------------------------------------
return { init, refresh };
})();