banyaro/backend/static/js/pages/settings.js
rene de1677154f Security + E-Mail-HTML + Quartalsbericht + Registrierungspflicht
Registrierung & Login:
- E-Mail-Verifikation jetzt Pflicht vor erstem Login
- Register gibt keinen Token mehr zurück → "Postfach prüfen"-Screen
- Login blockt mit EMAIL_NOT_VERIFIED (403) wenn unverifiziert
- Resend-Verification ohne Auth (email-basiert)
- Frontend: _renderVerifyPending() nach Register und Login-Fehler
- Account-Lockout: 5 Fehlversuche → 15 Min gesperrt (ratelimit.py)
- Login Rate-Limit zusätzlich per E-Mail-Adresse (5/5 Min)
- Fehler-Tracking wird bei erfolgreichem Login zurückgesetzt

E-Mail-Templates (alle Mails jetzt HTML):
- email_html() Shared-Template in mailer.py (Gradient-Header, Warm-Beige)
- Verifikations-Mail, Passwort-Reset → HTML mit CTA-Button
- Admin-Outreach: plain text auto-wrapped in HTML
- Züchter-Mails (Antrag/Genehmigung/Ablehnung) → Template
- Tierschutz-Alert (litters.py) → Template
- send_support_mail → HTML
- outreach._build_message() + _send_smtp() unterstützen jetzt html= Parameter

Forum-Schutz:
- Post-Cooldown: 30 Sek zwischen beliebigen Posts (DB-Check)
- Stunden-Limit: 5 Threads / 20 Antworten pro User/Stunde
- Duplikat-Erkennung: gleicher Text in 5 Min blockiert (in-memory)
- content_filter.py: Spam-Keywords, URL-Sperre für Accounts < 7 Tage,
  Sonderzeichen-Ratio-Check

Security-Headers:
- HSTS: max-age=31536000; includeSubDomains
- Content-Security-Policy: frame-ancestors none, base-uri self, …
- X-Frame-Options entfernt (CSP frame-ancestors ist moderner)

Honeypot-Fallen (13 Scanner-Pfade → 24h IP-Sperre):
- /api/admin/users, /api/v1/users, /api/.env, /api/config,
  /api/setup, /api/install, /api/phpinfo, /api/debug,
  /api/actuator, /api/swagger, /api/graphql u.a.

Quartalsbericht-System:
- backend/scripts/generate_reports.py: 6 Sections
  (Sicherheit, Funktionsumfang, Dateien, Nutzer, Partner, Server)
- make reports: generiert alle Berichte aus dem Container, committed
- Scheduler: quarterly_report Job (1. Feb/Mai/Aug/Nov 07:00)
  → vollständige HTML-Mail an ADMIN_EMAIL
- quarterly_report erscheint im täglichen Status-Report

Admin-Panel:
- "Forum & Meldungen" → "Forum"
2026-05-01 08:20:53 +02:00

1803 lines
84 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) {
_container = container;
_appState = appState;
_render();
// 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"
style="width:56px;height:56px;border-radius:50%;
background:var(--c-primary);color:#fff;
display:flex;align-items:center;justify-content:center;
font-size:1.5rem;font-weight:700;flex-shrink:0;
cursor:pointer;overflow:hidden;position:relative">
${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 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)">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>
<!-- Züchter-Profil Slot -->
<div id="breeder-card-slot"></div>
<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)">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-logout-btn"
style="padding:var(--space-4);border-radius:0;cursor:pointer;
color:var(--c-danger)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#sign-out"></use></svg>
<span>Abmelden</span>
</div>
</div>
</div>
<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)">
App-Einstellungen
</div>
<div class="card-body" style="padding:0">
<!-- Dark-Mode-Auswahl -->
<div style="display:flex;align-items:center;gap:var(--space-3);
padding:var(--space-4);border-bottom:1px solid var(--c-border)">
<svg class="ph-icon" aria-hidden="true" style="width:1.25rem;height:1.25rem"><use href="/icons/phosphor.svg#moon"></use></svg>
<div style="flex:1">
<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" ${(localStorage.getItem('by_theme')||'system') === 'system' ? 'selected' : ''}>System</option>
<option value="light" ${localStorage.getItem('by_theme') === 'light' ? 'selected' : ''}>Hell</option>
<option value="dark" ${localStorage.getItem('by_theme') === 'dark' ? 'selected' : ''}>Dunkel</option>
</select>
</div>
<!-- KI-Notiz-Assistent -->
<div style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-4)">
<svg class="ph-icon" aria-hidden="true" style="width:1.25rem;height:1.25rem"><use href="/icons/phosphor.svg#brain"></use></svg>
<div style="flex:1">
<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>
</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 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)">
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>`;
}
if (badgesEl && a.categories) {
// SVG-Schild für jede Kategorie
const shield = (color, dark, emoji, opacity = 1) => `
<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="M30 3 L57 15 L57 38 Q57 60 30 70 Q3 60 3 38 L3 15 Z"
fill="url(#g_${color.replace('#','')})" opacity="${opacity}"/>
<path d="M30 3 L57 15 L57 38 Q57 60 30 70 Q3 60 3 38 L3 15 Z"
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)
: shield('#9ca3af', '#6b7280', cat.emoji, 0.5);
// 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 style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
<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 {
// Aktuelle Version vom Server holen (no-cache)
const serverResp = await fetch('/js/app.js', { cache: 'no-store' });
const serverText = await serverResp.text();
const match = serverText.match(/APP_VERSION\s*=\s*'([^']+)'/);
const serverVersion = match?.[1] || null;
const localVersion = typeof APP_VERSION !== 'undefined' ? APP_VERSION : '0';
// SW update anstoßen
const reg = await navigator.serviceWorker.getRegistration();
await reg?.update();
if (serverVersion && serverVersion !== localVersion) {
// Neuere Version verfügbar — Seite neu laden
if (reg?.waiting) reg.waiting.postMessage({ type: 'SKIP_WAITING' });
UI.toast.info(`Update auf v${serverVersion} verfügbar — Seite wird neu geladen…`);
setTimeout(() => location.reload(), 1500);
} else if (reg?.waiting) {
reg.waiting.postMessage({ type: 'SKIP_WAITING' });
UI.toast.success('Update wird installiert…');
} else {
UI.toast.success(`Ban Yaro ist aktuell — v${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-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-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', e => {
const val = e.target.value;
localStorage.setItem('by_theme', val);
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.'
);
});
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';
}
});
_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 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)">
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 style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
<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;margin:0 auto;padding:var(--space-6) 0">
<!-- 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);
margin-bottom:var(--space-3)">
<h1 style="font-size:var(--text-2xl);font-weight:700;margin:0">Ban Yaro</h1>
<p style="color:var(--c-text-secondary);margin:var(--space-1) 0 0">
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: Welcome-Seite oder Profil anlegen
if (_appState.activeDog) {
App.navigate('welcome');
} else {
App.navigate('dog-profile');
}
});
});
}
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 };
})();