Feature: Züchter-Profil Komplett-Redesign — Hero, Hunde+Tests, Würfe, Gesundheitsstatistik (SW by-v900)

This commit is contained in:
rene 2026-05-13 18:47:49 +02:00
parent d5a3a1bb05
commit b17706e7ba
6 changed files with 362 additions and 143 deletions

View file

@ -406,7 +406,7 @@ async def serve_media(path: str, request: _Request):
raise _HE(404, "Nicht gefunden.") raise _HE(404, "Nicht gefunden.")
return _media_response(filepath) return _media_response(filepath)
APP_VER = "899" # muss mit APP_VER in app.js übereinstimmen APP_VER = "900" # muss mit APP_VER in app.js übereinstimmen
@app.get("/.well-known/assetlinks.json") @app.get("/.well-known/assetlinks.json")
async def assetlinks(): async def assetlinks():

View file

@ -301,7 +301,7 @@ async def admin_reject_breeder(user_id: int, body: RejectBody, admin=Depends(req
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# GET /api/breeder/profil/{zwingername} — öffentliches Profil # GET /api/breeder/profil/{zwingername} — öffentliches Profil (angereichert)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@router.get("/breeder/profil/{zwingername}") @router.get("/breeder/profil/{zwingername}")
async def breeder_public_profile(zwingername: str): async def breeder_public_profile(zwingername: str):
@ -318,9 +318,84 @@ async def breeder_public_profile(zwingername: str):
AND u.rolle IN ('breeder', 'admin') AND u.rolle IN ('breeder', 'admin')
AND (u.breeder_status = 'approved' OR u.rolle = 'admin') AND (u.breeder_status = 'approved' OR u.rolle = 'admin')
""", (zwingername,)).fetchone() """, (zwingername,)).fetchone()
if not row: if not row:
raise HTTPException(404, "Züchter nicht gefunden.") raise HTTPException(404, "Züchter nicht gefunden.")
return dict(row)
breeder_id = row["id"]
result = dict(row)
# Öffentliche Zuchthunde + ihre wichtigsten Gesundheitstests + Titel
hunde_rows = conn.execute("""
SELECT id, name, rufname, geschlecht, geburtsdatum, farbe, zuchtbuchnummer, foto_url
FROM zucht_hunde
WHERE breeder_id=? AND is_public=1 AND (sterbedatum IS NULL OR sterbedatum='')
ORDER BY geschlecht, name
""", (breeder_id,)).fetchall()
hunde = []
for h in hunde_rows:
hund = dict(h)
# Gesundheitstests (nur öffentliche, nur HD/ED/Augen/Herz)
tests = conn.execute("""
SELECT test_typ, ergebnis, test_name, untersuch_am
FROM dog_health_tests
WHERE hund_id=? AND is_public=1
AND test_typ IN ('HD','ED','augen','herz','OCD','patella','ZTP')
ORDER BY test_typ, untersuch_am DESC
""", (h["id"],)).fetchall()
seen = set()
hund["health_tests"] = []
for t in tests:
if t["test_typ"] not in seen:
seen.add(t["test_typ"])
hund["health_tests"].append(dict(t))
# Gentests (nur öffentliche, Zusammenfassung)
gentests = conn.execute("""
SELECT COUNT(*) as total,
SUM(CASE WHEN ergebnis_klasse='clear' THEN 1 ELSE 0 END) as clear_cnt
FROM dog_genetic_tests WHERE hund_id=? AND is_public=1
""", (h["id"],)).fetchone()
hund["gentests_total"] = gentests["total"] or 0
hund["gentests_clear"] = gentests["clear_cnt"] or 0
# Auszeichnungen (nur Zucht/Champion)
titles = conn.execute("""
SELECT titel_name FROM dog_titles
WHERE hund_id=? AND titel_typ IN ('champion','zucht','ausstellung')
ORDER BY verliehen_am DESC LIMIT 3
""", (h["id"],)).fetchall()
hund["titel"] = [t["titel_name"] for t in titles]
hunde.append(hund)
result["hunde"] = hunde
# Sichtbare Würfe
wuerfe = conn.execute("""
SELECT id, vater_name, mutter_name, geburt_datum, erwartetes_datum,
status, welpen_gesamt, welpen_verfuegbar, preis_spanne, beschreibung
FROM litters
WHERE breeder_id=? AND sichtbar=1 AND status != 'abgeschlossen'
ORDER BY COALESCE(geburt_datum, erwartetes_datum) DESC
""", (breeder_id,)).fetchall()
result["wuerfe"] = [dict(w) for w in wuerfe]
# Gesundheits-Statistik (aggregiert über alle öffentlichen Hunde)
hd_stats = conn.execute("""
SELECT ergebnis, COUNT(*) as cnt FROM dog_health_tests
WHERE hund_id IN (SELECT id FROM zucht_hunde WHERE breeder_id=? AND is_public=1)
AND test_typ='HD' AND is_public=1
GROUP BY ergebnis
""", (breeder_id,)).fetchall()
result["hd_stats"] = [dict(r) for r in hd_stats]
ed_stats = conn.execute("""
SELECT ergebnis, COUNT(*) as cnt FROM dog_health_tests
WHERE hund_id IN (SELECT id FROM zucht_hunde WHERE breeder_id=? AND is_public=1)
AND test_typ='ED' AND is_public=1
GROUP BY ergebnis
""", (breeder_id,)).fetchall()
result["ed_stats"] = [dict(r) for r in ed_stats]
return result
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View file

@ -591,10 +591,10 @@
<div id="modal-container"></div> <div id="modal-container"></div>
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features --> <!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
<script src="/js/api.js?v=899"></script> <script src="/js/api.js?v=900"></script>
<script src="/js/ui.js?v=899"></script> <script src="/js/ui.js?v=900"></script>
<script src="/js/app.js?v=899"></script> <script src="/js/app.js?v=900"></script>
<script src="/js/worlds.js?v=899"></script> <script src="/js/worlds.js?v=900"></script>
<!-- Feature-Seiten werden lazy geladen --> <!-- Feature-Seiten werden lazy geladen -->

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung. Router, State-Management, Navigation, Initialisierung.
============================================================ */ ============================================================ */
const APP_VER = '899'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VER = '900'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.5.1'; // ← semantische Version, wird bei make release gesetzt const APP_VERSION = '1.5.1'; // ← semantische Version, wird bei make release gesetzt
const IS_STAGING = location.hostname === 'staging.banyaro.app'; const IS_STAGING = location.hostname === 'staging.banyaro.app';
// Cache-Bust-Parameter nach Update-Reload sofort entfernen // Cache-Bust-Parameter nach Update-Reload sofort entfernen

View file

@ -1,6 +1,5 @@
/* ============================================================ /* ============================================================
BAN YARO Öffentliches Züchter-Profil BAN YARO Öffentliches Züchter-Profil (Visitenkarte)
Seiten-Modul: Zeigt das verifizierte Profil eines Züchters.
============================================================ */ ============================================================ */
window.Page_breeder = (() => { window.Page_breeder = (() => {
@ -8,7 +7,8 @@ window.Page_breeder = (() => {
let _container = null; let _container = null;
let _appState = null; let _appState = null;
const _esc = s => UI.esc ? UI.esc(s) : String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); const _esc = s => UI.esc ? UI.esc(s) : String(s ?? '').replace(/[&<>"']/g,
c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
// ---------------------------------------------------------- // ----------------------------------------------------------
// INIT // INIT
@ -17,7 +17,6 @@ window.Page_breeder = (() => {
_container = container; _container = container;
_appState = appState; _appState = appState;
// Zwingername aus params oder URL-Pfad (/breeder/vom-sonnenfeld)
const zwingername = params?.zwingername const zwingername = params?.zwingername
|| decodeURIComponent((window.location.pathname.split('/breeder/')[1] || '').replace(/\/$/, '')); || decodeURIComponent((window.location.pathname.split('/breeder/')[1] || '').replace(/\/$/, ''));
@ -27,8 +26,8 @@ window.Page_breeder = (() => {
} }
container.innerHTML = ` container.innerHTML = `
<div id="breeder-profile-body" style="padding:var(--space-4) var(--space-4) calc(var(--space-16) + 24px)"> <div id="breeder-profile-body" style="padding-bottom:calc(var(--space-16) + 24px)">
${UI.skeleton(3)} ${UI.skeleton(5)}
</div> </div>
<button id="breeder-back-fab" aria-label="Zurück zur Wurfbörse" <button id="breeder-back-fab" aria-label="Zurück zur Wurfbörse"
style="position:fixed;bottom:calc(var(--safe-bottom,0px) + 20px);right:20px; style="position:fixed;bottom:calc(var(--safe-bottom,0px) + 20px);right:20px;
@ -51,7 +50,9 @@ window.Page_breeder = (() => {
_render(p); _render(p);
} catch (e) { } catch (e) {
document.getElementById('breeder-profile-body').innerHTML = document.getElementById('breeder-profile-body').innerHTML =
`<p style="padding:var(--space-6);color:var(--c-text-secondary)">${_esc(e.message || 'Züchter nicht gefunden.')}</p>`; `<div style="padding:var(--space-8);text-align:center;color:var(--c-text-secondary)">
${UI.icon('magnifying-glass')} ${_esc(e.message || 'Züchter nicht gefunden.')}
</div>`;
} }
} }
@ -59,176 +60,319 @@ window.Page_breeder = (() => {
// RENDER // RENDER
// ---------------------------------------------------------- // ----------------------------------------------------------
function _render(p) { function _render(p) {
const verifiedDate = p.verified_at const body = document.getElementById('breeder-profile-body') || _container;
? new Date(p.verified_at).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
const seit = p.verified_at
? new Date(p.verified_at).toLocaleDateString('de-DE', { month: 'long', year: 'numeric' })
: null; : null;
const websiteHtml = p.website const isLoggedIn = !!_appState?.user;
? `<a href="${_esc(p.website)}" target="_blank" rel="noopener noreferrer" const isOwnProfile = _appState?.user?.id === p.zuechter_user_id;
style="color:var(--c-primary);text-decoration:none;word-break:break-all">
${UI.icon('arrow-square-out')} ${_esc(p.website)}
</a>`
: '';
const beschreibungHtml = p.beschreibung
? `<div class="card" style="margin-bottom:var(--space-3)">
<p style="margin:0;white-space:pre-line;color:var(--c-text-secondary)">${_esc(p.beschreibung)}</p>
</div>`
: '';
const body = document.getElementById('breeder-profile-body') || _container;
body.innerHTML = ` body.innerHTML = `
<div style="padding:var(--space-4) 0"> <!-- HERO -->
<div style="background:linear-gradient(135deg,var(--c-primary-dark,#a86e2e),var(--c-primary,#C4843A));
<!-- Header-Card --> padding:var(--space-6) var(--space-4) var(--space-8);color:white;position:relative">
<div class="card" style="margin-bottom:var(--space-3)"> <div style="max-width:640px;margin:0 auto">
<div style="display:flex;align-items:flex-start;gap:var(--space-3);flex-wrap:wrap"> <div style="display:flex;align-items:flex-start;gap:var(--space-3);flex-wrap:wrap">
<div style="flex:1;min-width:0"> <div style="flex:1;min-width:0">
<h2 style="margin:0 0 var(--space-1);font-size:var(--text-xl);word-break:break-word"> <p style="margin:0 0 var(--space-1);font-size:var(--text-xs);opacity:.7;text-transform:uppercase;letter-spacing:.1em">
${UI.icon('certificate')} ${_esc(p.zwingername)} Verifizierter Züchter
</h2> </p>
<span class="badge badge-primary" style="background:var(--c-success,#22C55E);color:#fff;font-size:var(--text-xs)"> <h1 style="margin:0 0 var(--space-2);font-size:clamp(1.3rem,4vw,1.9rem);font-weight:800;line-height:1.2;word-break:break-word">
${UI.icon('seal-check')} Verifizierter Züchter ${_esc(p.zwingername)}
</span> </h1>
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);align-items:center">
${p.rasse_text ? `<span style="background:rgba(255,255,255,.2);border-radius:999px;padding:2px 10px;font-size:var(--text-xs);font-weight:600">${_esc(p.rasse_text)}</span>` : ''}
${p.vdh_mitglied ? `<span style="background:rgba(255,255,255,.2);border-radius:999px;padding:2px 10px;font-size:var(--text-xs);font-weight:600">${UI.icon('certificate')} VDH</span>` : ''}
${p.stadt ? `<span style="opacity:.8;font-size:var(--text-xs)">${UI.icon('map-pin')} ${_esc(p.stadt)}</span>` : ''}
${seit ? `<span style="opacity:.7;font-size:var(--text-xs)">Züchter seit ${_esc(seit)}</span>` : ''}
</div>
</div>
<div style="background:rgba(255,255,255,.15);border-radius:50%;width:52px;height:52px;display:flex;align-items:center;justify-content:center;flex-shrink:0">
<svg style="width:28px;height:28px" viewBox="0 0 256 256"><use href="/icons/phosphor.svg#seal-check"></use></svg>
</div> </div>
</div> </div>
${!isOwnProfile ? `
<div style="margin-top:var(--space-5);display:flex;gap:var(--space-3);flex-wrap:wrap">
${isLoggedIn
? `<button class="breeder-chat-btn"
style="background:white;color:var(--c-primary-dark,#a86e2e);border:none;
border-radius:999px;padding:var(--space-2) var(--space-5);
font-weight:700;cursor:pointer;display:flex;align-items:center;gap:6px">
${UI.icon('chat-circle-dots')} Nachricht senden
</button>`
: `<button class="breeder-login-btn"
style="background:white;color:var(--c-primary-dark,#a86e2e);border:none;
border-radius:999px;padding:var(--space-2) var(--space-5);
font-weight:700;cursor:pointer">
Anmelden um zu schreiben
</button>`
}
${p.website ? `<a href="${_esc(p.website)}" target="_blank" rel="noopener noreferrer"
style="background:rgba(255,255,255,.2);color:white;border:1px solid rgba(255,255,255,.4);
border-radius:999px;padding:var(--space-2) var(--space-5);
font-weight:600;font-size:var(--text-sm);text-decoration:none;
display:flex;align-items:center;gap:6px">
${UI.icon('arrow-square-out')} Website
</a>` : ''}
</div>` : ''}
</div> </div>
</div>
<!-- Details-Card --> <div style="max-width:640px;margin:0 auto;padding:var(--space-4)">
<div class="card" style="margin-bottom:var(--space-3)">
<dl style="margin:0;display:flex;flex-direction:column;gap:var(--space-3)">
<div style="display:flex;gap:var(--space-2);align-items:baseline"> <!-- Beschreibung -->
<dt style="color:var(--c-text-secondary);min-width:100px;font-size:var(--text-sm);flex-shrink:0">Rasse</dt> ${p.beschreibung ? `
<dd style="margin:0;font-weight:600">${_esc(p.rasse_text || '')}</dd> <div style="background:var(--c-bg-secondary);border:1px solid var(--c-border);border-radius:var(--radius-lg);
</div> padding:var(--space-4);margin-bottom:var(--space-4)">
<p style="margin:0;line-height:1.7;color:var(--c-text-secondary);white-space:pre-line">${_esc(p.beschreibung)}</p>
</div>` : ''}
<div style="display:flex;gap:var(--space-2);align-items:baseline"> <!-- Zuchthunde -->
<dt style="color:var(--c-text-secondary);min-width:100px;font-size:var(--text-sm);flex-shrink:0">Verein</dt> ${p.hunde?.length ? `
<dd style="margin:0">${_esc(p.verein || '')}</dd> <div style="margin-bottom:var(--space-5)">
</div> <h2 style="margin:0 0 var(--space-3);font-size:var(--text-base);font-weight:700;
display:flex;align-items:center;gap:var(--space-2)">
${UI.icon('dog')} Unsere Zuchthunde
<span style="font-size:var(--text-xs);color:var(--c-text-muted);font-weight:400">${p.hunde.length} Hunde</span>
</h2>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:var(--space-3)">
${p.hunde.map(h => _hundCard(h)).join('')}
</div>
</div>` : ''}
<div style="display:flex;gap:var(--space-2);align-items:baseline"> <!-- Aktuelle Würfe -->
<dt style="color:var(--c-text-secondary);min-width:100px;font-size:var(--text-sm);flex-shrink:0">VDH-Mitglied</dt> ${p.wuerfe?.length ? `
<dd style="margin:0"> <div style="margin-bottom:var(--space-5)">
${p.vdh_mitglied <h2 style="margin:0 0 var(--space-3);font-size:var(--text-base);font-weight:700;
? `<span class="badge badge-primary">${UI.icon('check')} Ja</span>` display:flex;align-items:center;gap:var(--space-2)">
: `<span style="color:var(--c-text-secondary)">Nein</span>`} ${UI.icon('baby')} Aktuelle Würfe
</dd> </h2>
</div> <div style="display:flex;flex-direction:column;gap:var(--space-3)">
${p.wuerfe.map(w => _wurfCard(w)).join('')}
</div>
</div>` : ''}
<div style="display:flex;gap:var(--space-2);align-items:baseline"> <!-- Gesundheits-Transparenz -->
<dt style="color:var(--c-text-secondary);min-width:100px;font-size:var(--text-sm);flex-shrink:0">Stadt</dt> ${(p.hd_stats?.length || p.ed_stats?.length) ? `
<dd style="margin:0">${_esc(p.stadt || '')}</dd> <div style="margin-bottom:var(--space-5)">
</div> <h2 style="margin:0 0 var(--space-3);font-size:var(--text-base);font-weight:700;
display:flex;align-items:center;gap:var(--space-2)">
${UI.icon('heartbeat')} Gesundheits-Transparenz
</h2>
<div style="background:var(--c-bg-secondary);border:1px solid var(--c-border);
border-radius:var(--radius-lg);padding:var(--space-4)">
${_statsSection('HD-Ergebnisse', p.hd_stats)}
${p.hd_stats?.length && p.ed_stats?.length ? '<hr style="border:none;border-top:1px solid var(--c-border);margin:var(--space-3) 0">' : ''}
${_statsSection('ED-Ergebnisse', p.ed_stats)}
</div>
</div>` : ''}
<!-- Kontakt/Details -->
<div style="background:var(--c-bg-secondary);border:1px solid var(--c-border);
border-radius:var(--radius-lg);padding:var(--space-4);margin-bottom:var(--space-4)">
<h2 style="margin:0 0 var(--space-3);font-size:var(--text-base);font-weight:700">
${UI.icon('info')} Über den Züchter
</h2>
<dl style="margin:0;display:flex;flex-direction:column;gap:var(--space-2)">
${_dl('Züchter', p.zuechter_name)}
${_dl('Rasse(n)', p.rasse_text)}
${_dl('Verein', p.verein)}
${_dl('VDH-Mitglied', p.vdh_mitglied ? '✓ Ja' : 'Nein')}
${_dl('Stadt', p.stadt)}
${p.website ? ` ${p.website ? `
<div style="display:flex;gap:var(--space-2);align-items:baseline"> <div style="display:flex;gap:var(--space-2);align-items:baseline">
<dt style="color:var(--c-text-secondary);min-width:100px;font-size:var(--text-sm);flex-shrink:0">Website</dt> <dt style="color:var(--c-text-secondary);min-width:110px;font-size:var(--text-sm);flex-shrink:0">Website</dt>
<dd style="margin:0">${websiteHtml}</dd> <dd style="margin:0"><a href="${_esc(p.website)}" target="_blank" rel="noopener noreferrer"
style="color:var(--c-primary);word-break:break-all">${_esc(p.website)}</a></dd>
</div>` : ''} </div>` : ''}
${seit ? _dl('Züchter seit', seit) : ''}
${verifiedDate ? `
<div style="display:flex;gap:var(--space-2);align-items:baseline">
<dt style="color:var(--c-text-secondary);min-width:100px;font-size:var(--text-sm);flex-shrink:0">Verifiziert</dt>
<dd style="margin:0;color:var(--c-text-secondary);font-size:var(--text-sm)">${verifiedDate}</dd>
</div>` : ''}
<div style="display:flex;gap:var(--space-2);align-items:baseline">
<dt style="color:var(--c-text-secondary);min-width:100px;font-size:var(--text-sm);flex-shrink:0">Züchter</dt>
<dd style="margin:0">${_esc(p.zuechter_name || '')}</dd>
</div>
</dl> </dl>
</div> </div>
<!-- Beschreibung --> <!-- Fotos -->
${beschreibungHtml}
<!-- Fotos (werden asynchron nachgeladen) -->
<div id="breeder-photos-section"></div> <div id="breeder-photos-section"></div>
<!-- Kontakt-Button --> </div>`;
${(() => {
if (!p.zuechter_user_id) return '';
const isLoggedIn = !!_appState?.user;
const isOwnProfile = _appState?.user?.id === p.zuechter_user_id;
if (isOwnProfile) return '';
if (isLoggedIn) {
return `<button class="btn btn-primary breeder-chat-btn" style="width:100%">
${UI.icon('chat-circle')} Nachricht senden
</button>`;
}
return `<button class="btn btn-primary breeder-login-btn" style="width:100%">
${UI.icon('sign-in')} Anmelden um zu schreiben
</button>`;
})()}
</div> // Events
`; body.querySelector('.breeder-chat-btn')?.addEventListener('click', () => _contactBreeder(p.zuechter_user_id));
body.querySelector('.breeder-login-btn')?.addEventListener('click', () => App.navigate('settings'));
_container.querySelector('.breeder-chat-btn')?.addEventListener('click', () => {
_contactBreeder(p.zuechter_user_id);
});
_container.querySelector('.breeder-login-btn')?.addEventListener('click', () => {
App.navigate('settings');
});
// Öffentliche Fotos nachladen
_loadBreederPhotos(p.id); _loadBreederPhotos(p.id);
} }
// ----------------------------------------------------------
// Hund-Karte
// ----------------------------------------------------------
function _hundCard(h) {
const alter = h.geburtsdatum
? Math.floor((Date.now() - new Date(h.geburtsdatum)) / 31557600000)
: null;
const gIcon = h.geschlecht === 'maennlich' ? UI.icon('gender-male') : UI.icon('gender-female');
const hdTest = h.health_tests?.find(t => t.test_typ === 'HD');
const edTest = h.health_tests?.find(t => t.test_typ === 'ED');
const augeTest = h.health_tests?.find(t => t.test_typ === 'augen');
const testPills = [
hdTest ? `<span style="${_testPillStyle(hdTest.ergebnis,'HD')}">HD ${_esc(hdTest.ergebnis)}</span>` : '',
edTest ? `<span style="${_testPillStyle(edTest.ergebnis,'ED')}">ED ${_esc(edTest.ergebnis)}</span>` : '',
augeTest ? `<span style="${_testPillStyle('clear','augen')}">Augen ✓</span>` : '',
].filter(Boolean).join('');
const titlePills = (h.titel || []).map(t =>
`<span style="background:var(--c-primary-light,#f5e6d3);color:var(--c-primary-dark,#a86e2e);
border-radius:999px;padding:1px 8px;font-size:10px;font-weight:700">${_esc(t)}</span>`
).join('');
const genBadge = h.gentests_total > 0
? `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">
${h.gentests_clear}/${h.gentests_total} Gentests frei
</span>`
: '';
return `
<div style="background:var(--c-surface);border:1px solid var(--c-border);border-radius:var(--radius-lg);
padding:var(--space-3);display:flex;flex-direction:column;gap:var(--space-2)">
<div style="display:flex;align-items:center;gap:var(--space-2)">
<span style="color:var(--c-primary)">${gIcon}</span>
<span style="font-weight:700;font-size:var(--text-sm)">${_esc(h.name)}</span>
${h.rufname ? `<span style="color:var(--c-text-muted);font-size:var(--text-xs)">"${_esc(h.rufname)}"</span>` : ''}
${alter !== null ? `<span style="color:var(--c-text-muted);font-size:var(--text-xs);margin-left:auto">${alter} J.</span>` : ''}
</div>
${h.farbe ? `<p style="margin:0;font-size:var(--text-xs);color:var(--c-text-secondary)">${_esc(h.farbe)}</p>` : ''}
${testPills ? `<div style="display:flex;flex-wrap:wrap;gap:4px">${testPills}</div>` : ''}
${titlePills ? `<div style="display:flex;flex-wrap:wrap;gap:4px">${titlePills}</div>` : ''}
${genBadge}
</div>`;
}
function _testPillStyle(ergebnis, typ) {
const e = (ergebnis || '').toUpperCase();
let bg = '#6b72801a', color = '#6b7280', border = '#6b728040';
if (typ === 'HD') {
if (['A','A1','A2'].includes(e)) { bg='#16a34a1a';color='#16a34a';border='#16a34a40'; }
else if (e === 'B' || e === 'B1' || e === 'B2') { bg='#86efac1a';color='#15803d';border='#86efac40'; }
else if (e === 'C') { bg='#eab3081a';color='#a16207';border='#eab30840'; }
else if (e === 'D' || e === 'E') { bg='#ef44441a';color='#dc2626';border='#ef444440'; }
} else if (typ === 'ED') {
if (e === '0' || e === 'ED 0') { bg='#16a34a1a';color='#16a34a';border='#16a34a40'; }
else if (e === '1') { bg='#eab3081a';color='#a16207';border='#eab30840'; }
else if (e === '2' || e === '3') { bg='#ef44441a';color='#dc2626';border='#ef444440'; }
} else if (typ === 'augen' || ergebnis === 'clear') {
bg='#16a34a1a';color='#16a34a';border='#16a34a40';
}
return `background:${bg};color:${color};border:1px solid ${border};border-radius:999px;padding:1px 8px;font-size:11px;font-weight:600`;
}
// ----------------------------------------------------------
// Wurf-Karte
// ----------------------------------------------------------
const _STATUS_LABEL = { geplant: 'Geplant', geboren: 'Geboren', verfuegbar: 'Verfügbar', abgeschlossen: 'Abgeschlossen' };
const _STATUS_COLOR = { geplant: '#6b7280', geboren: '#3b82f6', verfuegbar: '#16a34a', abgeschlossen: '#9ca3af' };
function _wurfCard(w) {
const eltern = [w.vater_name, w.mutter_name].filter(Boolean).join(' × ') || '—';
const datum = w.geburt_datum
? `Geburt: ${_fmtDate(w.geburt_datum)}`
: w.erwartetes_datum ? `Erwartet: ${_fmtDate(w.erwartetes_datum)}` : '';
const sc = _STATUS_COLOR[w.status] || '#6b7280';
const sl = _STATUS_LABEL[w.status] || w.status;
return `
<div style="background:var(--c-surface);border:1px solid var(--c-border);border-radius:var(--radius-lg);
padding:var(--space-3) var(--space-4)">
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-1)">
<span style="font-weight:700;font-size:var(--text-sm)">${_esc(eltern)}</span>
<span style="background:${sc}1a;color:${sc};border:1px solid ${sc}40;
border-radius:999px;padding:1px 8px;font-size:var(--text-xs);font-weight:600">${sl}</span>
</div>
<div style="display:flex;gap:var(--space-4);flex-wrap:wrap;font-size:var(--text-xs);color:var(--c-text-secondary)">
${datum ? `<span>${UI.icon('calendar-dots')} ${_esc(datum)}</span>` : ''}
${w.welpen_gesamt ? `<span>${UI.icon('dog')} ${w.welpen_verfuegbar ?? '?'}/${w.welpen_gesamt} verfügbar</span>` : ''}
${w.preis_spanne ? `<span>${UI.icon('currency-eur')} ${_esc(w.preis_spanne)}</span>` : ''}
</div>
${w.beschreibung ? `<p style="margin:var(--space-2) 0 0;font-size:var(--text-xs);color:var(--c-text-secondary);line-height:1.5">${_esc(w.beschreibung)}</p>` : ''}
</div>`;
}
// ----------------------------------------------------------
// Statistik-Sektion
// ----------------------------------------------------------
function _statsSection(label, stats) {
if (!stats?.length) return '';
const total = stats.reduce((s, r) => s + r.cnt, 0);
return `
<div>
<p style="margin:0 0 var(--space-2);font-size:var(--text-xs);font-weight:700;
color:var(--c-text-muted);text-transform:uppercase;letter-spacing:.06em">${_esc(label)}</p>
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2)">
${stats.map(r => `
<div style="display:flex;align-items:center;gap:6px;font-size:var(--text-sm)">
<span style="font-weight:700">${_esc(r.ergebnis || '—')}</span>
<span style="color:var(--c-text-muted)">${r.cnt}×</span>
<span style="background:var(--c-border);border-radius:999px;height:6px;
width:${Math.round(r.cnt/total*80)+16}px;display:inline-block"></span>
</div>`).join('')}
</div>
</div>`;
}
// ----------------------------------------------------------
// Hilfsfunktionen
// ----------------------------------------------------------
function _dl(label, value) {
if (!value) return '';
return `<div style="display:flex;gap:var(--space-2);align-items:baseline">
<dt style="color:var(--c-text-secondary);min-width:110px;font-size:var(--text-sm);flex-shrink:0">${_esc(label)}</dt>
<dd style="margin:0;font-size:var(--text-sm)">${_esc(String(value))}</dd>
</div>`;
}
function _fmtDate(iso) {
if (!iso) return '—';
const [y,m,d] = iso.slice(0,10).split('-');
return `${d}.${m}.${y}`;
}
async function _loadBreederPhotos(breederId) { async function _loadBreederPhotos(breederId) {
const section = document.getElementById('breeder-photos-section'); const section = document.getElementById('breeder-photos-section');
if (!section) return; if (!section) return;
try { try {
const photos = await API.breederPhotos.list('breeder', breederId); const photos = await API.breederPhotos.list('breeder', breederId);
if (!photos || !photos.length) return; if (!photos?.length) return;
section.innerHTML = ` section.innerHTML = `
<div class="card" style="margin-bottom:var(--space-3)"> <div style="margin-bottom:var(--space-4)">
<h3 style="margin:0 0 var(--space-3);font-size:var(--text-md);font-weight:var(--weight-semibold)"> <h2 style="margin:0 0 var(--space-3);font-size:var(--text-base);font-weight:700">
${UI.icon('images')} Fotos ${UI.icon('images')} Fotos
</h3> </h2>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(100px,1fr));gap:var(--space-2)"> <div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:var(--space-2)">
${photos.map(ph => { ${photos.map(ph => `
const thumb = ph.thumbnail_url || ph.url || ''; <a href="${_esc(ph.url||'')}" target="_blank" rel="noopener noreferrer"
return ` style="display:block;border-radius:var(--radius-md);overflow:hidden;
<a href="${_esc(ph.url || '')}" target="_blank" rel="noopener noreferrer" border:1px solid var(--c-border);aspect-ratio:1">
style="display:block;border-radius:var(--radius-md);overflow:hidden; <img src="${_esc(ph.thumbnail_url||ph.url||'')}" alt="${_esc(ph.caption||'')}"
border:1px solid var(--c-border);aspect-ratio:1"> loading="lazy" style="width:100%;height:100%;object-fit:cover;display:block"
<img src="${_esc(thumb)}" onerror="this.parentElement.style.display='none'">
alt="${_esc(ph.caption || '')}" </a>`).join('')}
loading="lazy"
style="width:100%;height:100%;object-fit:cover;display:block"
onerror="this.parentElement.style.display='none'">
</a>`;
}).join('')}
</div> </div>
</div>`; </div>`;
} catch (_) { } catch (_) {}
// Fotos sind nicht kritisch — bei Fehler still ignorieren
}
} }
async function _contactBreeder(breederId) { async function _contactBreeder(userId) {
if (!_appState?.user) { if (!_appState?.user) { App.navigate('settings'); return; }
App.navigate('settings');
return;
}
try { try {
await API.chat.start(breederId); await API.chat.start(userId);
App.navigate('chat'); App.navigate('chat');
} catch (e) { } catch (e) { UI.toast.error(e.message || 'Chat konnte nicht geöffnet werden.'); }
UI.toast.error(e.message || 'Chat konnte nicht geöffnet werden.');
}
} }
function refresh() {} function refresh() {}
function onDogChange() {} function onDogChange() {}
function destroy() { function destroy() { document.getElementById('breeder-back-fab')?.remove(); }
document.getElementById('breeder-back-fab')?.remove();
}
return { init, refresh, onDogChange, destroy }; return { init, refresh, onDogChange, destroy };

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache Offline-Cache + Push Notifications + Tile-Cache
============================================================ */ ============================================================ */
const CACHE_VERSION = 'by-v899'; const CACHE_VERSION = 'by-v900';
const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache