Partner verteilen gedruckte QR-Codes (Sticker/Flyer); jeder physische Code
ist einzeln rückverfolgbar von Scan bis Registrierung.
Backend:
- partner_qr_batches + partner_qr_codes (Token 8-stellig, ohne 0/O/1/l/I),
users.referred_qr, partner_codes.owner_user_id (+Backfill über referred_by)
- /q/{token}: Scan zählen (scans, first/last_scan_at) → Redirect
/?ref=CODE&qr=TOKEN — dockt am bestehenden Referral-Flow an
- Registrierung: qr_token wird nur zugeordnet, wenn er zum eingelösten
Partner-Code gehört (Manipulationsschutz)
- Admin: Kontingent bestellen (max 500), Liste mit Scans/Registrierungen,
Löschen (Zweiklick), druckfertiges A4-PDF (segno+fpdf2, 3×4 Grid mit
Kurz-URL + laufender Nummer), Code-Besitzer zuordnen
- Partner-Self-Service: /partner/my-qr (+PDF) für Code-Besitzer
Frontend:
- Admin-Partner-Tab: Karte 'QR-Kontingente' (Bestellung, Stats, PDF, Besitzer)
- Partner-Profil: 'Meine QR-Codes' mit Scans/Registrierungen + PDF-Download
- boot.js/app.js speichern ?qr=, Registrierung schickt qr_token mit
Neu: segno==1.6.6 (pure-python QR). Tests: 5 neue (PDF, Scan-Zählung,
Attribution, Fremd-Token-Schutz, Self-Service). Suite: 51 passed.
2780 lines
137 KiB
JavaScript
2780 lines
137 KiB
JavaScript
/* ============================================================
|
||
BAN YARO — Einstellungen / Account
|
||
Login, Registrierung, Logout, Account-Info.
|
||
============================================================ */
|
||
|
||
window.Page_settings = (() => {
|
||
|
||
let _container = null;
|
||
let _appState = null;
|
||
let _mode = 'login'; // 'login' | 'register'
|
||
|
||
// ----------------------------------------------------------
|
||
// HUNDEPASSPHRASE — sicheres Passwort aus Hundewelt
|
||
// ----------------------------------------------------------
|
||
const _PW_WOERTER = [
|
||
// Rassen
|
||
'Labrador','Pudel','Beagle','Husky','Dackel','Spitz','Mops','Boxer',
|
||
'Collie','Setter','Pointer','Retriever','Shepherd','Terrier','Welpe',
|
||
// Körper & Natur
|
||
'Pfote','Schwanz','Schnauze','Schnurrbart','Fell','Nase','Ohr',
|
||
// Aktivität
|
||
'Gassi','Laufen','Bellen','Springen','Graben','Schnüffeln','Spielen',
|
||
'Apportieren','Schwimmen','Hecheln','Wackeln','Toben',
|
||
// Gegenstände
|
||
'Leckerli','Leine','Halsband','Ball','Napf','Knochen','Frisbee',
|
||
'Körbchen','Bürste','Leine','Stöckchen','Kauspielzeug',
|
||
// Orte & Personen
|
||
'Wiese','Wald','Park','Bach','Pfütze','Tierarzt','Züchter',
|
||
// Eigenschaften
|
||
'Treu','Tapfer','Mutig','Flauschig','Verspielt','Neugierig',
|
||
'Wachsam','Flink','Sanft','Lieb',
|
||
// Geräusche & Aktionen
|
||
'Wuff','Jaulen','Schnuppern','Wedeln','Gähnen','Strecken',
|
||
// Futter
|
||
'Trockenfutter','Nassfutter','Kausnack','Futternapf',
|
||
];
|
||
|
||
function _genPassphrase() {
|
||
const pick = () => _PW_WOERTER[Math.floor(Math.random() * _PW_WOERTER.length)];
|
||
const num = Math.floor(Math.random() * 90) + 10; // 2-stellig
|
||
// 3 zufällige Wörter + Zahl, mit Bindestrich
|
||
const words = [];
|
||
while (words.length < 3) {
|
||
const w = pick();
|
||
if (!words.includes(w)) words.push(w);
|
||
}
|
||
return words.join('-') + '-' + num;
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// INIT / REFRESH
|
||
// ----------------------------------------------------------
|
||
async function init(container, appState, params = {}) {
|
||
_container = container;
|
||
_appState = appState;
|
||
_render();
|
||
if (params.tab === 'login') setTimeout(() => _renderAuth('login'), 50);
|
||
if (params.tab === 'register') setTimeout(() => _renderAuth('register'), 50);
|
||
// Frischen User-State laden damit Badges (is_founder, is_partner) aktuell sind
|
||
if (_appState.user) {
|
||
try {
|
||
const fresh = await API.auth.me();
|
||
Object.assign(_appState.user, fresh);
|
||
_render();
|
||
} catch {}
|
||
}
|
||
}
|
||
|
||
async function refresh() {
|
||
_render();
|
||
if (_appState?.user) {
|
||
try {
|
||
const fresh = await API.auth.me();
|
||
Object.assign(_appState.user, fresh);
|
||
_render();
|
||
} catch {}
|
||
}
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// ABO & TARIF
|
||
// ----------------------------------------------------------
|
||
function _tierCard(u) {
|
||
const tier = u.subscription_tier || 'standard';
|
||
const rolle = u.rolle || 'user';
|
||
const isAdmin = rolle === 'admin' || rolle === 'moderator';
|
||
const isPro = ['pro','pro_test'].includes(tier);
|
||
const isBreeder = ['breeder','breeder_test'].includes(tier) || rolle === 'breeder';
|
||
const isStandard = !isAdmin && !isPro && !isBreeder;
|
||
|
||
const _badge = (label, color) =>
|
||
`<span style="display:inline-block;padding:2px 10px;border-radius:20px;
|
||
font-size:var(--text-xs);font-weight:700;letter-spacing:.03em;
|
||
background:${color};color:#fff">${label}</span>`;
|
||
|
||
const _upgradeBtn = (id, label, price, color) =>
|
||
`<button id="${id}"
|
||
style="flex:1;min-width:130px;padding:var(--space-3) var(--space-2);
|
||
border-radius:var(--radius-md);border:none;cursor:pointer;
|
||
background:${color};color:#fff;
|
||
font-size:var(--text-sm);font-weight:600;
|
||
display:flex;flex-direction:column;align-items:center;gap:2px">
|
||
<span>${label}</span>
|
||
<span style="font-size:var(--text-xs);font-weight:400;opacity:.9">${price}</span>
|
||
</button>`;
|
||
|
||
const expires = u.subscription_expires_at;
|
||
const cancelled = u.subscription_cancelled_at;
|
||
const expiresDate = expires ? new Date(expires).toLocaleDateString('de-DE', {day:'numeric',month:'long',year:'numeric'}) : null;
|
||
const isPaid = (isPro || isBreeder) && !tier.endsWith('_test') && !isAdmin;
|
||
|
||
const _expiryInfo = () => {
|
||
if (!isPaid) return '';
|
||
if (cancelled && !expiresDate) {
|
||
return `<div style="font-size:var(--text-xs);color:#e65100;margin-top:var(--space-1)">
|
||
Gekündigt — läuft bis Ablauf des bezahlten Zeitraums
|
||
</div>`;
|
||
}
|
||
if (!expiresDate) return '';
|
||
const color = cancelled ? '#e65100' : 'var(--c-text-secondary)';
|
||
const text = cancelled
|
||
? `Gekündigt — läuft bis ${expiresDate}`
|
||
: `Aktiv bis ${expiresDate}`;
|
||
return `<div style="font-size:var(--text-xs);color:${color};margin-top:var(--space-1)">${text}</div>`;
|
||
};
|
||
|
||
const _cancelBtn = () => {
|
||
if (!isPaid || cancelled) return '';
|
||
return `<button id="settings-cancel-sub-btn"
|
||
style="margin-top:var(--space-3);padding:var(--space-2) var(--space-3);
|
||
border-radius:var(--radius-md);border:1px solid var(--c-border);
|
||
background:transparent;color:var(--c-text-secondary);
|
||
font-size:var(--text-xs);cursor:pointer">
|
||
Abo kündigen
|
||
</button>`;
|
||
};
|
||
|
||
let statusHtml = '';
|
||
let actionsHtml = '';
|
||
|
||
if (isAdmin) {
|
||
statusHtml = _badge('Admin', '#6366f1');
|
||
} else if (isBreeder) {
|
||
statusHtml = _badge(cancelled ? 'Züchter (gekündigt)' : 'Züchter aktiv', '#C4843A');
|
||
} else if (isPro) {
|
||
statusHtml = _badge(cancelled ? 'Pro (gekündigt)' : 'Pro aktiv', '#16a34a');
|
||
actionsHtml = `
|
||
<div style="margin-top:var(--space-3);display:flex;gap:var(--space-2);flex-wrap:wrap">
|
||
${!cancelled ? _upgradeBtn('settings-upgrade-breeder-btn','Züchter werden','49 €/Jahr','#C4843A') : ''}
|
||
</div>`;
|
||
} else {
|
||
statusHtml = _badge('Kostenlos', '#888');
|
||
actionsHtml = `
|
||
<div style="margin-top:var(--space-3);display:flex;gap:var(--space-2);flex-wrap:wrap">
|
||
${_upgradeBtn('settings-upgrade-pro-btn','Ban Yaro Pro','29 €/Jahr','#16a34a')}
|
||
${_upgradeBtn('settings-upgrade-breeder-btn','Züchter','49 €/Jahr','#C4843A')}
|
||
</div>`;
|
||
}
|
||
|
||
return `
|
||
<div class="card mb-4">
|
||
<div class="by-card-section-header">Abo & Tarif</div>
|
||
<div class="p-4">
|
||
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap">
|
||
<span class="text-sm-secondary">Aktueller Tarif:</span>
|
||
${statusHtml}
|
||
</div>
|
||
${_expiryInfo()}
|
||
${actionsHtml}
|
||
${_cancelBtn()}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function _showUpgradeModal(tier) {
|
||
const isPro = tier === 'pro';
|
||
const label = isPro ? 'Ban Yaro Pro' : 'Züchter';
|
||
const price = isPro ? '29 €/Jahr' : '49 €/Jahr';
|
||
const color = isPro ? '#16a34a' : '#C4843A';
|
||
const _group = (title, items) => `
|
||
<div class="mb-3">
|
||
<div style="font-size:10px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;
|
||
color:var(--c-text-muted);margin-bottom:var(--space-2)">${title}</div>
|
||
${items.map(f => `
|
||
<div style="display:flex;align-items:flex-start;gap:var(--space-2);
|
||
padding:3px 0;font-size:var(--text-sm)">
|
||
<span style="color:${color};font-weight:700;flex-shrink:0;margin-top:1px">✓</span>
|
||
<span>${f}</span>
|
||
</div>`).join('')}
|
||
</div>`;
|
||
|
||
const featureList = isPro
|
||
? _group('Deine Hunde', [
|
||
'Bis zu 10 Hunde gleichzeitig verwalten',
|
||
'Getrennte Trainingsfortschritte, Gesundheits- und Ernährungsdaten je Hund',
|
||
])
|
||
+ _group('Community & Alltag', [
|
||
'Gassi-Treffen: Fotos und Rasse der Teilnehmer sichtbar, Fotos nach dem Treffen hochladen',
|
||
'Direktnachrichten & Chat mit anderen Hundebesitzern',
|
||
'Playdate: Spielkameraden in der Nähe finden und verabreden',
|
||
])
|
||
+ _group('Tools & Wissen', [
|
||
'Ernährung: Kalorienbedarf-Rechner, BARF-Guide, Giftliste, KI-Ernährungsberater',
|
||
'Reise-Checkliste & EU-Länder-Einreiseregeln',
|
||
'Notizblock mit KI-Muster-Analyse',
|
||
'Erweiterte Karten-Layer (Wandern, Radfahren, Satellit)',
|
||
'Alle künftigen Pro-Features inklusive',
|
||
])
|
||
: `<div style="padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
|
||
background:rgba(22,163,74,0.08);border:1px solid rgba(22,163,74,0.2);
|
||
font-size:var(--text-xs);color:var(--c-text-secondary);margin-bottom:var(--space-3)">
|
||
<strong style="color:#16a34a">✓ Alle Pro-Features inklusive</strong> —
|
||
mehrere Hunde, Ernährung, Gassi-Community, Chat, Playdate, Reise, Karten-Layer
|
||
</div>`
|
||
+ _group('Zucht-Management', [
|
||
'Zuchtkartei: Stammdaten, Gesundheitstests (HD, ED, OCD, Augen, Herz, Patella, ZTP), Gentests (MDR1, PRA, DM, vWD)',
|
||
'Wurfverwaltung: Welpen, Gewichtsverlauf, Fotos, automatisch ausgefüllter Kaufvertrag',
|
||
'Warteliste: Interessenten mit Präferenzen (Geschlecht, Farbe, Verwendungszweck) pro Zuchthündin',
|
||
'Läufigkeit & Trächtigkeit: Zykluskalender, Progesterontests, Deckdaten, Meilensteinberechnung',
|
||
'Wurf-Buchstabe und Wurf-Name (z. B. A-Wurf, „Vatertags-Wurf")',
|
||
])
|
||
+ _group('KI & Analyse', [
|
||
'KI-Züchter-Assistent: Wurfankündigungen schreiben, Genetik-Erklärung für Käufer, Paarungsanalyse',
|
||
'Stammbaum bis 4 Generationen mit klickbaren Knoten',
|
||
'Inzucht-Koeffizient (Wright\'s Formel, Ampel-Bewertung, Probeverpaarung)',
|
||
'Tierschutz-Check automatisch bei jeder Verpaarung',
|
||
'KI-Jahresbericht mit Trends und Empfehlungen',
|
||
])
|
||
+ _group('Sichtbarkeit & Export', [
|
||
'Öffentliches Züchter-Profil unter banyaro.app/breeder/{zwingername}',
|
||
'Wurfbörse: Würfe öffentlich ankündigen, Käufer schreiben direkt an',
|
||
'Datenexport als HTML-Dossier und ODS-Tabelle (LibreOffice / Excel)',
|
||
'Privater Züchter-Bereich mit Zwingername und Logo',
|
||
]);
|
||
|
||
const inputStyle = `width:100%;box-sizing:border-box;padding:var(--space-2) var(--space-3);
|
||
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
|
||
font-size:var(--text-sm);font-family:inherit;background:var(--c-surface);color:var(--c-text)`;
|
||
|
||
const breederForm = isPro ? '' : `
|
||
<div style="margin-top:var(--space-4);padding-top:var(--space-4);border-top:1px solid var(--c-border)">
|
||
<div style="font-size:var(--text-xs);font-weight:700;color:var(--c-text-muted);
|
||
text-transform:uppercase;letter-spacing:.05em;margin-bottom:var(--space-3)">
|
||
Dein Zwinger
|
||
</div>
|
||
<form id="breeder-upgrade-form" class="flex-col-gap-3">
|
||
<div>
|
||
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:4px">
|
||
Zwingername <span class="text-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:4px">
|
||
Rasse <span class="text-danger">*</span>
|
||
</label>
|
||
<input name="rasse_text" type="text" maxlength="100" required
|
||
placeholder="z. B. Labrador Retriever" style="${inputStyle}">
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-2)">
|
||
<div>
|
||
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:4px">Zuchtverein</label>
|
||
<input name="verein" type="text" maxlength="100"
|
||
placeholder="z. B. VDH, BCD" style="${inputStyle}">
|
||
</div>
|
||
<div>
|
||
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:4px">Stadt</label>
|
||
<input name="stadt" type="text" maxlength="80"
|
||
placeholder="z. B. München" style="${inputStyle}">
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;align-items:center;gap:var(--space-2)">
|
||
<input name="vdh_mitglied" type="checkbox" id="upg-breeder-vdh"
|
||
style="width:16px;height:16px;cursor:pointer;flex-shrink:0">
|
||
<label for="upg-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:4px">
|
||
Dokument hochladen <span style="font-weight:400;color:var(--c-text-muted)">(optional)</span>
|
||
</label>
|
||
<input name="dokument" type="file" 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:4px">
|
||
Zuchtbuch, Vereinsausweis o.ä. — kann auch per E-Mail nachgereicht werden
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</div>`;
|
||
|
||
UI.modal.open({
|
||
title: `${label} freischalten`,
|
||
body: `
|
||
<div style="padding:var(--space-2) 0">
|
||
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-4)">
|
||
<div style="font-size:2rem;font-weight:800;color:${color}">${price}</div>
|
||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);line-height:1.5">
|
||
Einmaliger Jahresbeitrag<br>Kündigung jederzeit möglich
|
||
</div>
|
||
</div>
|
||
<div style="margin:0 0 var(--space-3)">
|
||
${featureList}
|
||
</div>
|
||
<div style="padding:var(--space-3);border-radius:var(--radius-md);
|
||
background:var(--c-surface-raised,rgba(0,0,0,.04));
|
||
font-size:var(--text-xs);color:var(--c-text-secondary);line-height:1.6">
|
||
Wir schalten deinen Account manuell frei — innerhalb von 24 Stunden.
|
||
Wir melden uns mit den Zahlungsdetails per E-Mail.
|
||
</div>
|
||
${!_appState.user?.billing_address ? `
|
||
<div style="padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
|
||
background:#fff8f0;border:1px solid #f0a060;
|
||
font-size:var(--text-xs);color:#c05000;line-height:1.6;margin-top:var(--space-2)">
|
||
💡 Tipp: Trag deine <strong>Rechnungsadresse</strong> im Profil ein — dann können wir die Rechnung vollständig ausstellen.
|
||
</div>` : ''}
|
||
<div style="margin-top:var(--space-3);padding:var(--space-3);border-radius:var(--radius-md);
|
||
background:var(--c-surface-raised,rgba(0,0,0,.04));">
|
||
<label style="display:flex;align-items:flex-start;gap:var(--space-2);cursor:pointer;
|
||
font-size:var(--text-xs);color:var(--c-text-secondary);line-height:1.5">
|
||
<input type="checkbox" id="agb-checkbox"
|
||
style="margin-top:2px;flex-shrink:0;accent-color:${color}">
|
||
<span>
|
||
Ich habe die <span style="color:var(--c-primary);cursor:pointer"
|
||
data-page="agb">AGB</span> gelesen und stimme ihnen zu.
|
||
</span>
|
||
</label>
|
||
</div>
|
||
<div style="margin-top:var(--space-3);padding:var(--space-3);border-radius:var(--radius-md);
|
||
background:var(--c-surface-raised,rgba(0,0,0,.04));">
|
||
<label style="display:flex;align-items:flex-start;gap:var(--space-2);cursor:pointer;
|
||
font-size:var(--text-xs);color:var(--c-text-secondary);line-height:1.5">
|
||
<input type="checkbox" id="widerruf-checkbox"
|
||
style="margin-top:2px;flex-shrink:0;accent-color:${color}">
|
||
<span>
|
||
Ich stimme zu, dass mein Zugang sofort nach Freischaltung beginnt, und bestätige,
|
||
dass ich damit mein 14-tägiges Widerrufsrecht verliere (§ 356 Abs. 4 BGB).
|
||
</span>
|
||
</label>
|
||
</div>
|
||
${breederForm}
|
||
</div>`,
|
||
footer: `
|
||
<button data-modal-close
|
||
style="padding:var(--space-2) var(--space-4);border-radius:var(--radius-md);
|
||
border:1.5px solid var(--c-border);background:transparent;
|
||
color:var(--c-text);font-size:var(--text-sm);cursor:pointer">
|
||
Abbrechen
|
||
</button>
|
||
<button id="upgrade-request-send-btn"
|
||
style="padding:var(--space-2) var(--space-4);border-radius:var(--radius-md);
|
||
border:none;cursor:pointer;background:${color};color:#fff;
|
||
font-size:var(--text-sm);font-weight:600">
|
||
Anfrage senden
|
||
</button>`
|
||
});
|
||
|
||
const agbBox = document.getElementById('agb-checkbox');
|
||
const widerrufBox = document.getElementById('widerruf-checkbox');
|
||
const sendBtn = document.getElementById('upgrade-request-send-btn');
|
||
if (sendBtn) sendBtn.disabled = true;
|
||
|
||
const _checkBtns = () => {
|
||
if (sendBtn) sendBtn.disabled = !(agbBox?.checked && widerrufBox?.checked);
|
||
};
|
||
agbBox?.addEventListener('change', _checkBtns);
|
||
widerrufBox?.addEventListener('change', _checkBtns);
|
||
|
||
document.getElementById('upgrade-request-send-btn')?.addEventListener('click', async () => {
|
||
const btn = document.getElementById('upgrade-request-send-btn');
|
||
if (!btn) return;
|
||
|
||
// Züchter: Formular validieren + als FormData senden
|
||
if (!isPro) {
|
||
const form = document.getElementById('breeder-upgrade-form');
|
||
if (form && !form.reportValidity()) return;
|
||
if (form) {
|
||
const fd = new FormData(form);
|
||
fd.set('vdh_mitglied', form.querySelector('[name="vdh_mitglied"]').checked ? '1' : '0');
|
||
// Pflichtfelder aus Form übernehmen falls leer → leere Strings senden
|
||
if (!fd.get('verein')) fd.set('verein', '');
|
||
if (!fd.get('stadt')) fd.set('stadt', '');
|
||
btn.disabled = true;
|
||
btn.textContent = 'Wird gesendet…';
|
||
try {
|
||
await API.breeder.apply(fd);
|
||
} catch (e) {
|
||
if (!e.message?.includes('bereits')) {
|
||
btn.disabled = false;
|
||
btn.textContent = 'Anfrage senden';
|
||
UI.toast.error(e.message || 'Fehler beim Einreichen.');
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
btn.disabled = true;
|
||
btn.textContent = 'Wird gesendet…';
|
||
}
|
||
|
||
try {
|
||
const widerrufAt = new Date().toLocaleString('de-DE');
|
||
const res = await API.auth.upgradeRequest(tier, `[Widerrufsrecht akzeptiert am ${widerrufAt}]`);
|
||
UI.modal.close();
|
||
if (res.already) {
|
||
UI.toast.info('Deine Anfrage liegt bereits vor — wir melden uns bald.');
|
||
} else {
|
||
UI.toast.success('Anfrage gesendet! Wir melden uns per E-Mail.');
|
||
}
|
||
if (!isPro) _loadBreederCard();
|
||
} catch (e) {
|
||
btn.disabled = false;
|
||
btn.textContent = 'Anfrage senden';
|
||
UI.toast.error(e.message || 'Fehler beim Senden.');
|
||
}
|
||
});
|
||
}
|
||
|
||
function _showCancelModal() {
|
||
const u = _appState.user;
|
||
const tier = u?.subscription_tier || 'standard';
|
||
const label = { pro: 'Ban Yaro Pro', breeder: 'Züchter' }[tier] || tier;
|
||
const expires = u?.subscription_expires_at;
|
||
const expiresDate = expires
|
||
? new Date(expires).toLocaleDateString('de-DE', {day:'numeric',month:'long',year:'numeric'})
|
||
: null;
|
||
|
||
UI.modal.open({
|
||
title: `${label} kündigen`,
|
||
body: `
|
||
<div style="padding:var(--space-2) 0">
|
||
${expiresDate ? `
|
||
<div style="padding:var(--space-3);border-radius:var(--radius-md);
|
||
background:rgba(234,88,12,.08);border:1px solid rgba(234,88,12,.2);
|
||
margin-bottom:var(--space-4);font-size:var(--text-sm)">
|
||
Dein Abo läuft noch bis <strong>${expiresDate}</strong> — du hast bis dahin vollen Zugriff.
|
||
</div>` : ''}
|
||
<div style="font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.6;
|
||
display:flex;flex-direction:column;gap:var(--space-2)">
|
||
<div>✓ Alle deine Daten (Tagebuch, Gesundheit, Notizen) bleiben vollständig erhalten</div>
|
||
<div>✓ Deine Hunde-Profile bleiben gespeichert</div>
|
||
<div>✓ Du kannst jederzeit wieder upgraden</div>
|
||
${_appState.dogs?.length > 1
|
||
? `<div style="color:var(--c-warning,#f59e0b)">⚠ Du hast mehrere Hunde — nach dem Ablauf wählst du einen als Haupthund</div>`
|
||
: ''}
|
||
</div>
|
||
</div>`,
|
||
footer: `
|
||
<button data-modal-close
|
||
style="padding:var(--space-2) var(--space-4);border-radius:var(--radius-md);
|
||
border:1.5px solid var(--c-border);background:transparent;
|
||
color:var(--c-text);font-size:var(--text-sm);cursor:pointer">
|
||
Abbrechen
|
||
</button>
|
||
<button id="cancel-sub-confirm-btn"
|
||
style="padding:var(--space-2) var(--space-4);border-radius:var(--radius-md);
|
||
border:none;cursor:pointer;background:var(--c-danger);color:#fff;
|
||
font-size:var(--text-sm);font-weight:600">
|
||
Jetzt kündigen
|
||
</button>`
|
||
});
|
||
|
||
document.getElementById('cancel-sub-confirm-btn')?.addEventListener('click', async () => {
|
||
const btn = document.getElementById('cancel-sub-confirm-btn');
|
||
if (!btn) return;
|
||
btn.disabled = true;
|
||
btn.textContent = '…';
|
||
try {
|
||
await API.auth.cancelSubscription();
|
||
// User-State aktualisieren
|
||
const fresh = await API.auth.me();
|
||
Object.assign(_appState.user, fresh);
|
||
UI.modal.close();
|
||
const tier = fresh.subscription_tier || '';
|
||
const label = { pro: 'Pro', breeder: 'Züchter' }[tier] || tier;
|
||
const exp = fresh.subscription_expires_at;
|
||
const expFmt = exp
|
||
? new Date(exp).toLocaleDateString('de-DE', {day:'numeric',month:'long',year:'numeric'})
|
||
: null;
|
||
UI.toast.success(
|
||
expFmt
|
||
? `Kündigung bestätigt — ${label} läuft noch bis ${expFmt}.`
|
||
: 'Kündigung bestätigt. Eine Bestätigungsmail wurde gesendet.'
|
||
);
|
||
_render();
|
||
} catch (e) {
|
||
btn.disabled = false;
|
||
btn.textContent = 'Jetzt kündigen';
|
||
UI.toast.error(e.message || 'Fehler beim Kündigen.');
|
||
}
|
||
});
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// RENDER
|
||
// ----------------------------------------------------------
|
||
function _render() {
|
||
if (_appState.user) {
|
||
_renderAccount();
|
||
} else {
|
||
_renderAuth(_mode);
|
||
}
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// EINGELOGGT — Account-Übersicht
|
||
// ----------------------------------------------------------
|
||
async function _renderAccount() {
|
||
const u = _appState.user;
|
||
|
||
// Avatar: Bild oder Buchstabe
|
||
const avatarInner = u.avatar_url
|
||
? `<img src="${UI.escape(u.avatar_url)}" alt="Avatar"
|
||
style="width:56px;height:56px;border-radius:50%;object-fit:cover;display:block">`
|
||
: UI.escape(u.name.charAt(0).toUpperCase());
|
||
|
||
// Mitglied seit
|
||
const memberSince = (() => {
|
||
if (!u.created_at) return '';
|
||
const d = new Date(u.created_at);
|
||
return d.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
|
||
})();
|
||
|
||
// Erfahrungs-Labels
|
||
const erfahrungLabel = {
|
||
einsteiger: 'Einsteiger (erster Hund)',
|
||
erfahren: 'Erfahrener Hundehalter',
|
||
trainer: 'Trainer / Ausbilder',
|
||
zuechter: 'Züchter',
|
||
};
|
||
|
||
_container.innerHTML = `
|
||
<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/*"
|
||
class="hidden">
|
||
<div>
|
||
<div style="font-weight:700;font-size:var(--text-lg)">${UI.escape(u.name)}</div>
|
||
<div style="display:flex;align-items:center;gap:var(--space-2);color:var(--c-text-secondary);font-size:var(--text-sm)">
|
||
${UI.escape(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)">
|
||
${(() => {
|
||
// Tier-Source-of-Truth ist subscription_tier (was auch
|
||
// _tierCard und has_pro_access nutzen). Das alte is_premium-
|
||
// Flag wird bei Admin-Upgrade nicht immer mitgezogen — daher
|
||
// hier den Tier nehmen.
|
||
const _tier = u.subscription_tier || 'standard';
|
||
const _rolle = u.rolle || 'user';
|
||
const _isAdminLike = _rolle === 'admin' || _rolle === 'moderator';
|
||
const _isBreeder = _tier === 'breeder' || _tier === 'breeder_test' || _rolle === 'breeder';
|
||
const _isPro = _tier === 'pro' || _tier === 'pro_test';
|
||
if (_isAdminLike) {
|
||
return `<span class="badge" style="background:#6366f1;color:#fff">
|
||
<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#shield-star"></use></svg> Admin
|
||
</span>`;
|
||
}
|
||
if (_isBreeder) {
|
||
return `<span class="badge" style="background:#C4843A;color:#fff">
|
||
<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#dog"></use></svg> Züchter
|
||
</span>`;
|
||
}
|
||
if (_isPro) {
|
||
return `<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 Pro
|
||
</span>`;
|
||
}
|
||
return `<span class="badge 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 mb-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 class="text-sm-secondary">
|
||
Mitglied seit ${UI.escape(memberSince)}
|
||
</div>`
|
||
: ''}
|
||
${u.bio
|
||
? `<div class="text-sm">${UI.escape(u.bio)}</div>`
|
||
: ''}
|
||
${u.wohnort
|
||
? `<div class="text-sm-secondary">
|
||
📍 ${UI.escape(u.wohnort)}
|
||
</div>`
|
||
: ''}
|
||
${u.erfahrung && erfahrungLabel[u.erfahrung]
|
||
? `<div class="text-sm-secondary">
|
||
${UI.escape(erfahrungLabel[u.erfahrung])}
|
||
</div>`
|
||
: ''}
|
||
${u.social_link
|
||
? `<div class="text-sm">
|
||
<a href="${UI.escape(u.social_link)}" target="_blank" rel="noopener"
|
||
class="text-primary">${UI.escape(u.social_link)}</a>
|
||
</div>`
|
||
: ''}
|
||
${!u.bio && !u.wohnort && !u.erfahrung && !u.social_link
|
||
? `<div class="text-sm-secondary">
|
||
Noch kein Profil ausgefüllt.
|
||
</div>`
|
||
: ''}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card mb-4" id="settings-stats-card">
|
||
<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 class="text-sm-muted">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>
|
||
|
||
${u.is_partner ? `
|
||
<!-- Partner-Bereich -->
|
||
<div class="card mb-4">
|
||
<div class="by-card-section-header">${UI.icon('handshake')} Partner</div>
|
||
<div class="p-4">
|
||
<p class="text-sm-secondary" style="margin:0 0 var(--space-3)">
|
||
Als Partner hast du vollen Pro-Zugang und eine öffentliche Karte auf der
|
||
Partner-Seite. Richte dein Profil ein — nach der Freigabe ist es für alle sichtbar.
|
||
</p>
|
||
<button class="btn btn-secondary btn-sm" id="settings-partner-profile-btn">
|
||
${UI.icon('pencil-simple')} Mein Partner-Profil
|
||
</button>
|
||
</div>
|
||
</div>` : ''}
|
||
|
||
<div class="card mb-4">
|
||
<div class="by-card-section-header">Trophäen</div>
|
||
<div id="settings-badges-body" class="p-4">
|
||
<div class="text-sm-muted">Lädt…</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card mb-4">
|
||
<div class="by-card-section-header">OpenStreetMap – die Karte mitverbessern</div>
|
||
<div id="settings-osm-body" class="p-4">
|
||
<div class="text-sm-muted">Lädt…</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card mb-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 id="settings-erinnerungen-wrap"></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 & 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>
|
||
<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-export-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-secondary);
|
||
font-size:var(--text-xs);cursor:pointer">
|
||
<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#download-simple"></use></svg>
|
||
Meine Daten exportieren (DSGVO Art. 20)
|
||
</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>
|
||
|
||
${_tierCard(u)}
|
||
|
||
<div class="card mb-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 (nur Pro) -->
|
||
${(() => {
|
||
const tier = u.subscription_tier || 'standard';
|
||
const hasPro = ['pro','breeder'].includes(tier) ||
|
||
u.rolle === 'admin' || u.rolle === 'moderator' ||
|
||
u.is_moderator || u.is_social_media ||
|
||
tier.endsWith('_test');
|
||
if (!hasPro) return '';
|
||
return `
|
||
<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>`;
|
||
})()}
|
||
|
||
<!-- DWD-Regenvorhersage (Deutschland) — speist die Karten-Radar-Timeline -->
|
||
<div class="settings-toggle-row">
|
||
<svg class="ph-icon" aria-hidden="true" style="width:1.25rem;height:1.25rem"><use href="/icons/phosphor.svg#cloud-rain"></use></svg>
|
||
<div class="settings-toggle-label">
|
||
<div style="font-weight:500">DWD-Regenvorhersage</div>
|
||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
|
||
2-Stunden-Vorhersage vom Deutschen Wetterdienst im Regenradar (nur Deutschland)
|
||
</div>
|
||
</div>
|
||
<label style="position:relative;display:inline-block;width:44px;height:24px;flex-shrink:0">
|
||
<input type="checkbox" id="toggle-dwd-radar"
|
||
style="opacity:0;width:0;height:0;position:absolute"
|
||
${localStorage.getItem('by_dwd_radar') !== '0' ? 'checked' : ''}>
|
||
<span style="position:absolute;cursor:pointer;inset:0;border-radius:12px;
|
||
background:${localStorage.getItem('by_dwd_radar') !== '0' ? 'var(--c-primary)' : 'var(--c-border)'};transition:.2s"
|
||
id="toggle-dwd-radar-track"></span>
|
||
<span id="toggle-dwd-radar-thumb"
|
||
style="position:absolute;top:2px;left:${localStorage.getItem('by_dwd_radar') !== '0' ? '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 class="text-xs-secondary">
|
||
10 Freunde → 20% · 20 Freunde → 30% · 50 Freunde → 50% — lebenslang, sobald Bezahlfunktionen aktiv sind.
|
||
</div>
|
||
</div>
|
||
<div id="referral-body" class="p-4">Lade…</div>
|
||
</div>
|
||
|
||
<!-- App installieren -->
|
||
<div class="card mb-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>
|
||
`;
|
||
|
||
// Verstorbene Hunde in Erinnerungen-Sektion laden
|
||
API.get('/dogs/verstorben').then(dogs => {
|
||
const el = document.getElementById('settings-erinnerungen-wrap');
|
||
if (!el || !dogs.length) return;
|
||
el.innerHTML = dogs.map(d => {
|
||
const av = d.foto_url
|
||
? `<img src="${UI.escape(d.foto_url)}" style="width:36px;height:36px;border-radius:50%;object-fit:cover;flex-shrink:0">`
|
||
: `<div style="width:36px;height:36px;border-radius:50%;background:var(--c-surface-2);display:flex;align-items:center;justify-content:center;flex-shrink:0">
|
||
<svg class="ph-icon" style="width:18px;height:18px;color:var(--c-text-muted)" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>
|
||
</div>`;
|
||
const jahr = d.verstorben_am ? d.verstorben_am.slice(0, 4) : '';
|
||
return `
|
||
<div class="sidebar-item settings-erinnerung-btn" data-dog-id="${d.id}" data-dog-name="${UI.escape(d.name)}"
|
||
style="padding:var(--space-3) var(--space-4);border-radius:0;border-bottom:1px solid var(--c-border);cursor:pointer">
|
||
${av}
|
||
<div style="display:flex;flex-direction:column;gap:1px;flex:1;min-width:0">
|
||
<span style="font-weight:600;font-size:var(--text-sm)">${UI.escape(d.name)}</span>
|
||
<span class="text-xs-muted">
|
||
<svg class="ph-icon" style="width:11px;height:11px" aria-hidden="true"><use href="/icons/phosphor.svg#heart-break"></use></svg>
|
||
Erinnerungen${jahr ? ' · ' + jahr : ''}
|
||
</span>
|
||
</div>
|
||
<span style="margin-left:auto;color:var(--c-text-secondary)">›</span>
|
||
</div>`;
|
||
}).join('');
|
||
el.querySelectorAll('.settings-erinnerung-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => _openGedenkseite(
|
||
parseInt(btn.dataset.dogId), btn.dataset.dogName
|
||
));
|
||
});
|
||
}).catch(() => {});
|
||
|
||
// OSM-Account-Verknüpfung (Modell A) — Status laden + Buttons verdrahten
|
||
(function _osmLink() {
|
||
const el = document.getElementById('settings-osm-body');
|
||
if (!el) return;
|
||
API.get('/osm-auth/status').then(st => {
|
||
if (st.linked) {
|
||
el.innerHTML = `
|
||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||
<svg class="ph-icon" style="color:var(--c-success)" aria-hidden="true"><use href="/icons/phosphor.svg#check-circle"></use></svg>
|
||
<span style="font-size:var(--text-sm)">Verknüpft als <strong>${UI.escape(st.osm_name)}</strong></span>
|
||
</div>
|
||
<div id="settings-osm-count" class="text-sm-muted" style="margin-top:var(--space-3)">…</div>
|
||
<button id="settings-osm-unlink"
|
||
style="margin-top:var(--space-3);background:none;border:none;
|
||
color:var(--c-text-muted);font-size:var(--text-xs);cursor:pointer">
|
||
Verknüpfung trennen
|
||
</button>`;
|
||
el.querySelector('#settings-osm-unlink').addEventListener('click', async () => {
|
||
try { await API.post('/osm-auth/unlink', {}); } catch (e) {}
|
||
_osmLink();
|
||
});
|
||
// Gamification-Zähler
|
||
API.get('/osm-contrib/status').then(cs => {
|
||
const c = document.getElementById('settings-osm-count');
|
||
if (!c) return;
|
||
const n = cs.verified_count || 0;
|
||
const next = n >= cs.pro_at ? 0 : (n < 10 ? 10 - n : cs.pro_at - n);
|
||
c.innerHTML = `🐾 <strong>${n}</strong> hundefreundliche Orte eingetragen`
|
||
+ (next ? ` · noch ${next} bis ${n < 10 ? 'zum Kartograf-Badge' : '1 Jahr Pro'}` : ' · Ziel erreicht! 🎉');
|
||
}).catch(() => { const c = document.getElementById('settings-osm-count'); if (c) c.textContent=''; });
|
||
} else {
|
||
el.innerHTML = `
|
||
<p class="text-sm-muted" style="margin:0 0 var(--space-3);line-height:1.45">
|
||
Du kennst die hundefreundlichen Orte besser als jede Karte. Verknüpfe deinen
|
||
kostenlosen OpenStreetMap-Account und trag mit einem Tap ein, wo dein Hund
|
||
willkommen war – das hilft jedem Hundehalter nach dir. Kostenlos, gemeinnützig,
|
||
keine Werbung.
|
||
</p>
|
||
<button id="settings-osm-link"
|
||
style="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:none;background:var(--c-primary);color:#fff;
|
||
font-size:var(--text-sm);font-weight:600;cursor:pointer">
|
||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-trifold"></use></svg>
|
||
OSM-Konto verknüpfen
|
||
</button>
|
||
<details style="margin-top:10px">
|
||
<summary style="cursor:pointer;font-size:12px;color:var(--c-primary)">Noch kein OSM-Konto? Was ist das?</summary>
|
||
<div class="text-sm-muted" style="margin-top:8px;font-size:12px;line-height:1.5">
|
||
<p style="margin:0 0 6px">OpenStreetMap ist die freie Weltkarte – von Menschen gemacht, gehört allen, keine Werbung, kein Datenverkauf. Mit einem kostenlosen Konto trägst du hundefreundliche Orte ein, die jeder Hundehalter sieht.</p>
|
||
<p style="margin:0 0 4px"><strong>So geht's:</strong></p>
|
||
<ol style="margin:0 0 8px 16px;padding:0">
|
||
<li>Konto erstellen (Benutzername + E-Mail – kein Klarname nötig)</li>
|
||
<li>Bestätigungs-E-Mail anklicken</li>
|
||
<li>Hier zurück → „OSM-Konto verknüpfen"</li>
|
||
</ol>
|
||
${st.sandbox ? `<p style="margin:0 0 8px;padding:6px 8px;background:rgba(245,158,11,.12);border-radius:6px">⚠️ <strong>Testphase:</strong> Dies ist eine Test-Karte. Deine Einträge verändern die echte OpenStreetMap noch nicht.</p>` : ''}
|
||
<a href="${st.signup_url || 'https://www.openstreetmap.org/user/new'}" target="_blank" rel="noopener"
|
||
style="display:inline-flex;align-items:center;gap:6px;color:var(--c-primary);font-weight:600;text-decoration:none">
|
||
Kostenloses OSM-Konto erstellen
|
||
<svg class="ph-icon" aria-hidden="true" style="width:13px;height:13px"><use href="/icons/phosphor.svg#arrow-square-out"></use></svg>
|
||
</a>
|
||
</div>
|
||
</details>`;
|
||
el.querySelector('#settings-osm-link').addEventListener('click', async () => {
|
||
try {
|
||
const r = await API.get('/osm-auth/authorize');
|
||
if (r.authorize_url) window.location.href = r.authorize_url;
|
||
} catch (e) {
|
||
UI.toast?.('OSM-Anbindung noch nicht konfiguriert.');
|
||
}
|
||
});
|
||
}
|
||
}).catch(() => { el.innerHTML = '<div class="text-sm-muted">OSM-Status nicht verfügbar.</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 class="text-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 class="text-sm-muted">🔥 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')} 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="${UI.escape(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} → ${UI.escape(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 class="flex-1-min">
|
||
<div style="display:flex;align-items:center;gap:6px;margin-bottom:2px">
|
||
<span style="font-weight:700;font-size:var(--text-sm)">${UI.escape(cat.name)}</span>
|
||
${cur ? `<span style="font-size:10px;font-weight:600;padding:1px 6px;border-radius:999px;
|
||
background:${cur.color};color:${cur.text}">${UI.escape(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 class="text-sm-muted">–</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="${UI.escape(val)}" ${u.erfahrung === val ? 'selected' : ''}>${UI.escape(label)}</option>`
|
||
).join('');
|
||
|
||
const sichtbarkeitOpts = [
|
||
['public', 'Öffentlich'],
|
||
['friends', 'Nur Freunde'],
|
||
['private', 'Privat'],
|
||
].map(([val, label]) =>
|
||
`<option value="${UI.escape(val)}" ${(u.profil_sichtbarkeit || 'public') === val ? 'selected' : ''}>${UI.escape(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="${UI.escape(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">${UI.escape(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="${UI.escape(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="${UI.escape(u.social_link || '')}"
|
||
style="${inputStyle}">
|
||
</div>
|
||
<div style="border-top:1px solid var(--c-border);padding-top:var(--space-3);margin-top:var(--space-1)">
|
||
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:2px">Rechnungsadresse</label>
|
||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-2)">Wird auf Rechnungen gedruckt. Straße in Zeile 1, PLZ + Ort in Zeile 2.</div>
|
||
<textarea name="billing_address" rows="2" maxlength="200"
|
||
placeholder="Musterstraße 1 12345 Berlin"
|
||
style="${inputStyle};resize:vertical;font-family:inherit">${UI.escape(u.billing_address || '')}</textarea>
|
||
</div>
|
||
<div>
|
||
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Dein Geburtstag <span style="font-weight:400;color:var(--c-text-secondary)">(optional)</span></label>
|
||
<input name="geburtstag" type="text" maxlength="5" placeholder="TT.MM"
|
||
value="${UI.escape(u.geburtstag || '')}"
|
||
pattern="\\d{2}\\.\\d{2}"
|
||
title="Format: TT.MM, z.B. 16.05"
|
||
style="${inputStyle}">
|
||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:var(--space-1)">Wird nur für Geburtstagsgrüße in der App verwendet.</div>
|
||
</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 w-full">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',
|
||
billing_address: fd.billing_address || '',
|
||
geburtstag: fd.geburtstag || '',
|
||
});
|
||
Object.assign(_appState.user, updated);
|
||
window.Worlds?.refresh?.(_appState); // Welten neu rendern (z.B. Geburtstags-Greeting)
|
||
UI.modal.close?.();
|
||
UI.toast.success('Profil gespeichert.');
|
||
_render();
|
||
});
|
||
});
|
||
});
|
||
|
||
document.getElementById('settings-check-update')?.addEventListener('click', async () => {
|
||
const btn = document.getElementById('settings-check-update');
|
||
if (!('serviceWorker' in navigator)) {
|
||
UI.toast.info('Service Worker nicht verfügbar.');
|
||
return;
|
||
}
|
||
if (btn) btn.textContent = 'Prüfe…';
|
||
try {
|
||
// Versionsnummer direkt vom API-Endpunkt holen
|
||
const r = await fetch('/api/version', { cache: 'no-store' });
|
||
// r.ok prüfen: SW antwortet offline mit 503+JSON → json() wirft nicht und
|
||
// serverVersion=undefined meldete fälschlich „Ban Yaro ist aktuell".
|
||
if (!r.ok) throw new Error(`version ${r.status}`);
|
||
const { version: serverVersion } = await r.json();
|
||
const localVersion = typeof APP_VER !== 'undefined' ? APP_VER : '0';
|
||
|
||
const reg = await navigator.serviceWorker.getRegistration();
|
||
reg?.update().catch(() => {}); // kein await — kann hängen
|
||
|
||
if (serverVersion && serverVersion !== localVersion) {
|
||
if (reg?.waiting) reg.waiting.postMessage({ type: 'SKIP_WAITING' });
|
||
UI.toast.info(`Update auf v${serverVersion} — Seite wird neu geladen…`);
|
||
setTimeout(() => location.replace('/?_t=' + Date.now()), 1500);
|
||
} else if (reg?.waiting) {
|
||
reg.waiting.postMessage({ type: 'SKIP_WAITING' });
|
||
UI.toast.success('Update wird installiert…');
|
||
setTimeout(() => location.replace('/?_t=' + Date.now()), 1500);
|
||
} else {
|
||
UI.toast.success(`Ban Yaro ist aktuell — Build ${localVersion}`);
|
||
}
|
||
} catch {
|
||
UI.toast.error('Update-Prüfung fehlgeschlagen.');
|
||
} finally {
|
||
if (btn) btn.innerHTML = UI.icon('arrows-clockwise') + ' Auf Update prüfen';
|
||
}
|
||
});
|
||
|
||
document.getElementById('settings-upgrade-pro-btn')?.addEventListener('click', () => {
|
||
_showUpgradeModal('pro');
|
||
});
|
||
document.getElementById('settings-upgrade-breeder-btn')?.addEventListener('click', () => {
|
||
_showUpgradeModal('breeder');
|
||
});
|
||
document.getElementById('settings-cancel-sub-btn')?.addEventListener('click', () => {
|
||
_showCancelModal();
|
||
});
|
||
|
||
document.getElementById('settings-worlds-btn')?.addEventListener('click', () => {
|
||
if (window.Worlds?._openConfigModal) window.Worlds._openConfigModal();
|
||
else if (window.Worlds) window.Worlds.openConfig?.();
|
||
});
|
||
|
||
document.getElementById('settings-hilfe-btn')?.addEventListener('click', () => {
|
||
App.navigate('hilfe');
|
||
});
|
||
|
||
document.getElementById('settings-feedback-btn')?.addEventListener('click', () => {
|
||
const sel = (id) => document.getElementById(id);
|
||
const inputStyle = 'width:100%;padding:10px 12px;border:1.5px solid var(--c-border);border-radius:var(--radius-md);background:var(--c-bg-card);color:var(--c-text);font-size:var(--text-sm);box-sizing:border-box';
|
||
UI.modal.open({
|
||
title: 'Feedback geben',
|
||
body: `
|
||
<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 w-full">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-export-btn')?.addEventListener('click', async () => {
|
||
const btn = document.getElementById('settings-export-btn');
|
||
await UI.asyncButton(btn, async () => {
|
||
try {
|
||
const resp = await fetch('/api/profile/export', {
|
||
credentials: 'include',
|
||
headers: { 'Authorization': `Bearer ${localStorage.getItem('by_token') || ''}` },
|
||
});
|
||
if (!resp.ok) throw new Error('Export fehlgeschlagen.');
|
||
const data = await resp.json();
|
||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `banyaro-export-${new Date().toISOString().slice(0,10)}.json`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
a.remove();
|
||
URL.revokeObjectURL(url);
|
||
UI.toast.success('Export heruntergeladen.');
|
||
} catch (err) {
|
||
UI.toast.error(err.message || 'Fehler beim Export.');
|
||
}
|
||
});
|
||
});
|
||
|
||
document.getElementById('settings-delete-account-btn')?.addEventListener('click', async () => {
|
||
const ok = await UI.modal.confirm({
|
||
title: 'Konto unwiderruflich löschen?',
|
||
message: 'Alle deine Daten (Tagebuch, Gesundheit, Training, Fotos) werden dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.',
|
||
confirmText: 'Ja, Konto löschen',
|
||
danger: true,
|
||
});
|
||
if (!ok) return;
|
||
try {
|
||
await API.del('/profile/account');
|
||
_appState.user = null; _appState.dogs = []; _appState.activeDog = null;
|
||
UI.toast.info('Dein Konto wurde gelöscht.');
|
||
App.navigate('welcome');
|
||
} catch {
|
||
UI.toast.error('Konto konnte nicht gelöscht werden. Bitte versuche es erneut.');
|
||
}
|
||
});
|
||
|
||
document.getElementById('settings-install-btn')?.addEventListener('click', () => {
|
||
App.navigate('welcome', true, { install: true });
|
||
});
|
||
|
||
document.getElementById('settings-push-btn')?.addEventListener('click', async () => {
|
||
try {
|
||
await API.subscribeToPush();
|
||
UI.toast.success('Push-Benachrichtigungen aktiviert.');
|
||
} catch {
|
||
UI.toast.warning('Push-Benachrichtigungen konnten nicht aktiviert werden.');
|
||
}
|
||
});
|
||
|
||
document.getElementById('settings-calendar-btn')?.addEventListener('click', async () => {
|
||
try {
|
||
const { token } = await API.webcal.getToken();
|
||
const url = `webcal://${location.host}/api/webcal/${token}.ics`;
|
||
const httpsUrl = `https://${location.host}/api/webcal/${token}.ics`;
|
||
UI.modal.open({
|
||
title: `${UI.icon('calendar-dots')} Kalender abonnieren`,
|
||
body: `
|
||
<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 class="flex-col-gap-2">
|
||
<a href="${url}"
|
||
class="btn btn-primary text-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.');
|
||
});
|
||
|
||
// DWD-Regenvorhersage (Default AN) — ausgewertet in pages/map.js _dwdEnabled()
|
||
document.getElementById('toggle-dwd-radar')?.addEventListener('change', e => {
|
||
const on = e.target.checked;
|
||
localStorage.setItem('by_dwd_radar', on ? '1' : '0');
|
||
const track = document.getElementById('toggle-dwd-radar-track');
|
||
const thumb = document.getElementById('toggle-dwd-radar-thumb');
|
||
if (track) track.style.background = on ? 'var(--c-primary)' : 'var(--c-border)';
|
||
if (thumb) thumb.style.left = on ? '22px' : '2px';
|
||
UI.toast.info(on
|
||
? 'DWD-Regenvorhersage aktiviert — 2h-Vorhersage im Regenradar (Deutschland).'
|
||
: 'DWD-Regenvorhersage deaktiviert — Regenradar nutzt RainViewer.');
|
||
});
|
||
|
||
document.getElementById('toggle-notes-ki')?.addEventListener('change', async e => {
|
||
const enabled = e.target.checked;
|
||
const track = document.getElementById('toggle-notes-ki-track');
|
||
const thumb = document.getElementById('toggle-notes-ki-thumb');
|
||
if (track) track.style.background = enabled ? 'var(--c-primary)' : 'var(--c-border)';
|
||
if (thumb) thumb.style.left = enabled ? '22px' : '2px';
|
||
try {
|
||
await API.patch('/profile', { notes_ki_enabled: enabled ? 1 : 0 });
|
||
_appState.user.notes_ki_enabled = enabled ? 1 : 0;
|
||
UI.toast.success(enabled ? 'KI-Notiz-Assistent aktiviert.' : 'KI-Notiz-Assistent deaktiviert.');
|
||
} catch (err) {
|
||
UI.toast.error(err?.message || 'Einstellung konnte nicht gespeichert werden.');
|
||
// Revert UI
|
||
e.target.checked = !enabled;
|
||
if (track) track.style.background = !enabled ? 'var(--c-primary)' : 'var(--c-border)';
|
||
if (thumb) thumb.style.left = !enabled ? '22px' : '2px';
|
||
}
|
||
});
|
||
|
||
document.getElementById('toggle-gassi-stunde')?.addEventListener('change', async e => {
|
||
const enabled = e.target.checked;
|
||
const track = document.getElementById('toggle-gassi-stunde-track');
|
||
const thumb = document.getElementById('toggle-gassi-stunde-thumb');
|
||
if (track) track.style.background = enabled ? 'var(--c-primary)' : 'var(--c-border)';
|
||
if (thumb) thumb.style.left = enabled ? '22px' : '2px';
|
||
try {
|
||
await API.patch('/profile', { gassi_stunde_push: enabled ? 1 : 0 });
|
||
_appState.user.gassi_stunde_push = enabled ? 1 : 0;
|
||
UI.toast.success(enabled ? 'Goldene Gassi-Stunde aktiviert.' : 'Goldene Gassi-Stunde deaktiviert.');
|
||
} catch (err) {
|
||
UI.toast.error(err?.message || 'Einstellung konnte nicht gespeichert werden.');
|
||
// Revert UI
|
||
e.target.checked = !enabled;
|
||
if (track) track.style.background = !enabled ? 'var(--c-primary)' : 'var(--c-border)';
|
||
if (thumb) thumb.style.left = !enabled ? '22px' : '2px';
|
||
}
|
||
});
|
||
|
||
_loadReferral();
|
||
_loadBreederCard();
|
||
|
||
document.getElementById('settings-partner-profile-btn')
|
||
?.addEventListener('click', () => App.navigate('partner-profil'));
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// 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>${UI.escape(label)}</span>
|
||
<button class="by-toggle ki-toggle-btn" data-key="${UI.escape(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 class="text-secondary">Zwinger: <strong>${UI.escape(profile.zwingername)}</strong></div>` : ''}
|
||
${profile?.rasse_text ? `<div class="text-secondary">Rasse: <strong>${UI.escape(profile.rasse_text)}</strong></div>` : ''}
|
||
</div>
|
||
${rolle === 'breeder' && profile ? `
|
||
<button class="btn btn-secondary btn-sm mt-3" id="breeder-edit-profile-btn">
|
||
${UI.icon('pencil-simple')} Profil bearbeiten
|
||
</button>` : ''}
|
||
${rolle === 'admin' && !profile ? `
|
||
<button class="btn btn-primary btn-sm mt-3" id="breeder-admin-create-btn">
|
||
${UI.icon('plus')} Admin-Züchterprofil anlegen
|
||
</button>` : ''}
|
||
${rolle === 'admin' && profile ? `
|
||
<button class="btn btn-secondary btn-sm mt-3" id="breeder-edit-profile-btn">
|
||
${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 class="mt-3">
|
||
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0 0 var(--space-2)">
|
||
Du kannst einen neuen Antrag stellen.
|
||
</p>
|
||
<button class="btn btn-secondary btn-sm" id="breeder-reapply-btn">
|
||
${UI.icon('arrow-counter-clockwise')} Neu beantragen
|
||
</button>
|
||
</div>`;
|
||
} else {
|
||
// Kein Antrag, kein Profil — Card ausblenden (Upgrade-Flow läuft über Abo & Tarif)
|
||
slot.innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
slot.innerHTML = `
|
||
<div class="card mb-4">
|
||
<div class="by-card-section-header">Züchter-Profil</div>
|
||
<div class="p-4">
|
||
${statusBadge}
|
||
${actionBlock}
|
||
</div>
|
||
</div>`;
|
||
|
||
// Button-Handler binden
|
||
slot.querySelector('#breeder-reapply-btn')?.addEventListener('click', () => _showUpgradeModal('breeder'));
|
||
|
||
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="${UI.escape(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="${UI.escape(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="${UI.escape(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="${UI.escape(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="${UI.escape(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">${UI.escape(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 class="text-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 class="text-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 class="text-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 class="text-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 class="text-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 w-full" id="breeder-apply-submit"
|
||
>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 class="mb-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 = ''; }
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// GEDENKSEITE — für verstorbene Hunde
|
||
// ----------------------------------------------------------
|
||
async function _openGedenkseite(dogId, dogName) {
|
||
UI.modal.open({ title: `Erinnerungen an ${UI.escape(dogName)}`, body: `
|
||
<div style="text-align:center;padding:var(--space-4)">
|
||
<svg class="ph-icon" style="width:32px;height:32px;color:var(--c-primary);animation:spin 1s linear infinite" aria-hidden="true">
|
||
<use href="/icons/phosphor.svg#spinner"></use>
|
||
</svg>
|
||
</div>` });
|
||
|
||
let data;
|
||
try { data = await API.get(`/dogs/${dogId}/gedenkseite`); }
|
||
catch { UI.modal.close(); return; }
|
||
|
||
const d = data;
|
||
const av = d.dog.foto_url
|
||
? `<img src="${UI.escape(d.dog.foto_url)}" style="width:100px;height:100px;border-radius:50%;object-fit:cover;border:3px solid var(--c-primary)">`
|
||
: `<div style="width:100px;height:100px;border-radius:50%;background:var(--c-primary-subtle);display:flex;align-items:center;justify-content:center;border:3px solid var(--c-primary)"><svg class="ph-icon" style="width:48px;height:48px;color:var(--c-primary)" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg></div>`;
|
||
|
||
const photoGrid = d.photos?.length ? `
|
||
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:4px;margin:var(--space-4) 0">
|
||
${d.photos.map(url => `<img src="${UI.escape(url)}" style="width:100%;aspect-ratio:1;object-fit:cover;border-radius:6px">`).join('')}
|
||
</div>` : '';
|
||
|
||
const statsHtml = `
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3);margin:var(--space-4) 0">
|
||
${d.km_total ? `<div class="card" style="padding:var(--space-3);text-align:center">
|
||
<div style="font-size:var(--text-xl);font-weight:800;color:var(--c-primary)">${d.km_total}</div>
|
||
<div class="text-xs-secondary">km zusammen</div>
|
||
</div>` : ''}
|
||
${d.diary_count ? `<div class="card" style="padding:var(--space-3);text-align:center">
|
||
<div style="font-size:var(--text-xl);font-weight:800;color:var(--c-primary)">${d.diary_count}</div>
|
||
<div class="text-xs-secondary">Tagebucheinträge</div>
|
||
</div>` : ''}
|
||
${d.gemeinsam_tage ? `<div class="card" style="padding:var(--space-3);text-align:center">
|
||
<div style="font-size:var(--text-xl);font-weight:800;color:var(--c-primary)">${d.gemeinsam_tage}</div>
|
||
<div class="text-xs-secondary">gemeinsame Tage</div>
|
||
</div>` : ''}
|
||
</div>`;
|
||
|
||
const passed = d.dog.verstorben_am;
|
||
const passedStr = passed
|
||
? new Date(passed).toLocaleDateString('de-DE', { day: 'numeric', month: 'long', year: 'numeric' })
|
||
: '';
|
||
|
||
UI.modal.open({
|
||
title: `Erinnerungen an ${UI.escape(d.dog.name)}`,
|
||
body: `
|
||
<div style="text-align:center;margin-bottom:var(--space-4)">
|
||
${av}
|
||
<div style="margin-top:var(--space-3);font-size:var(--text-lg);font-weight:700">${UI.escape(d.dog.name)}</div>
|
||
${passedStr ? `<div style="font-size:var(--text-sm);color:var(--c-text-muted);margin-top:4px">
|
||
<svg class="ph-icon" style="width:14px;height:14px" aria-hidden="true"><use href="/icons/phosphor.svg#heart-break"></use></svg>
|
||
${passedStr}
|
||
</div>` : ''}
|
||
</div>
|
||
${statsHtml}
|
||
${photoGrid}
|
||
<div style="background:var(--c-primary-subtle);border-left:3px solid var(--c-primary);
|
||
border-radius:0 var(--radius-md) var(--radius-md) 0;padding:var(--space-4);margin:var(--space-4) 0">
|
||
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0;line-height:1.6">
|
||
Der Schmerz über den Verlust eines Hundes ist real und tief. Lass dich trauern — die Erinnerungen bleiben immer bei dir.
|
||
</p>
|
||
</div>
|
||
${d.ki_abschied ? `<div style="font-style:italic;font-size:var(--text-sm);color:var(--c-text-secondary);
|
||
line-height:1.7;padding:var(--space-3);background:var(--c-surface);
|
||
border-radius:var(--radius-md);border:1px solid var(--c-border)">
|
||
"${UI.escape(d.ki_abschied)}"
|
||
</div>` : ''}
|
||
`,
|
||
});
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// 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 mb-3">
|
||
Link erneut senden
|
||
</button>
|
||
<button id="verify-back-btn" class="btn btn-ghost w-full text-sm-muted">
|
||
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 mt-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 mt-2">
|
||
<label class="form-label 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 mt-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}" class="flex-col-gap-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" data-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);
|
||
});
|
||
}
|
||
|
||
// Referral-Code aus localStorage lesen (30-Tage-Ablauf) bzw. löschen.
|
||
const _storedRefCode = () => {
|
||
try {
|
||
const code = localStorage.getItem('by_ref_code') || '';
|
||
if (!code) return '';
|
||
const ts = parseInt(localStorage.getItem('by_ref_code_ts') || '0', 10);
|
||
if (ts && Date.now() - ts > 30 * 24 * 3600 * 1000) { _clearRefCode(); return ''; }
|
||
return code;
|
||
} catch { return ''; }
|
||
};
|
||
const _clearRefCode = () => {
|
||
try { localStorage.removeItem('by_ref_code'); localStorage.removeItem('by_ref_code_ts'); } catch {}
|
||
};
|
||
|
||
// Partner-Code live validieren
|
||
const partnerInput = document.getElementById('reg-partner-code');
|
||
const partnerHint = document.getElementById('reg-partner-hint');
|
||
let _partnerValid = false;
|
||
if (partnerInput) {
|
||
// Vorausfüllen falls via Referral-Link gesetzt (localStorage, überlebt App-Schließen)
|
||
const stored = _storedRefCode();
|
||
if (stored) partnerInput.value = stored;
|
||
|
||
let _debounce = null;
|
||
partnerInput.addEventListener('input', () => {
|
||
const code = partnerInput.value.trim().toUpperCase();
|
||
partnerInput.value = code;
|
||
clearTimeout(_debounce);
|
||
partnerHint.style.display = 'none';
|
||
_partnerValid = false;
|
||
if (code.length < 3) return;
|
||
_debounce = setTimeout(async () => {
|
||
try {
|
||
const info = await API.get(`/partner/codes/${encodeURIComponent(code)}/info`);
|
||
if (info.redeemable) {
|
||
partnerHint.textContent = info.grants_founder
|
||
? `✓ Gültiger Code von "${info.label}" — du erhältst eine lebenslange Gründer-Lizenz!`
|
||
: `✓ Gültiger Einladungscode von "${info.label}"`;
|
||
partnerHint.style.cssText = 'display:block;background:var(--c-success-bg,#f0fdf4);color:var(--c-success,#16a34a);padding:var(--space-2) var(--space-3);border-radius:var(--radius-sm);font-size:var(--text-xs)';
|
||
_partnerValid = true;
|
||
} else {
|
||
partnerHint.textContent = 'Dieser Code ist bereits vollständig eingelöst.';
|
||
partnerHint.style.cssText = 'display:block;background:#fef2f2;color:#dc2626;padding:var(--space-2) var(--space-3);border-radius:var(--radius-sm);font-size:var(--text-xs)';
|
||
}
|
||
} catch {
|
||
partnerHint.textContent = 'Code nicht gefunden.';
|
||
partnerHint.style.cssText = 'display:block;color:var(--c-text-muted);font-size:var(--text-xs)';
|
||
}
|
||
}, 500);
|
||
});
|
||
}
|
||
|
||
document.getElementById('auth-form')?.addEventListener('submit', async e => {
|
||
e.preventDefault();
|
||
const btn = e.target.querySelector('[type="submit"]');
|
||
const fd = UI.formData(e.target);
|
||
|
||
if (!fd.name?.trim()) {
|
||
UI.toast.warning('Bitte einen Namen eingeben.');
|
||
return;
|
||
}
|
||
if ((fd.password || '').length < 8) {
|
||
UI.toast.warning('Passwort muss mindestens 8 Zeichen lang sein.');
|
||
return;
|
||
}
|
||
|
||
await UI.asyncButton(btn, async () => {
|
||
const partnerCode = (fd.partner_code || '').trim().toUpperCase() || undefined;
|
||
const refCode = _storedRefCode();
|
||
const finalCode = partnerCode || refCode || undefined;
|
||
// QR-Token mitschicken — Backend ordnet ihn nur zu, wenn er zum Code passt
|
||
const qrToken = (() => { try { return localStorage.getItem('by_qr_token') || undefined; } catch { return undefined; } })();
|
||
const result = await API.auth.register(fd.email, fd.password, fd.name.trim(), finalCode, qrToken);
|
||
if (refCode) _clearRefCode();
|
||
try { localStorage.removeItem('by_qr_token'); } catch {}
|
||
|
||
if (result.pending_verification) {
|
||
_renderVerifyPending(fd.email);
|
||
return;
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// PUSH-BENACHRICHTIGUNGEN ANBIETEN (nach Login)
|
||
// ----------------------------------------------------------
|
||
function _offerPushNotifications() {
|
||
// Kleiner Toast-Banner mit Ja-Button — nicht-invasiv
|
||
const toastEl = document.createElement('div');
|
||
toastEl.id = 'push-offer-banner';
|
||
toastEl.style.cssText = [
|
||
'position:fixed',
|
||
'bottom:calc(var(--nav-h, 64px) + var(--space-3))',
|
||
'left:50%',
|
||
'transform:translateX(-50%)',
|
||
'background:var(--c-surface)',
|
||
'border:1.5px solid var(--c-border)',
|
||
'border-radius:var(--radius-lg)',
|
||
'box-shadow:var(--shadow-lg)',
|
||
'padding:var(--space-3) var(--space-4)',
|
||
'display:flex',
|
||
'align-items:center',
|
||
'gap:var(--space-3)',
|
||
'font-size:var(--text-sm)',
|
||
'z-index:1100',
|
||
'max-width:340px',
|
||
'width:calc(100% - var(--space-8))',
|
||
].join(';');
|
||
toastEl.innerHTML = `
|
||
<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
|
||
// ----------------------------------------------------------
|
||
|
||
// ----------------------------------------------------------
|
||
// PUBLIC
|
||
// ----------------------------------------------------------
|
||
return { init, refresh };
|
||
|
||
})();
|