Release: Züchter-Sprint — Läufigkeit, Warteliste, Privater-Header, Wurfbörse, Z-Badge (by-v918)

This commit is contained in:
rene 2026-05-13 22:22:41 +02:00
commit 5d60b6c841
18 changed files with 2969 additions and 336 deletions

View file

@ -2265,6 +2265,82 @@ def _migrate(conn_factory):
except Exception as e:
logger.warning(f"Migration behavior_log: {e}")
try:
conn.execute("""
CREATE TABLE IF NOT EXISTS laeufi_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hund_id INTEGER NOT NULL REFERENCES zucht_hunde(id) ON DELETE CASCADE,
beginn TEXT NOT NULL,
ende TEXT,
notiz TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS progesteron_tests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
laeufi_id INTEGER NOT NULL REFERENCES laeufi_log(id) ON DELETE CASCADE,
datum TEXT NOT NULL,
wert REAL,
einheit TEXT NOT NULL DEFAULT 'ng/ml',
labor TEXT,
notiz TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS deckdaten (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hund_id INTEGER NOT NULL REFERENCES zucht_hunde(id) ON DELETE CASCADE,
laeufi_id INTEGER REFERENCES laeufi_log(id) ON DELETE SET NULL,
deckdatum TEXT NOT NULL,
ruede_id INTEGER REFERENCES zucht_hunde(id) ON DELETE SET NULL,
ruede_name TEXT,
deckart TEXT NOT NULL DEFAULT 'natuerlich',
traechtig INTEGER NOT NULL DEFAULT 0,
ultraschall_datum TEXT,
notiz TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_laeufi_hund ON laeufi_log(hund_id, beginn DESC)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_prog_laeufi ON progesteron_tests(laeufi_id, datum)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_deck_hund ON deckdaten(hund_id, deckdatum DESC)")
logger.info("Migration: laeufi_log, progesteron_tests, deckdaten bereit.")
except Exception as e:
logger.warning(f"Migration laeufi: {e}")
try:
conn.execute("""
CREATE TABLE IF NOT EXISTS litter_waitlist (
id INTEGER PRIMARY KEY AUTOINCREMENT,
litter_id INTEGER NOT NULL REFERENCES litters(id) ON DELETE CASCADE,
name TEXT NOT NULL,
email TEXT,
telefon TEXT,
nachricht TEXT,
wunsch_geschlecht TEXT DEFAULT 'egal',
wunsch_farbe TEXT,
prioritaet INTEGER DEFAULT 0,
status TEXT DEFAULT 'anfrage',
notiz TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_waitlist_litter ON litter_waitlist(litter_id, prioritaet)")
logger.info("Migration: litter_waitlist bereit.")
except Exception as e:
logger.warning(f"Migration litter_waitlist: {e}")
try:
conn.execute("ALTER TABLE litters ADD COLUMN wurf_rang TEXT")
except Exception:
pass
try:
conn.execute("ALTER TABLE litters ADD COLUMN wurf_name TEXT")
except Exception:
pass
# route_dogs: bestehende Routen allen Hunden des Users zuweisen
try:
existing = conn.execute("SELECT COUNT(*) FROM route_dogs").fetchone()[0]

View file

@ -156,17 +156,15 @@ app.add_middleware(_AppVersionMiddleware)
class _CacheControlMiddleware(BaseHTTPMiddleware):
"""Setzt Cache-Control-Header für statische Assets.
CSS/JS: no-cache (ETag-Validierung) iOS cached sonst ewig ohne Ablaufdatum.
Versioned Assets (?v=): immutable URL ändert sich bei Updates.
JS/CSS: immer no-cache SW übernimmt Caching. Immutable wäre gefährlich,
weil Browser-HTTP-Cache nach force-update nicht geleert wird und veraltete
app.js mit falschem APP_VER eine Update-Dauerschleife verursacht.
"""
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
path = request.url.path
if path.startswith(("/css/", "/js/", "/icons/phosphor.svg")):
if "v=" in str(request.url.query):
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
else:
response.headers["Cache-Control"] = "no-cache, must-revalidate"
response.headers["Cache-Control"] = "no-cache, must-revalidate"
return response
app.add_middleware(_CacheControlMiddleware)
@ -235,6 +233,7 @@ from routes.moderation import router as moderation_router
from routes.notes import router as notes_router
from routes.breeder import router as breeder_router
from routes.litters import router as litters_router
from routes.laeufi import router as laeufi_router
from routes.breeder_photos import router as breeder_photos_router
from routes.zucht_hunde import router as zucht_hunde_router
from routes.breeder_export import router as breeder_export_router
@ -281,6 +280,7 @@ app.include_router(chat_router, prefix="/api/chat", tags=["Chat"])
app.include_router(admin_router, prefix="/api/admin", tags=["Admin"])
app.include_router(breeder_router, prefix="/api", tags=["Züchter"])
app.include_router(litters_router, prefix="/api", tags=["Würfe"])
app.include_router(laeufi_router, prefix="/api", tags=["Läufigkeit"])
app.include_router(breeder_photos_router, prefix="/api", tags=["Züchter-Fotos"])
app.include_router(zucht_hunde_router, prefix="/api", tags=["Zuchtkartei"])
app.include_router(breeder_export_router, prefix="/api", tags=["Export"])
@ -406,7 +406,7 @@ async def serve_media(path: str, request: _Request):
raise _HE(404, "Nicht gefunden.")
return _media_response(filepath)
APP_VER = "885" # muss mit APP_VER in app.js übereinstimmen
APP_VER = "918" # muss mit APP_VER in app.js übereinstimmen
@app.get("/.well-known/assetlinks.json")
async def assetlinks():
@ -523,6 +523,11 @@ async def info_page():
return FileResponse(f"{STATIC_DIR}/landing.html", headers={"Cache-Control": "max-age=3600"})
@app.get("/zuechter")
async def zuechter_landing():
return FileResponse(f"{STATIC_DIR}/zuechter.html", headers={"Cache-Control": "max-age=3600"})
# ------------------------------------------------------------------
# SEO: Server-gerenderete Wiki-Rassen-Übersicht /wiki/rassen
# ------------------------------------------------------------------

View file

@ -54,15 +54,25 @@ async def breeder_status(user=Depends(get_current_user)):
profile = None
if row["rolle"] in ("breeder", "admin"):
profile = conn.execute(
"SELECT zwingername, rasse_text, verein, vdh_mitglied, stadt, website, beschreibung, verified_at "
"SELECT id, zwingername, rasse_text, verein, vdh_mitglied, stadt, website, beschreibung, verified_at "
"FROM breeder_profiles WHERE user_id=?",
(user["id"],)
).fetchone()
return {
if profile:
logo = conn.execute(
"""SELECT file_path FROM breeder_photos
WHERE breeder_id=? AND entity_type='breeder'
ORDER BY is_primary DESC, id LIMIT 1""",
(profile["id"],)
).fetchone()
result = {
"rolle": row["rolle"],
"breeder_status": row["breeder_status"],
"profile": dict(profile) if profile else None,
}
if profile:
result["profile"]["logo_url"] = f"/media/{logo['file_path']}" if logo else None
return result
# ------------------------------------------------------------------
@ -301,7 +311,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}")
async def breeder_public_profile(zwingername: str):
@ -315,12 +325,114 @@ async def breeder_public_profile(zwingername: str):
FROM breeder_profiles bp
JOIN users u ON u.id = bp.user_id
WHERE LOWER(bp.zwingername) = LOWER(?)
AND u.rolle = 'breeder'
AND u.breeder_status = 'approved'
AND u.rolle IN ('breeder', 'admin')
AND (u.breeder_status = 'approved' OR u.rolle = 'admin')
""", (zwingername,)).fetchone()
if not row:
raise HTTPException(404, "Züchter nicht gefunden.")
return dict(row)
if not row:
raise HTTPException(404, "Züchter nicht gefunden.")
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]
# Logo = primäres Bild der entity_type='breeder' Fotos
logo = conn.execute("""
SELECT file_path FROM breeder_photos
WHERE breeder_id=? AND entity_type='breeder' AND is_primary=1
LIMIT 1
""", (breeder_id,)).fetchone()
if not logo:
logo = conn.execute("""
SELECT file_path FROM breeder_photos
WHERE breeder_id=? AND entity_type='breeder'
ORDER BY sort_order, id LIMIT 1
""", (breeder_id,)).fetchone()
result["logo_url"] = f"/media/{logo['file_path']}" if logo else None
# Öffentliche Fotos für die Gallery (alle entity_type='breeder', max. 12)
photos = conn.execute("""
SELECT file_path, thumbnail_path, caption, is_primary FROM breeder_photos
WHERE breeder_id=? AND entity_type='breeder' AND visibility IN ('public','inquiry')
ORDER BY is_primary DESC, sort_order, id LIMIT 12
""", (breeder_id,)).fetchall()
result["fotos"] = [{
"url": f"/media/{p['file_path']}",
"thumb": f"/media/{p['thumbnail_path']}" if p['thumbnail_path'] else f"/media/{p['file_path']}",
"caption": p["caption"] or "",
"primary": bool(p["is_primary"]),
} for p in photos]
return result
# ------------------------------------------------------------------

307
backend/routes/laeufi.py Normal file
View file

@ -0,0 +1,307 @@
"""BAN YARO — Läufigkeit, Progesterontests & Trächtigkeit (Züchter)"""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from typing import Optional
from datetime import date, timedelta
from database import db
from auth import get_current_user
router = APIRouter()
def _require_breeder(user=Depends(get_current_user)):
if user["rolle"] not in ("breeder", "admin"):
raise HTTPException(403, "Nur für verifizierte Züchter.")
return user
def _check_hund_owner(hund_id: int, user: dict, conn):
row = conn.execute(
"""SELECT zh.id, bp.user_id AS owner_user_id FROM zucht_hunde zh
JOIN breeder_profiles bp ON bp.id = zh.breeder_id
WHERE zh.id=?""", (hund_id,)
).fetchone()
if not row:
raise HTTPException(404, "Hund nicht gefunden.")
if user["rolle"] != "admin" and row["owner_user_id"] != user["id"]:
raise HTTPException(403, "Kein Zugriff.")
return row
def _check_laeufi_owner(laeufi_id: int, user: dict, conn):
row = conn.execute(
"""SELECT l.id, bp.user_id AS owner_user_id FROM laeufi_log l
JOIN zucht_hunde zh ON zh.id = l.hund_id
JOIN breeder_profiles bp ON bp.id = zh.breeder_id
WHERE l.id=?""", (laeufi_id,)
).fetchone()
if not row:
raise HTTPException(404, "Läufigkeit nicht gefunden.")
if user["rolle"] != "admin" and row["owner_user_id"] != user["id"]:
raise HTTPException(403, "Kein Zugriff.")
return row
# ------------------------------------------------------------------
# Meilensteine berechnen
# ------------------------------------------------------------------
_MEILENSTEINE = [
(21, "Frühester Ultraschall möglich"),
(25, "Ultraschall empfohlen (Welpen erkennbar)"),
(35, "Bauch wird sichtbar"),
(45, "Röntgen möglich (Skelettanzahl)"),
(56, "Wurfbox aufstellen"),
(58, "Tägliche Temperaturmessung beginnen"),
(63, "Erwarteter Geburtstermin"),
(65, "Tierarzt konsultieren wenn keine Geburt"),
]
def _calc_meilensteine(deckdatum_str: str) -> list:
try:
deck = date.fromisoformat(deckdatum_str)
except Exception:
return []
return [
{
"tag": tag,
"datum": (deck + timedelta(days=tag)).isoformat(),
"label": label,
"vorbei": (deck + timedelta(days=tag)) < date.today(),
}
for tag, label in _MEILENSTEINE
]
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class LaeufiCreate(BaseModel):
beginn: str
ende: Optional[str] = None
notiz: Optional[str] = None
class LaeufiUpdate(BaseModel):
beginn: Optional[str] = None
ende: Optional[str] = None
notiz: Optional[str] = None
class ProgestCreate(BaseModel):
datum: str
wert: Optional[float] = None
einheit: str = "ng/ml"
labor: Optional[str] = None
notiz: Optional[str] = None
class ProgestUpdate(BaseModel):
datum: Optional[str] = None
wert: Optional[float] = None
einheit: Optional[str] = None
labor: Optional[str] = None
notiz: Optional[str] = None
class DeckCreate(BaseModel):
deckdatum: str
laeufi_id: Optional[int] = None
ruede_id: Optional[int] = None
ruede_name: Optional[str] = None
deckart: str = "natuerlich"
traechtig: int = 0
ultraschall_datum: Optional[str] = None
notiz: Optional[str] = None
class DeckUpdate(BaseModel):
deckdatum: Optional[str] = None
ruede_id: Optional[int] = None
ruede_name: Optional[str] = None
deckart: Optional[str] = None
traechtig: Optional[int] = None
ultraschall_datum: Optional[str] = None
notiz: Optional[str] = None
# ------------------------------------------------------------------
# Läufigkeit
# ------------------------------------------------------------------
@router.get("/laeufi/{hund_id}")
async def list_laeufi(hund_id: int, user=Depends(_require_breeder)):
with db() as conn:
_check_hund_owner(hund_id, user, conn)
rows = conn.execute(
"SELECT * FROM laeufi_log WHERE hund_id=? ORDER BY beginn DESC",
(hund_id,)
).fetchall()
return [dict(r) for r in rows]
@router.post("/laeufi/{hund_id}", status_code=201)
async def add_laeufi(hund_id: int, body: LaeufiCreate, user=Depends(_require_breeder)):
with db() as conn:
_check_hund_owner(hund_id, user, conn)
cur = conn.execute(
"INSERT INTO laeufi_log (hund_id, beginn, ende, notiz) VALUES (?,?,?,?)",
(hund_id, body.beginn, body.ende, body.notiz)
)
row = conn.execute("SELECT * FROM laeufi_log WHERE id=?", (cur.lastrowid,)).fetchone()
return dict(row)
@router.put("/laeufi/entry/{laeufi_id}")
async def update_laeufi(laeufi_id: int, body: LaeufiUpdate, user=Depends(_require_breeder)):
with db() as conn:
_check_laeufi_owner(laeufi_id, user, conn)
fields = {k: v for k, v in body.model_dump().items() if v is not None}
if fields:
sets = ", ".join(f"{k}=?" for k in fields)
conn.execute(f"UPDATE laeufi_log SET {sets} WHERE id=?", (*fields.values(), laeufi_id))
row = conn.execute("SELECT * FROM laeufi_log WHERE id=?", (laeufi_id,)).fetchone()
return dict(row)
@router.delete("/laeufi/entry/{laeufi_id}", status_code=204)
async def delete_laeufi(laeufi_id: int, user=Depends(_require_breeder)):
with db() as conn:
_check_laeufi_owner(laeufi_id, user, conn)
conn.execute("DELETE FROM laeufi_log WHERE id=?", (laeufi_id,))
# ------------------------------------------------------------------
# Progesterontests
# ------------------------------------------------------------------
@router.get("/laeufi/entry/{laeufi_id}/prog")
async def list_prog(laeufi_id: int, user=Depends(_require_breeder)):
with db() as conn:
_check_laeufi_owner(laeufi_id, user, conn)
rows = conn.execute(
"SELECT * FROM progesteron_tests WHERE laeufi_id=? ORDER BY datum",
(laeufi_id,)
).fetchall()
return [dict(r) for r in rows]
@router.post("/laeufi/entry/{laeufi_id}/prog", status_code=201)
async def add_prog(laeufi_id: int, body: ProgestCreate, user=Depends(_require_breeder)):
with db() as conn:
_check_laeufi_owner(laeufi_id, user, conn)
cur = conn.execute(
"INSERT INTO progesteron_tests (laeufi_id, datum, wert, einheit, labor, notiz) VALUES (?,?,?,?,?,?)",
(laeufi_id, body.datum, body.wert, body.einheit, body.labor, body.notiz)
)
row = conn.execute("SELECT * FROM progesteron_tests WHERE id=?", (cur.lastrowid,)).fetchone()
return dict(row)
@router.put("/laeufi/prog/{prog_id}")
async def update_prog(prog_id: int, body: ProgestUpdate, user=Depends(_require_breeder)):
with db() as conn:
pt = conn.execute(
"""SELECT pt.id, bp.user_id AS owner_user_id FROM progesteron_tests pt
JOIN laeufi_log l ON l.id = pt.laeufi_id
JOIN zucht_hunde zh ON zh.id = l.hund_id
JOIN breeder_profiles bp ON bp.id = zh.breeder_id
WHERE pt.id=?""", (prog_id,)
).fetchone()
if not pt:
raise HTTPException(404)
if user["rolle"] != "admin" and pt["owner_user_id"] != user["id"]:
raise HTTPException(403)
fields = {k: v for k, v in body.model_dump().items() if v is not None}
if fields:
sets = ", ".join(f"{k}=?" for k in fields)
conn.execute(f"UPDATE progesteron_tests SET {sets} WHERE id=?", (*fields.values(), prog_id))
row = conn.execute("SELECT * FROM progesteron_tests WHERE id=?", (prog_id,)).fetchone()
return dict(row)
@router.delete("/laeufi/prog/{prog_id}", status_code=204)
async def delete_prog(prog_id: int, user=Depends(_require_breeder)):
with db() as conn:
pt = conn.execute(
"""SELECT pt.id, bp.user_id AS owner_user_id FROM progesteron_tests pt
JOIN laeufi_log l ON l.id = pt.laeufi_id
JOIN zucht_hunde zh ON zh.id = l.hund_id
JOIN breeder_profiles bp ON bp.id = zh.breeder_id
WHERE pt.id=?""", (prog_id,)
).fetchone()
if not pt:
raise HTTPException(404)
if user["rolle"] != "admin" and pt["owner_user_id"] != user["id"]:
raise HTTPException(403)
conn.execute("DELETE FROM progesteron_tests WHERE id=?", (prog_id,))
# ------------------------------------------------------------------
# Deckdaten & Trächtigkeit
# ------------------------------------------------------------------
@router.get("/laeufi/deck/{hund_id}")
async def list_deck(hund_id: int, user=Depends(_require_breeder)):
with db() as conn:
_check_hund_owner(hund_id, user, conn)
rows = conn.execute(
"SELECT * FROM deckdaten WHERE hund_id=? ORDER BY deckdatum DESC",
(hund_id,)
).fetchall()
result = []
for r in rows:
d = dict(r)
d["meilensteine"] = _calc_meilensteine(d["deckdatum"])
result.append(d)
return result
@router.post("/laeufi/deck/{hund_id}", status_code=201)
async def add_deck(hund_id: int, body: DeckCreate, user=Depends(_require_breeder)):
with db() as conn:
_check_hund_owner(hund_id, user, conn)
cur = conn.execute(
"""INSERT INTO deckdaten
(hund_id, laeufi_id, deckdatum, ruede_id, ruede_name, deckart,
traechtig, ultraschall_datum, notiz)
VALUES (?,?,?,?,?,?,?,?,?)""",
(hund_id, body.laeufi_id, body.deckdatum, body.ruede_id, body.ruede_name,
body.deckart, body.traechtig, body.ultraschall_datum, body.notiz)
)
row = conn.execute("SELECT * FROM deckdaten WHERE id=?", (cur.lastrowid,)).fetchone()
d = dict(row)
d["meilensteine"] = _calc_meilensteine(d["deckdatum"])
return d
@router.put("/laeufi/deck/entry/{deck_id}")
async def update_deck(deck_id: int, body: DeckUpdate, user=Depends(_require_breeder)):
with db() as conn:
dk = conn.execute(
"""SELECT d.id, bp.user_id AS owner_user_id FROM deckdaten d
JOIN zucht_hunde zh ON zh.id = d.hund_id
JOIN breeder_profiles bp ON bp.id = zh.breeder_id
WHERE d.id=?""", (deck_id,)
).fetchone()
if not dk:
raise HTTPException(404)
if user["rolle"] != "admin" and dk["owner_user_id"] != user["id"]:
raise HTTPException(403)
fields = {k: v for k, v in body.model_dump().items() if v is not None}
if fields:
sets = ", ".join(f"{k}=?" for k in fields)
conn.execute(f"UPDATE deckdaten SET {sets} WHERE id=?", (*fields.values(), deck_id))
row = conn.execute("SELECT * FROM deckdaten WHERE id=?", (deck_id,)).fetchone()
d = dict(row)
d["meilensteine"] = _calc_meilensteine(d["deckdatum"])
return d
@router.delete("/laeufi/deck/entry/{deck_id}", status_code=204)
async def delete_deck(deck_id: int, user=Depends(_require_breeder)):
with db() as conn:
dk = conn.execute(
"""SELECT d.id, bp.user_id AS owner_user_id FROM deckdaten d
JOIN zucht_hunde zh ON zh.id = d.hund_id
JOIN breeder_profiles bp ON bp.id = zh.breeder_id
WHERE d.id=?""", (deck_id,)
).fetchone()
if not dk:
raise HTTPException(404)
if user["rolle"] != "admin" and dk["owner_user_id"] != user["id"]:
raise HTTPException(403)
conn.execute("DELETE FROM deckdaten WHERE id=?", (deck_id,))

View file

@ -27,23 +27,27 @@ def _require_breeder(user=Depends(get_current_user)):
# Schemas
# ------------------------------------------------------------------
class LitterCreate(BaseModel):
wurf_rang: Optional[str] = None # A, B, C …
wurf_name: Optional[str] = None # z.B. "Vatertags-Wurf"
vater_name: Optional[str] = None
mutter_name: Optional[str] = None
vater_id: Optional[int] = None # FK zucht_hunde
mutter_id: Optional[int] = None # FK zucht_hunde
geburt_datum: Optional[str] = None # YYYY-MM-DD
erwartetes_datum: Optional[str] = None # YYYY-MM-DD
vater_id: Optional[int] = None
mutter_id: Optional[int] = None
geburt_datum: Optional[str] = None
erwartetes_datum: Optional[str] = None
welpen_gesamt: Optional[int] = None
welpen_verfuegbar: Optional[int] = None
beschreibung: Optional[str] = None
gesundheitstests: Optional[str] = None
preis_spanne: Optional[str] = None
status: str = "geplant" # geplant|geboren|verfuegbar|abgeschlossen
status: str = "geplant"
sichtbar: int = 0
sichtbar_bis: Optional[str] = None
class LitterUpdate(BaseModel):
wurf_rang: Optional[str] = None
wurf_name: Optional[str] = None
vater_name: Optional[str] = None
mutter_name: Optional[str] = None
vater_id: Optional[int] = None
@ -189,13 +193,16 @@ async def create_litter(body: LitterCreate, user=Depends(_require_breeder)):
cur = conn.execute(
"""INSERT INTO litters
(breeder_id, vater_name, mutter_name, vater_id, mutter_id,
(breeder_id, wurf_rang, wurf_name,
vater_name, mutter_name, vater_id, mutter_id,
geburt_datum, erwartetes_datum,
welpen_gesamt, welpen_verfuegbar, beschreibung, gesundheitstests,
preis_spanne, status, sichtbar, sichtbar_bis)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
profile["id"],
body.wurf_rang,
body.wurf_name,
body.vater_name,
body.mutter_name,
body.vater_id,
@ -650,3 +657,98 @@ async def generate_contract(
</html>"""
return HTMLResponse(content=html)
# ------------------------------------------------------------------
# Warteliste
# ------------------------------------------------------------------
class WaitlistEntry(BaseModel):
name: str
email: Optional[str] = None
telefon: Optional[str] = None
nachricht: Optional[str] = None
wunsch_geschlecht: str = "egal"
wunsch_farbe: Optional[str] = None
prioritaet: int = 0
status: str = "anfrage"
notiz: Optional[str] = None
class WaitlistUpdate(BaseModel):
name: Optional[str] = None
email: Optional[str] = None
telefon: Optional[str] = None
nachricht: Optional[str] = None
wunsch_geschlecht: Optional[str] = None
wunsch_farbe: Optional[str] = None
prioritaet: Optional[int] = None
status: Optional[str] = None
notiz: Optional[str] = None
@router.get("/litters/{litter_id}/waitlist")
async def get_waitlist(litter_id: int, user=Depends(_require_breeder)):
with db() as conn:
_check_litter_owner(litter_id, user, conn)
rows = conn.execute(
"SELECT * FROM litter_waitlist WHERE litter_id=? ORDER BY prioritaet, created_at",
(litter_id,)
).fetchall()
return [dict(r) for r in rows]
@router.post("/litters/{litter_id}/waitlist", status_code=201)
async def add_waitlist_entry(litter_id: int, body: WaitlistEntry, user=Depends(_require_breeder)):
with db() as conn:
_check_litter_owner(litter_id, user, conn)
cur = conn.execute(
"""INSERT INTO litter_waitlist
(litter_id, name, email, telefon, nachricht, wunsch_geschlecht, wunsch_farbe,
prioritaet, status, notiz)
VALUES (?,?,?,?,?,?,?,?,?,?)""",
(litter_id, body.name, body.email, body.telefon, body.nachricht,
body.wunsch_geschlecht, body.wunsch_farbe, body.prioritaet, body.status, body.notiz)
)
row = conn.execute("SELECT * FROM litter_waitlist WHERE id=?", (cur.lastrowid,)).fetchone()
return dict(row)
@router.put("/litters/waitlist/{entry_id}")
async def update_waitlist_entry(entry_id: int, body: WaitlistUpdate, user=Depends(_require_breeder)):
with db() as conn:
entry = conn.execute(
"""SELECT w.*, bp.user_id AS owner_user_id FROM litter_waitlist w
JOIN litters l ON l.id = w.litter_id
JOIN breeder_profiles bp ON bp.id = l.breeder_id
WHERE w.id=?""",
(entry_id,)
).fetchone()
if not entry:
raise HTTPException(404, "Eintrag nicht gefunden.")
if user["rolle"] != "admin" and entry["owner_user_id"] != user["id"]:
raise HTTPException(403, "Kein Zugriff.")
fields = {k: v for k, v in body.model_dump().items() if v is not None}
if not fields:
return dict(entry)
sets = ", ".join(f"{k}=?" for k in fields)
conn.execute(f"UPDATE litter_waitlist SET {sets} WHERE id=?", (*fields.values(), entry_id))
row = conn.execute("SELECT * FROM litter_waitlist WHERE id=?", (entry_id,)).fetchone()
return dict(row)
@router.delete("/litters/waitlist/{entry_id}", status_code=204)
async def delete_waitlist_entry(entry_id: int, user=Depends(_require_breeder)):
with db() as conn:
entry = conn.execute(
"""SELECT w.id, bp.user_id AS owner_user_id FROM litter_waitlist w
JOIN litters l ON l.id = w.litter_id
JOIN breeder_profiles bp ON bp.id = l.breeder_id
WHERE w.id=?""",
(entry_id,)
).fetchone()
if not entry:
raise HTTPException(404, "Eintrag nicht gefunden.")
if user["rolle"] != "admin" and entry["owner_user_id"] != user["id"]:
raise HTTPException(403, "Kein Zugriff.")
conn.execute("DELETE FROM litter_waitlist WHERE id=?", (entry_id,))

View file

@ -6977,18 +6977,10 @@ svg.empty-state-icon {
.wb-cards {
display: grid;
grid-template-columns: 1fr;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: var(--space-4);
}
@media (min-width: 768px) {
.wb-cards { grid-template-columns: repeat(2, 1fr); }
}
@media (min-width: 1200px) {
.wb-cards { grid-template-columns: repeat(3, 1fr); }
}
.wb-card {
background: var(--c-surface);
border: 1px solid var(--c-border-light);

View file

@ -101,9 +101,9 @@
</script>
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
<link rel="stylesheet" href="/css/design-system.css?v=885">
<link rel="stylesheet" href="/css/layout.css?v=885">
<link rel="stylesheet" href="/css/components.css?v=885">
<link rel="stylesheet" href="/css/design-system.css?v=907">
<link rel="stylesheet" href="/css/layout.css?v=907">
<link rel="stylesheet" href="/css/components.css?v=907">
</head>
<body>
@ -248,13 +248,25 @@
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg> Erste Hilfe
</div>
<div class="sidebar-item" data-page="zuchthunde" id="sidebar-zuchthunde"
style="display:none;color:var(--c-primary,#7c3aed)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#tree-structure"></use></svg> Zuchtkartei
</div>
<div class="sidebar-item" data-page="litters" id="sidebar-litters"
style="display:none;color:var(--c-primary,#7c3aed)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#certificate"></use></svg> Wurfverwaltung
<div id="sidebar-breeder-section" style="display:none">
<div style="padding:var(--space-3) var(--space-3) var(--space-1);font-size:10px;font-weight:700;
text-transform:uppercase;letter-spacing:.1em;color:var(--c-primary,#C4843A);
opacity:.8;display:flex;align-items:center;gap:6px">
<svg class="ph-icon" style="width:12px;height:12px" aria-hidden="true"><use href="/icons/phosphor.svg#lock-key"></use></svg>
Züchter
</div>
<div class="sidebar-item" data-page="zuchthunde" id="sidebar-zuchthunde"
style="color:var(--c-primary,#7c3aed)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#tree-structure"></use></svg> Zuchtkartei
</div>
<div class="sidebar-item" data-page="litters" id="sidebar-litters"
style="color:var(--c-primary,#7c3aed)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#certificate"></use></svg> Wurfverwaltung
</div>
<div class="sidebar-item" data-page="laeufi" id="sidebar-laeufi"
style="color:var(--c-primary,#7c3aed)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#thermometer"></use></svg> Läufigkeit
</div>
</div>
<div class="sidebar-item" data-page="social" id="sidebar-social"
@ -426,17 +438,21 @@
</section>
<section class="page" id="page-zuchthunde">
<div class="page-body page-container"></div>
<div class="page-body page-container-wide"></div>
</section>
<section class="page" id="page-zucht-profil">
<div class="page-body page-container"></div>
<div class="page-body page-container-wide"></div>
</section>
<section class="page" id="page-litters">
<div class="page-body page-container"></div>
<div class="page-body page-container-wide"></div>
</section>
<section class="page" id="page-laeufi">
<div class="page-body page-container-wide"></div>
</section>
<section class="page" id="page-wurfboerse">
<div class="page-body page-container"></div>
<div class="page-body page-container-wide"></div>
</section>
<section class="page" id="page-breeder">
@ -583,10 +599,10 @@
<div id="modal-container"></div>
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
<script src="/js/api.js?v=885"></script>
<script src="/js/ui.js?v=885"></script>
<script src="/js/app.js?v=885"></script>
<script src="/js/worlds.js?v=885"></script>
<script src="/js/api.js?v=918"></script>
<script src="/js/ui.js?v=918"></script>
<script src="/js/app.js?v=918"></script>
<script src="/js/worlds.js?v=918"></script>
<!-- Feature-Seiten werden lazy geladen -->
@ -637,11 +653,8 @@
if (!sw) return;
sw.addEventListener('statechange', () => {
if (sw.state === 'activated') {
// Kein zweiter Reload nach force-update
if (sessionStorage.getItem('by_skip_sw_reload')) {
sessionStorage.removeItem('by_skip_sw_reload');
return;
}
// Flag nur prüfen, nicht konsumieren — controllerchange konsumiert ihn
if (sessionStorage.getItem('by_skip_sw_reload')) return;
window.location.replace('/?_t=' + Date.now());
}
});
@ -663,9 +676,17 @@
});
// Backup: controllerchange (falls updatefound nicht feuert)
navigator.serviceWorker.addEventListener('controllerchange', () => {
window.location.replace('/?_t=' + Date.now());
});
// NICHT registrieren wenn diese Seite selbst durch einen SW-Reload entstand (_t= im URL)
// — verhindert Dauerschleife wenn clients.claim() erst nach Seitenstart feuert
if (!location.search.includes('_t=')) {
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (sessionStorage.getItem('by_skip_sw_reload')) {
sessionStorage.removeItem('by_skip_sw_reload');
return;
}
window.location.replace('/?_t=' + Date.now());
});
}
navigator.serviceWorker.addEventListener('message', e => {
if (e.data?.type === 'QUEUE_PROCESSED') {

View file

@ -709,11 +709,36 @@ const API = (() => {
addPuppy(id, data) { return post(`/litters/${id}/puppies`, data); },
updatePuppy(id, data) { return put(`/litters/puppies/${id}`, data); },
addWeight(id, data) { return post(`/litters/puppies/${id}/weight`, data); },
// Warteliste
waitlist(id) { return get(`/litters/${id}/waitlist`); },
addWaitlist(id, data) { return post(`/litters/${id}/waitlist`, data); },
updateWaitlist(entryId, data) { return put(`/litters/waitlist/${entryId}`, data); },
removeWaitlist(entryId) { return del(`/litters/waitlist/${entryId}`); },
// Öffentlich
public(params) { return get('/litters?' + new URLSearchParams(params || {}).toString()); },
detail(id) { return get(`/litters/${id}`); },
};
// ----------------------------------------------------------
// LÄUFIGKEIT & TRÄCHTIGKEIT
// ----------------------------------------------------------
const laeufi = {
list(hundId) { return get(`/laeufi/${hundId}`); },
add(hundId, data) { return post(`/laeufi/${hundId}`, data); },
update(id, data) { return put(`/laeufi/entry/${id}`, data); },
remove(id) { return del(`/laeufi/entry/${id}`); },
// Progesterontests
listProg(laeufiId) { return get(`/laeufi/entry/${laeufiId}/prog`); },
addProg(laeufiId, data) { return post(`/laeufi/entry/${laeufiId}/prog`, data); },
updateProg(id, data) { return put(`/laeufi/prog/${id}`, data); },
removeProg(id) { return del(`/laeufi/prog/${id}`); },
// Deckdaten
listDeck(hundId) { return get(`/laeufi/deck/${hundId}`); },
addDeck(hundId, data) { return post(`/laeufi/deck/${hundId}`, data); },
updateDeck(id, data) { return put(`/laeufi/deck/entry/${id}`, data); },
removeDeck(id) { return del(`/laeufi/deck/entry/${id}`); },
};
// ----------------------------------------------------------
// ZÜCHTER-FOTOS
// ----------------------------------------------------------
@ -777,7 +802,7 @@ const API = (() => {
auth, dogs, diary, health, tieraerzte, healthDocs, poison,
places, routes, walks, events, sitting, forum, lost, knigge, weather, push,
friends, chat, webcal, importData, sharing, widget, notifications, services, ratings, sittingAccess, training, notes,
breeder, litters, breederPhotos, zuchthunde, zuchtKi,
breeder, litters, breederPhotos, zuchthunde, zuchtKi, laeufi,
osm,
subscribeToPush, getLocation, clientNow,
APIError,

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '885'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '918'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.5.1'; // ← semantische Version, wird bei make release gesetzt
const IS_STAGING = location.hostname === 'staging.banyaro.app';
// Cache-Bust-Parameter nach Update-Reload sofort entfernen
@ -70,6 +70,7 @@ const App = (() => {
litters: { title: 'Wurfverwaltung', module: null, requiresAuth: true },
wurfboerse: { title: 'Wurfbörse', module: null },
zuchthunde: { title: 'Zuchtkartei', module: null, requiresAuth: true },
laeufi: { title: 'Läufigkeit', module: null, requiresAuth: true },
'zucht-profil': { title: 'Hunde-Profil', module: null },
gruender: { title: '100 Gründer', module: null },
jobs: { title: 'Wir suchen dich', module: null },
@ -133,8 +134,15 @@ const App = (() => {
}
if (window.Worlds?._visible) window.Worlds.hide();
// destroy() der aktuellen Seite aufrufen (z.B. FABs aufräumen)
const activePage = document.querySelector('.page.active');
if (activePage) {
const activeId = activePage.id?.replace('page-', '');
if (activeId && pages[activeId]?.module?.destroy) pages[activeId].module.destroy();
}
// Aktive Seite ausblenden
document.querySelector('.page.active')?.classList.remove('active');
activePage?.classList.remove('active');
document.querySelectorAll('.nav-item.active, .sidebar-item.active')
.forEach(el => el.classList.remove('active'));
@ -545,10 +553,8 @@ const App = (() => {
moderationItem.style.display = isMod ? '' : 'none';
}
const isBreeder = state.user.rolle === 'breeder' || state.user.rolle === 'admin';
const littersItem = document.getElementById('sidebar-litters');
if (littersItem) littersItem.style.display = isBreeder ? '' : 'none';
const zuchthundeItem = document.getElementById('sidebar-zuchthunde');
if (zuchthundeItem) zuchthundeItem.style.display = isBreeder ? '' : 'none';
const breederSection = document.getElementById('sidebar-breeder-section');
if (breederSection) breederSection.style.display = isBreeder ? '' : 'none';
const socialItem = document.getElementById('sidebar-social');
if (socialItem) {
const isSocial = state.user.is_social_media || state.user.rolle === 'admin';

View file

@ -1,6 +1,5 @@
/* ============================================================
BAN YARO Öffentliches Züchter-Profil
Seiten-Modul: Zeigt das verifizierte Profil eines Züchters.
BAN YARO Öffentliches Züchter-Profil (Visitenkarte)
============================================================ */
window.Page_breeder = (() => {
@ -8,6 +7,9 @@ window.Page_breeder = (() => {
let _container = null;
let _appState = null;
const _esc = s => UI.esc ? UI.esc(s) : String(s ?? '').replace(/[&<>"']/g,
c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
// ----------------------------------------------------------
// INIT
// ----------------------------------------------------------
@ -15,7 +17,6 @@ window.Page_breeder = (() => {
_container = container;
_appState = appState;
// Zwingername aus params oder URL-Pfad (/breeder/vom-sonnenfeld)
const zwingername = params?.zwingername
|| decodeURIComponent((window.location.pathname.split('/breeder/')[1] || '').replace(/\/$/, ''));
@ -24,13 +25,34 @@ window.Page_breeder = (() => {
return;
}
container.innerHTML = '<div style="padding:var(--space-6);text-align:center">Lade…</div>';
container.innerHTML = `
<div id="breeder-profile-body" style="padding-bottom:calc(var(--space-16) + 24px)">
${UI.skeleton(5)}
</div>`;
// FAB an document.body hängen damit position:fixed zuverlässig funktioniert
// und destroy() der einzige Lifecycle-Kontrollpunkt bleibt
const fab = document.createElement('button');
fab.id = 'breeder-back-fab';
fab.setAttribute('aria-label', 'Zurück zur Wurfbörse');
fab.style.cssText = 'position:fixed;bottom:calc(var(--safe-bottom,0px) + 20px);right:20px;' +
'width:54px;height:54px;border-radius:50%;background:var(--c-primary);' +
'border:none;color:#fff;cursor:pointer;z-index:200;' +
'display:flex;align-items:center;justify-content:center;' +
'box-shadow:0 4px 18px rgba(196,132,58,.45);transition:transform .12s,box-shadow .12s;' +
'-webkit-tap-highlight-color:transparent';
fab.innerHTML = '<svg style="width:22px;height:22px" viewBox="0 0 256 256"><use href="/icons/phosphor.svg#arrow-left"></use></svg>';
fab.addEventListener('click', () => App.navigate('wurfboerse'));
document.body.appendChild(fab);
try {
const p = await API.breeder.profile(zwingername);
_render(p);
} catch (e) {
container.innerHTML = `<div style="padding:var(--space-6)">${_esc(e.message || 'Züchter nicht gefunden.')}</div>`;
document.getElementById('breeder-profile-body').innerHTML =
`<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>`;
}
}
@ -38,173 +60,353 @@ window.Page_breeder = (() => {
// RENDER
// ----------------------------------------------------------
function _render(p) {
const verifiedDate = p.verified_at
? new Date(p.verified_at).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
const body = document.getElementById('breeder-profile-body') || _container;
const seit = p.verified_at
? new Date(p.verified_at).toLocaleDateString('de-DE', { month: 'long', year: 'numeric' })
: null;
const websiteHtml = p.website
? `<a href="${_esc(p.website)}" target="_blank" rel="noopener noreferrer"
style="color:var(--c-primary);text-decoration:none;word-break:break-all">
${UI.icon('arrow-square-out')} ${_esc(p.website)}
</a>`
: '';
const isLoggedIn = !!_appState?.user;
const isOwnProfile = _appState?.user?.id === p.zuechter_user_id;
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>`
: '';
_container.innerHTML = `
<div style="padding:var(--space-4)">
<!-- Header-Card -->
<div class="card" style="margin-bottom:var(--space-3)">
body.innerHTML = `
<!-- HERO -->
<div style="background:linear-gradient(135deg,var(--c-primary-dark,#a86e2e),var(--c-primary,#C4843A));
padding:var(--space-6) var(--space-4) var(--space-8);color:white;position:relative">
<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="flex:1;min-width:0">
<h2 style="margin:0 0 var(--space-1);font-size:var(--text-xl);word-break:break-word">
${UI.icon('certificate')} ${_esc(p.zwingername)}
</h2>
<span class="badge badge-primary" style="background:var(--c-success,#22C55E);color:#fff;font-size:var(--text-xs)">
<p style="margin:0 0 var(--space-1);font-size:var(--text-xs);opacity:.7;text-transform:uppercase;letter-spacing:.1em">
${UI.icon('seal-check')} Verifizierter Züchter
</span>
</p>
<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">
${_esc(p.zwingername)}
</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>
${p.logo_url
? `<img src="${_esc(p.logo_url)}" alt="Zwinger-Logo"
style="width:72px;height:72px;border-radius:50%;object-fit:cover;
border:3px solid rgba(255,255,255,.5);flex-shrink:0;box-shadow:0 2px 12px rgba(0,0,0,.25)"
onerror="this.style.display='none'">`
: `<div style="background:rgba(255,255,255,.15);border-radius:50%;width:64px;height:64px;
display:flex;align-items:center;justify-content:center;flex-shrink:0">
<svg style="width:32px;height:32px" viewBox="0 0 256 256"><use href="/icons/phosphor.svg#paw-print"></use></svg>
</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>
<!-- Details-Card -->
<div class="card" style="margin-bottom:var(--space-3)">
<dl style="margin:0;display:flex;flex-direction:column;gap:var(--space-3)">
<div style="max-width:640px;margin:0 auto;padding:var(--space-4)">
<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">Rasse</dt>
<dd style="margin:0;font-weight:600">${_esc(p.rasse_text || '')}</dd>
</div>
<!-- Beschreibung -->
${p.beschreibung ? `
<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)">
<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">
<dt style="color:var(--c-text-secondary);min-width:100px;font-size:var(--text-sm);flex-shrink:0">Verein</dt>
<dd style="margin:0">${_esc(p.verein || '')}</dd>
</div>
<!-- Zuchthunde -->
${p.hunde?.length ? `
<div style="margin-bottom:var(--space-5)">
<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">
<dt style="color:var(--c-text-secondary);min-width:100px;font-size:var(--text-sm);flex-shrink:0">VDH-Mitglied</dt>
<dd style="margin:0">
${p.vdh_mitglied
? `<span class="badge badge-primary">${UI.icon('check')} Ja</span>`
: `<span style="color:var(--c-text-secondary)">Nein</span>`}
</dd>
</div>
<!-- Aktuelle Würfe -->
${p.wuerfe?.length ? `
<div style="margin-bottom:var(--space-5)">
<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('baby')} Aktuelle Würfe
</h2>
<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">
<dt style="color:var(--c-text-secondary);min-width:100px;font-size:var(--text-sm);flex-shrink:0">Stadt</dt>
<dd style="margin:0">${_esc(p.stadt || '')}</dd>
</div>
<!-- Gesundheits-Transparenz -->
${(p.hd_stats?.length || p.ed_stats?.length) ? `
<div style="margin-bottom:var(--space-5)">
<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 ? `
<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>
<dd style="margin:0">${websiteHtml}</dd>
<dt style="color:var(--c-text-secondary);min-width:110px;font-size:var(--text-sm);flex-shrink:0">Website</dt>
<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>` : ''}
${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>
${seit ? _dl('Züchter seit', seit) : ''}
</dl>
</div>
<!-- Beschreibung -->
${beschreibungHtml}
<!-- Fotos / Gallery -->
${p.fotos?.length ? `
<div style="margin-bottom:var(--space-4)">
<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('images')} Galerie
<span style="font-size:var(--text-xs);color:var(--c-text-muted);font-weight:400">${p.fotos.length} Fotos</span>
</h2>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:var(--space-2)">
${p.fotos.map((ph, i) => `
<a href="${_esc(ph.url)}" target="_blank" rel="noopener noreferrer"
style="display:block;border-radius:var(--radius-md);overflow:hidden;
border:${ph.primary ? '2px solid var(--c-primary)' : '1px solid var(--c-border)'};
aspect-ratio:1;position:relative">
<img src="${_esc(ph.thumb)}" alt="${_esc(ph.caption)}"
loading="${i < 6 ? 'eager' : 'lazy'}"
style="width:100%;height:100%;object-fit:cover;display:block"
onerror="this.parentElement.style.display='none'">
${ph.primary ? `<span style="position:absolute;top:4px;left:4px;background:var(--c-primary);
color:white;font-size:9px;font-weight:700;border-radius:999px;padding:1px 6px">Logo</span>` : ''}
${ph.caption ? `<div style="position:absolute;bottom:0;left:0;right:0;
background:linear-gradient(transparent,rgba(0,0,0,.6));
color:white;font-size:10px;padding:12px 6px 4px;line-height:1.3">${_esc(ph.caption)}</div>` : ''}
</a>`).join('')}
</div>
</div>` : ''}
<!-- Fotos (werden asynchron nachgeladen) -->
<div id="breeder-photos-section"></div>
<div id="breeder-photos-section" style="display:none"></div>
<!-- Kontakt-Button -->
${(() => {
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>`;
</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);
}
// ----------------------------------------------------------
// 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) {
const section = document.getElementById('breeder-photos-section');
if (!section) return;
try {
const photos = await API.breederPhotos.list('breeder', breederId);
if (!photos || !photos.length) return;
if (!photos?.length) return;
section.innerHTML = `
<div class="card" style="margin-bottom:var(--space-3)">
<h3 style="margin:0 0 var(--space-3);font-size:var(--text-md);font-weight:var(--weight-semibold)">
<div style="margin-bottom:var(--space-4)">
<h2 style="margin:0 0 var(--space-3);font-size:var(--text-base);font-weight:700">
${UI.icon('images')} Fotos
</h3>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(100px,1fr));gap:var(--space-2)">
${photos.map(ph => {
const thumb = ph.thumbnail_url || ph.url || '';
return `
<a href="${_esc(ph.url || '')}" target="_blank" rel="noopener noreferrer"
style="display:block;border-radius:var(--radius-md);overflow:hidden;
border:1px solid var(--c-border);aspect-ratio:1">
<img src="${_esc(thumb)}"
alt="${_esc(ph.caption || '')}"
loading="lazy"
style="width:100%;height:100%;object-fit:cover;display:block"
onerror="this.parentElement.style.display='none'">
</a>`;
}).join('')}
</h2>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:var(--space-2)">
${photos.map(ph => `
<a href="${_esc(ph.url||'')}" target="_blank" rel="noopener noreferrer"
style="display:block;border-radius:var(--radius-md);overflow:hidden;
border:1px solid var(--c-border);aspect-ratio:1">
<img src="${_esc(ph.thumbnail_url||ph.url||'')}" alt="${_esc(ph.caption||'')}"
loading="lazy" style="width:100%;height:100%;object-fit:cover;display:block"
onerror="this.parentElement.style.display='none'">
</a>`).join('')}
</div>
</div>`;
} catch (_) {
// Fotos sind nicht kritisch — bei Fehler still ignorieren
}
} catch (_) {}
}
async function _contactBreeder(breederId) {
if (!_appState?.user) {
App.navigate('settings');
return;
}
async function _contactBreeder(userId) {
if (!_appState?.user) { App.navigate('settings'); return; }
try {
await API.chat.start(breederId);
await API.chat.start(userId);
App.navigate('chat');
} catch (e) {
UI.toast.error(e.message || 'Chat konnte nicht geöffnet werden.');
}
} catch (e) { UI.toast.error(e.message || 'Chat konnte nicht geöffnet werden.'); }
}
function refresh() {}
function onDogChange() {}
function destroy() { document.getElementById('breeder-back-fab')?.remove(); }
return { init, refresh, onDogChange };
return { init, refresh, onDogChange, destroy };
})();

View file

@ -0,0 +1,646 @@
/* BAN YARO — Läufigkeit & Trächtigkeit (Züchter) */
window.Page_laeufi = (() => {
let _container, _appState;
let _hunde = [];
let _openHundId = null;
let _breederInfo = null;
// ----------------------------------------------------------
// Init / Refresh
// ----------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
if (!appState.user || !['breeder','admin'].includes(appState.user.rolle)) {
_container.innerHTML = `<div style="text-align:center;padding:var(--space-10)">
<p style="color:var(--c-text-secondary)">Nur für verifizierte Züchter.</p></div>`;
return;
}
API.breeder.status().then(s => {
_breederInfo = s?.profile ? { zwingername: s.profile.zwingername, logo_url: s.profile.logo_url } : null;
const headerEl = _container.querySelector('#breeder-private-header');
if (headerEl) headerEl.outerHTML = _privateHeader();
}).catch(() => {});
_render();
await _loadHunde();
}
function refresh() { _loadHunde(); }
function onDogChange() {}
// ----------------------------------------------------------
// Grundstruktur
// ----------------------------------------------------------
function _privateHeader() {
const zwinger = _breederInfo?.zwingername || 'Mein Zwinger';
const logoUrl = _breederInfo?.logo_url || null;
const logoHtml = logoUrl
? `<img src="${UI.escape(logoUrl)}" alt="Logo"
style="width:48px;height:48px;border-radius:50%;object-fit:cover;
border:2px solid rgba(196,132,58,.5);flex-shrink:0"
onerror="this.style.display='none'">`
: `<div style="width:48px;height:48px;border-radius:50%;background:rgba(196,132,58,.15);
border:2px solid rgba(196,132,58,.4);display:flex;align-items:center;
justify-content:center;flex-shrink:0">
<svg style="width:24px;height:24px;color:var(--c-primary)" viewBox="0 0 256 256">
<use href="/icons/phosphor.svg#paw-print"></use>
</svg>
</div>`;
return `
<div id="breeder-private-header" style="background:linear-gradient(135deg,#1a1208,#2d1f0e);
border-bottom:1px solid rgba(196,132,58,.25);
padding:var(--space-3) var(--space-4);
display:flex;align-items:center;gap:var(--space-3)">
${logoHtml}
<div style="flex:1;min-width:0">
<h2 style="margin:0 0 2px;font-size:var(--text-lg);font-weight:700;
color:rgba(255,255,255,.95);white-space:nowrap;overflow:hidden;
text-overflow:ellipsis;line-height:1.2">${UI.escape(zwinger)}</h2>
<div style="display:flex;align-items:center;gap:var(--space-2)">
<svg style="width:11px;height:11px;color:var(--c-primary);flex-shrink:0" viewBox="0 0 256 256">
<use href="/icons/phosphor.svg#lock-key"></use>
</svg>
<span style="font-size:var(--text-xs);color:rgba(196,132,58,.7)">Privater Bereich · Nur du siehst das</span>
</div>
</div>
</div>`;
}
function _render() {
_container.innerHTML = `
<div style="max-width:860px">
${_privateHeader()}
<div class="by-toolbar" style="margin-bottom:var(--space-4)">
<h2 style="margin:0;font-size:var(--text-lg);font-weight:var(--weight-semibold)">
${UI.icon('thermometer')} Läufigkeit & Trächtigkeit
</h2>
</div>
<div id="laeufi-list">
<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8)">Lädt</p>
</div>
</div>`;
}
async function _loadHunde() {
try {
const alle = await API.zuchthunde.list();
_hunde = alle.filter(h => h.geschlecht === 'weiblich');
_renderHundeList();
} catch (err) {
document.getElementById('laeufi-list').innerHTML =
`<p style="color:var(--c-danger)">${UI.escape(err.message || 'Fehler')}</p>`;
}
}
function _renderHundeList() {
const el = document.getElementById('laeufi-list');
if (!_hunde.length) {
el.innerHTML = `
<div style="text-align:center;padding:var(--space-10) var(--space-4);
border:1px dashed var(--c-border);border-radius:var(--radius-lg)">
<div style="font-size:2.5rem;margin-bottom:var(--space-3)">${UI.icon('gender-female')}</div>
<p style="font-weight:600;color:var(--c-text)">Keine Hündinnen in der Zuchtkartei</p>
<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-top:var(--space-2)">
Lege zuerst weibliche Hunde in der Zuchtkartei an.
</p>
</div>`;
return;
}
el.innerHTML = _hunde.map(h => _hundCardHTML(h)).join('');
_hunde.forEach(h => {
document.getElementById(`laeufi-toggle-${h.id}`)
?.addEventListener('click', () => _toggleHund(h.id));
});
if (_openHundId) _toggleHund(_openHundId, true);
}
// ----------------------------------------------------------
// Hund-Karte
// ----------------------------------------------------------
function _hundCardHTML(h) {
const alter = h.geburtsdatum
? Math.floor((Date.now() - new Date(h.geburtsdatum)) / 31557600000) + ' J'
: '';
return `
<div style="background:var(--c-bg-secondary);border:1px solid var(--c-border);
border-radius:var(--radius-lg);margin-bottom:var(--space-3);overflow:hidden"
id="laeufi-card-${h.id}">
<div id="laeufi-toggle-${h.id}"
style="padding:var(--space-4);display:flex;align-items:center;gap:var(--space-3);
cursor:pointer;user-select:none">
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap">
<span style="font-size:var(--text-base);font-weight:700">${UI.escape(h.name)}</span>
${h.rufname ? `<span style="color:var(--c-text-muted);font-size:var(--text-sm)">"${UI.escape(h.rufname)}"</span>` : ''}
${alter ? `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">${alter}</span>` : ''}
</div>
${h.rasse_text || h.farbe ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
${[h.rasse_text, h.farbe].filter(Boolean).map(s => UI.escape(s)).join(' · ')}
</div>` : ''}
</div>
<span style="color:var(--c-text-muted)">${UI.icon('caret-down')}</span>
</div>
<div id="laeufi-detail-${h.id}" style="display:none;border-top:1px solid var(--c-border)">
<div id="laeufi-content-${h.id}"
style="padding:var(--space-4)">
<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Lädt</p>
</div>
</div>
</div>`;
}
async function _toggleHund(hundId, forceOpen = false) {
const detail = document.getElementById(`laeufi-detail-${hundId}`);
if (!detail) return;
const isOpen = detail.style.display !== 'none';
if (isOpen && !forceOpen) {
detail.style.display = 'none';
_openHundId = null;
return;
}
detail.style.display = '';
_openHundId = hundId;
await _loadHundContent(hundId);
}
// ----------------------------------------------------------
// Inhalt pro Hündin laden
// ----------------------------------------------------------
async function _loadHundContent(hundId) {
const el = document.getElementById(`laeufi-content-${hundId}`);
if (!el) return;
try {
const [laeufiList, deckList] = await Promise.all([
API.laeufi.list(hundId),
API.laeufi.listDeck(hundId),
]);
_renderHundContent(el, hundId, laeufiList, deckList);
} catch (err) {
el.innerHTML = `<p style="color:var(--c-danger)">${UI.escape(err.message || 'Fehler')}</p>`;
}
}
function _renderHundContent(container, hundId, laeufiList, deckList) {
container.innerHTML = `
<div style="display:flex;flex-direction:column;gap:var(--space-5)">
<!-- LÄUFIGKEITEN -->
<div>
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-3)">
<h3 style="margin:0;font-size:var(--text-sm);font-weight:700;color:var(--c-text-secondary);
text-transform:uppercase;letter-spacing:.06em">
${UI.icon('drop')} Läufigkeiten
</h3>
<button class="btn btn-secondary btn-xs" id="laeufi-add-btn-${hundId}">
${UI.icon('plus')} Eintragen
</button>
</div>
<div id="laeufi-entries-${hundId}">
${_renderLaeufiEntries(hundId, laeufiList)}
</div>
</div>
<!-- DECKDATEN & TRÄCHTIGKEIT -->
<div>
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-3)">
<h3 style="margin:0;font-size:var(--text-sm);font-weight:700;color:var(--c-text-secondary);
text-transform:uppercase;letter-spacing:.06em">
${UI.icon('heart')} Deckdaten & Trächtigkeit
</h3>
<button class="btn btn-secondary btn-xs" id="deck-add-btn-${hundId}">
${UI.icon('plus')} Deckung eintragen
</button>
</div>
<div id="deck-entries-${hundId}">
${_renderDeckEntries(hundId, deckList)}
</div>
</div>
</div>`;
document.getElementById(`laeufi-add-btn-${hundId}`)
?.addEventListener('click', () => _showLaeufiForm(hundId, null, laeufiList));
document.getElementById(`deck-add-btn-${hundId}`)
?.addEventListener('click', () => _showDeckForm(hundId, null, laeufiList));
// Edit/Delete Events für Läufigkeiten
container.querySelectorAll('.laeufi-edit-btn').forEach(btn => {
const id = parseInt(btn.dataset.id);
const entry = laeufiList.find(l => l.id === id);
if (entry) btn.addEventListener('click', () => _showLaeufiForm(hundId, entry, laeufiList));
});
container.querySelectorAll('.laeufi-delete-btn').forEach(btn => {
const id = parseInt(btn.dataset.id);
btn.addEventListener('click', async () => {
if (!window.confirm('Läufigkeit und alle Progesterontests löschen?')) return;
try { await API.laeufi.remove(id); await _loadHundContent(hundId); }
catch (err) { UI.toast.error(err.message); }
});
});
container.querySelectorAll('.laeufi-prog-btn').forEach(btn => {
const id = parseInt(btn.dataset.id);
const entry = laeufiList.find(l => l.id === id);
if (entry) btn.addEventListener('click', () => _showProgModal(hundId, entry));
});
// Edit/Delete Events für Deckdaten
container.querySelectorAll('.deck-edit-btn').forEach(btn => {
const id = parseInt(btn.dataset.id);
const entry = deckList.find(d => d.id === id);
if (entry) btn.addEventListener('click', () => _showDeckForm(hundId, entry, laeufiList));
});
container.querySelectorAll('.deck-delete-btn').forEach(btn => {
const id = parseInt(btn.dataset.id);
btn.addEventListener('click', async () => {
if (!window.confirm('Deckdaten löschen?')) return;
try { await API.laeufi.removeDeck(id); await _loadHundContent(hundId); }
catch (err) { UI.toast.error(err.message); }
});
});
}
// ----------------------------------------------------------
// Läufigkeits-Einträge rendern
// ----------------------------------------------------------
function _renderLaeufiEntries(hundId, list) {
if (!list.length) return `
<div style="padding:var(--space-4);text-align:center;border:1px dashed var(--c-border);
border-radius:var(--radius-md);color:var(--c-text-muted);font-size:var(--text-sm)">
Noch keine Läufigkeit eingetragen.
</div>`;
return list.map(l => `
<div style="background:var(--c-surface);border:1px solid var(--c-border);border-radius:var(--radius-md);
padding:var(--space-3);margin-bottom:var(--space-2);display:flex;gap:var(--space-3);align-items:flex-start">
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap">
<span style="font-weight:600;font-size:var(--text-sm)">${_fmtDate(l.beginn)}</span>
${l.ende ? `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">→ ${_fmtDate(l.ende)}</span>
<span style="font-size:var(--text-xs);color:var(--c-text-muted)">${_daysDiff(l.beginn, l.ende)} Tage</span>` : ''}
</div>
${l.notiz ? `<p style="font-size:var(--text-xs);color:var(--c-text-secondary);margin:var(--space-1) 0 0;font-style:italic">${UI.escape(l.notiz)}</p>` : ''}
</div>
<div style="display:flex;gap:var(--space-1);flex-shrink:0">
<button class="btn btn-ghost btn-xs laeufi-prog-btn" data-id="${l.id}" title="Progesterontests">
${UI.icon('test-tube')}
</button>
<button class="btn btn-ghost btn-xs laeufi-edit-btn" data-id="${l.id}" title="Bearbeiten">
${UI.icon('pencil-simple')}
</button>
<button class="btn btn-ghost btn-xs laeufi-delete-btn" data-id="${l.id}"
title="Löschen" style="color:var(--c-danger)">
${UI.icon('trash')}
</button>
</div>
</div>`).join('');
}
// ----------------------------------------------------------
// Deckdaten + Meilensteine rendern
// ----------------------------------------------------------
const _TRAECHTIG = { 0: { label: 'Unbekannt', color: '#6b7280' }, 1: { label: 'Trächtig ✓', color: '#16a34a' }, [-1]: { label: 'Nicht trächtig', color: '#dc2626' } };
const _DECKART = { natuerlich: 'Natürlich', ki_frisch: 'KI frisch', ki_tiefgekuehlt: 'KI tiefgekühlt', ki_gefroren: 'KI gefroren' };
function _renderDeckEntries(hundId, list) {
if (!list.length) return `
<div style="padding:var(--space-4);text-align:center;border:1px dashed var(--c-border);
border-radius:var(--radius-md);color:var(--c-text-muted);font-size:var(--text-sm)">
Noch keine Deckung eingetragen.
</div>`;
return list.map(d => {
const tc = _TRAECHTIG[d.traechtig] || _TRAECHTIG[0];
const heute = d.meilensteine?.find(m => !m.vorbei);
const naechster = heute || d.meilensteine?.[d.meilensteine.length - 1];
return `
<div style="background:var(--c-surface);border:1px solid var(--c-border);border-radius:var(--radius-md);
margin-bottom:var(--space-3);overflow:hidden">
<!-- Deck-Header -->
<div style="padding:var(--space-3);display:flex;gap:var(--space-3);align-items:flex-start">
<div style="flex:1;min-width:0">
<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)">${UI.icon('heart')} Deckung ${_fmtDate(d.deckdatum)}</span>
<span style="background:${tc.color}1a;color:${tc.color};border:1px solid ${tc.color}30;
border-radius:999px;padding:1px 8px;font-size:var(--text-xs);font-weight:600">${tc.label}</span>
</div>
<div style="display:flex;gap:var(--space-3);flex-wrap:wrap;font-size:var(--text-xs);color:var(--c-text-secondary)">
${d.ruede_name ? `<span>${UI.icon('dog')} Rüde: ${UI.escape(d.ruede_name)}</span>` : ''}
<span>${UI.icon('arrows-clockwise')} ${_DECKART[d.deckart] || d.deckart}</span>
${d.ultraschall_datum ? `<span>${UI.icon('heartbeat')} Ultraschall: ${_fmtDate(d.ultraschall_datum)}</span>` : ''}
</div>
${naechster && d.traechtig === 1 ? `
<div style="margin-top:var(--space-2);font-size:var(--text-xs);
background:var(--c-primary-light,#f5e6d3);color:var(--c-primary-dark,#a86e2e);
border-radius:var(--radius-sm);padding:var(--space-1) var(--space-2);display:inline-flex;gap:4px;align-items:center">
${UI.icon('calendar-dots')} ${naechster.vorbei ? 'Letzter' : 'Nächster'} Meilenstein:
<strong>${naechster.label}</strong> · Tag ${naechster.tag} · ${_fmtDate(naechster.datum)}
</div>` : ''}
</div>
<div style="display:flex;gap:var(--space-1);flex-shrink:0">
<button class="btn btn-ghost btn-xs deck-edit-btn" data-id="${d.id}" title="Bearbeiten">${UI.icon('pencil-simple')}</button>
<button class="btn btn-ghost btn-xs deck-delete-btn" data-id="${d.id}" title="Löschen" style="color:var(--c-danger)">${UI.icon('trash')}</button>
</div>
</div>
<!-- Meilensteine -->
${d.traechtig === 1 && d.meilensteine?.length ? _renderMeilensteine(d.meilensteine) : ''}
</div>`;
}).join('');
}
function _renderMeilensteine(meilensteine) {
return `
<div style="border-top:1px solid var(--c-border);padding:var(--space-3)">
<p style="font-size:var(--text-xs);font-weight:700;color:var(--c-text-muted);text-transform:uppercase;
letter-spacing:.06em;margin-bottom:var(--space-2)">${UI.icon('calendar-check')} Trächtigkeits-Meilensteine</p>
<div style="display:flex;flex-direction:column;gap:var(--space-1)">
${meilensteine.map(m => `
<div style="display:flex;align-items:center;gap:var(--space-2);font-size:var(--text-xs);
opacity:${m.vorbei ? '.45' : '1'}">
<span style="width:18px;height:18px;border-radius:50%;flex-shrink:0;display:flex;align-items:center;justify-content:center;
background:${m.vorbei ? 'var(--c-success)' : 'var(--c-border)'};
color:${m.vorbei ? 'white' : 'var(--c-text-muted)'};font-size:9px">
${m.vorbei ? '✓' : m.tag}
</span>
<span style="color:var(--c-text-secondary)">${_fmtDate(m.datum)}</span>
<span style="color:${m.vorbei ? 'var(--c-text-muted)' : 'var(--c-text)'};font-weight:${m.vorbei ? '400' : '600'}">
${UI.escape(m.label)}
</span>
</div>`).join('')}
</div>
</div>`;
}
// ----------------------------------------------------------
// Formulare
// ----------------------------------------------------------
function _showLaeufiForm(hundId, entry, _allLaeufi) {
const isEdit = !!entry;
const v = entry || {};
const today = new Date().toISOString().slice(0, 10);
UI.modal.open({
title: isEdit ? 'Läufigkeit bearbeiten' : 'Läufigkeit eintragen',
body: `
<form id="laeufi-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Beginn *</label>
<input class="form-control" type="date" name="beginn" required value="${v.beginn || today}">
</div>
<div class="form-group">
<label class="form-label">Ende</label>
<input class="form-control" type="date" name="ende" value="${v.ende || ''}">
</div>
</div>
<div class="form-group">
<label class="form-label">Notiz</label>
<textarea class="form-control" name="notiz" rows="2">${UI.escape(v.notiz || '')}</textarea>
</div>
</form>`,
footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" form="laeufi-form" type="submit">${isEdit ? 'Speichern' : 'Eintragen'}</button>`,
});
document.getElementById('laeufi-form').addEventListener('submit', async e => {
e.preventDefault();
const fd = new FormData(e.target);
const data = { beginn: fd.get('beginn'), ende: fd.get('ende') || null, notiz: fd.get('notiz') || null };
try {
if (isEdit) await API.laeufi.update(entry.id, data);
else await API.laeufi.add(hundId, data);
UI.modal.close();
await _loadHundContent(hundId);
UI.toast.success(isEdit ? 'Gespeichert.' : 'Läufigkeit eingetragen.');
} catch (err) { UI.toast.error(err.message); }
});
}
function _showDeckForm(hundId, entry, laeufiList) {
const isEdit = !!entry;
const v = entry || {};
const today = new Date().toISOString().slice(0, 10);
const laeufiOpts = laeufiList.map(l =>
`<option value="${l.id}" ${v.laeufi_id === l.id ? 'selected' : ''}>${_fmtDate(l.beginn)}</option>`
).join('');
UI.modal.open({
title: isEdit ? 'Deckung bearbeiten' : 'Deckung eintragen',
body: `
<form id="deck-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Deckdatum *</label>
<input class="form-control" type="date" name="deckdatum" required value="${v.deckdatum || today}">
</div>
<div class="form-group">
<label class="form-label">Zugehörige Läufigkeit</label>
<select class="form-control" name="laeufi_id">
<option value=""> keine </option>
${laeufiOpts}
</select>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Rüde</label>
<input class="form-control" name="ruede_name" placeholder="Name des Deckrüden"
value="${UI.escape(v.ruede_name || '')}">
</div>
<div class="form-group">
<label class="form-label">Deckart</label>
<select class="form-control" name="deckart">
<option value="natuerlich" ${(v.deckart||'natuerlich') === 'natuerlich' ? 'selected':''}>Natürlich</option>
<option value="ki_frisch" ${v.deckart === 'ki_frisch' ? 'selected':''}>KI frisch</option>
<option value="ki_tiefgekuehlt"${v.deckart === 'ki_tiefgekuehlt'? 'selected':''}>KI tiefgekühlt</option>
<option value="ki_gefroren" ${v.deckart === 'ki_gefroren' ? 'selected':''}>KI gefroren</option>
</select>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Trächtigkeitsstatus</label>
<select class="form-control" name="traechtig">
<option value="0" ${(v.traechtig === 0 || v.traechtig == null) ? 'selected':''}>Unbekannt</option>
<option value="1" ${v.traechtig === 1 ? 'selected':''}>Trächtig </option>
<option value="-1" ${v.traechtig === -1 ? 'selected':''}>Nicht trächtig</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Ultraschall-Datum</label>
<input class="form-control" type="date" name="ultraschall_datum"
value="${v.ultraschall_datum || ''}">
</div>
</div>
<div class="form-group">
<label class="form-label">Notiz</label>
<textarea class="form-control" name="notiz" rows="2">${UI.escape(v.notiz || '')}</textarea>
</div>
</form>`,
footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" form="deck-form" type="submit">${isEdit ? 'Speichern' : 'Eintragen'}</button>`,
});
document.getElementById('deck-form').addEventListener('submit', async e => {
e.preventDefault();
const fd = new FormData(e.target);
const data = {
deckdatum: fd.get('deckdatum'),
laeufi_id: fd.get('laeufi_id') ? parseInt(fd.get('laeufi_id')) : null,
ruede_name: fd.get('ruede_name') || null,
deckart: fd.get('deckart'),
traechtig: parseInt(fd.get('traechtig')),
ultraschall_datum: fd.get('ultraschall_datum') || null,
notiz: fd.get('notiz') || null,
};
try {
if (isEdit) await API.laeufi.updateDeck(entry.id, data);
else await API.laeufi.addDeck(hundId, data);
UI.modal.close();
await _loadHundContent(hundId);
UI.toast.success(isEdit ? 'Gespeichert.' : 'Deckung eingetragen.');
} catch (err) { UI.toast.error(err.message); }
});
}
// ----------------------------------------------------------
// Progesterontests-Modal
// ----------------------------------------------------------
async function _showProgModal(hundId, laeufi) {
UI.modal.open({
title: `Progesterontests — ${_fmtDate(laeufi.beginn)}`,
body: `<div id="prog-modal-content"><p style="color:var(--c-text-muted)">Lädt…</p></div>`,
footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
<button class="btn btn-primary" id="prog-add-btn">${UI.icon('plus')} Test eintragen</button>`,
});
await _loadProgContent(laeufi.id);
document.getElementById('prog-add-btn')
?.addEventListener('click', () => _showProgForm(hundId, laeufi.id, null));
}
async function _loadProgContent(laeufiId) {
const el = document.getElementById('prog-modal-content');
if (!el) return;
const tests = await API.laeufi.listProg(laeufiId);
if (!tests.length) {
el.innerHTML = `<p style="color:var(--c-text-muted);font-size:var(--text-sm);text-align:center;padding:var(--space-4)">
Noch keine Tests eingetragen.</p>`;
return;
}
el.innerHTML = `
<table style="width:100%;border-collapse:collapse;font-size:var(--text-sm)">
<thead>
<tr style="font-size:var(--text-xs);color:var(--c-text-muted);text-transform:uppercase;letter-spacing:.05em">
<th style="text-align:left;padding:var(--space-1) var(--space-2)">Datum</th>
<th style="text-align:right;padding:var(--space-1) var(--space-2)">Wert</th>
<th style="text-align:left;padding:var(--space-1) var(--space-2)">Labor</th>
<th style="padding:var(--space-1) var(--space-2)"></th>
</tr>
</thead>
<tbody>
${tests.map(t => `
<tr style="border-top:1px solid var(--c-border)">
<td style="padding:var(--space-2)">${_fmtDate(t.datum)}</td>
<td style="text-align:right;padding:var(--space-2);font-weight:600">
${t.wert != null ? `${t.wert} ${UI.escape(t.einheit)}` : '—'}
${t.wert != null ? `<span style="font-size:10px;margin-left:4px;color:var(--c-text-muted)">${_progEinschaetzung(t.wert, t.einheit)}</span>` : ''}
</td>
<td style="padding:var(--space-2);color:var(--c-text-secondary)">${t.labor ? UI.escape(t.labor) : '—'}</td>
<td style="padding:var(--space-2);text-align:right">
<button class="btn btn-ghost btn-xs prog-delete-btn" data-id="${t.id}"
style="color:var(--c-danger)">${UI.icon('trash')}</button>
</td>
</tr>`).join('')}
</tbody>
</table>`;
el.querySelectorAll('.prog-delete-btn').forEach(btn => {
btn.addEventListener('click', async () => {
try {
await API.laeufi.removeProg(parseInt(btn.dataset.id));
await _loadProgContent(laeufiId);
} catch (err) { UI.toast.error(err.message); }
});
});
}
function _progEinschaetzung(wert, einheit) {
if (einheit !== 'ng/ml') return '';
if (wert < 2) return '(Basiswert)';
if (wert < 5) return '(Anstieg)';
if (wert < 10) return '(LH-Peak Nähe)';
if (wert < 15) return '(Ovulation)';
return '(Post-Ovulation)';
}
function _showProgForm(hundId, laeufiId, _entry) {
const today = new Date().toISOString().slice(0, 10);
UI.modal.open({
title: 'Progesterontest eintragen',
body: `
<form id="prog-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Datum *</label>
<input class="form-control" type="date" name="datum" required value="${today}">
</div>
<div class="form-group">
<label class="form-label">Einheit</label>
<select class="form-control" name="einheit">
<option value="ng/ml">ng/ml</option>
<option value="nmol/l">nmol/l</option>
</select>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Wert</label>
<input class="form-control" type="number" step="0.01" name="wert" placeholder="z.B. 8.5">
</div>
<div class="form-group">
<label class="form-label">Labor / Tierarzt</label>
<input class="form-control" name="labor" placeholder="z.B. Tierarzt Müller">
</div>
</div>
<div class="form-group">
<label class="form-label">Notiz</label>
<input class="form-control" name="notiz">
</div>
</form>`,
footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" form="prog-form" type="submit">Eintragen</button>`,
});
document.getElementById('prog-form').addEventListener('submit', async e => {
e.preventDefault();
const fd = new FormData(e.target);
const wertRaw = fd.get('wert');
const data = {
datum: fd.get('datum'),
wert: wertRaw ? parseFloat(wertRaw) : null,
einheit: fd.get('einheit'),
labor: fd.get('labor') || null,
notiz: fd.get('notiz') || null,
};
try {
await API.laeufi.addProg(laeufiId, data);
UI.modal.close();
// Prog-Modal neu öffnen
const laeufi = { id: laeufiId, beginn: '' };
await _showProgModal(hundId, laeufi);
await _loadHundContent(hundId);
UI.toast.success('Test eingetragen.');
} catch (err) { UI.toast.error(err.message); }
});
}
// ----------------------------------------------------------
// Hilfsfunktionen
// ----------------------------------------------------------
function _fmtDate(iso) {
if (!iso) return '—';
const [y, m, d] = iso.slice(0, 10).split('-');
return `${d}.${m}.${y}`;
}
function _daysDiff(a, b) {
if (!a || !b) return '';
return Math.round((new Date(b) - new Date(a)) / 86400000);
}
return { init, refresh, onDogChange };
})();

View file

@ -5,10 +5,12 @@
window.Page_litters = (() => {
let _container = null;
let _appState = null;
let _litters = []; // geladene Würfe
let _openId = null; // aufgeklappter Wurf
let _container = null;
let _appState = null;
let _litters = [];
let _openId = null;
let _filterStatus = null;
let _breederInfo = null; // { zwingername, logo_url }
// ----------------------------------------------------------
// Hilfsfunktionen
@ -75,6 +77,12 @@ window.Page_litters = (() => {
}
_render();
API.breeder.status().then(s => {
_breederInfo = s?.profile ? { zwingername: s.profile.zwingername, logo_url: s.profile.logo_url } : null;
// Header nach Laden der Info neu rendern
const headerEl = _container.querySelector('#breeder-private-header');
if (headerEl) headerEl.outerHTML = _privateHeader();
}).catch(() => {});
await _loadLitters();
}
@ -89,17 +97,54 @@ window.Page_litters = (() => {
// ----------------------------------------------------------
// Grundstruktur rendern
// ----------------------------------------------------------
function _privateHeader() {
const zwinger = _breederInfo?.zwingername || 'Mein Zwinger';
const logoUrl = _breederInfo?.logo_url || null;
const logoHtml = logoUrl
? `<img src="${_esc(logoUrl)}" alt="Logo"
style="width:48px;height:48px;border-radius:50%;object-fit:cover;
border:2px solid rgba(196,132,58,.5);flex-shrink:0"
onerror="this.style.display='none'">`
: `<div style="width:48px;height:48px;border-radius:50%;background:rgba(196,132,58,.15);
border:2px solid rgba(196,132,58,.4);display:flex;align-items:center;
justify-content:center;flex-shrink:0">
<svg style="width:24px;height:24px;color:var(--c-primary)" viewBox="0 0 256 256">
<use href="/icons/phosphor.svg#paw-print"></use>
</svg>
</div>`;
return `
<div id="breeder-private-header" style="background:linear-gradient(135deg,#1a1208,#2d1f0e);
border-bottom:1px solid rgba(196,132,58,.25);
padding:var(--space-3) var(--space-4);
display:flex;align-items:center;gap:var(--space-3)">
${logoHtml}
<div style="flex:1;min-width:0">
<h2 style="margin:0 0 2px;font-size:var(--text-lg);font-weight:700;
color:rgba(255,255,255,.95);white-space:nowrap;overflow:hidden;
text-overflow:ellipsis;line-height:1.2">${_esc(zwinger)}</h2>
<div style="display:flex;align-items:center;gap:var(--space-2)">
<svg style="width:11px;height:11px;color:var(--c-primary);flex-shrink:0" viewBox="0 0 256 256">
<use href="/icons/phosphor.svg#lock-key"></use>
</svg>
<span style="font-size:var(--text-xs);color:rgba(196,132,58,.7)">Privater Bereich · Nur du siehst das</span>
</div>
</div>
</div>`;
}
function _render() {
_container.innerHTML = `
<div class="litters-layout">
<div class="by-toolbar">
${_privateHeader()}
<div class="by-toolbar" style="flex-wrap:wrap">
<h2 style="margin:0;font-size:var(--text-lg);font-weight:var(--weight-semibold)">
${UI.icon('dog')} Meine Würfe
${UI.icon('certificate')} Meine Würfe
</h2>
<button class="btn btn-primary btn-sm" id="litters-new-btn">
${UI.icon('plus')} Neuer Wurf
</button>
</div>
<div id="litters-stats" style="display:none;gap:var(--space-3);margin-bottom:var(--space-4);flex-wrap:wrap"></div>
<div id="litters-list">
<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8)">Lädt</p>
</div>
@ -132,6 +177,69 @@ window.Page_litters = (() => {
// ----------------------------------------------------------
// Würfe-Liste rendern
// ----------------------------------------------------------
function _renderStats() {
const bar = document.getElementById('litters-stats');
if (!bar || !_litters.length) return;
const total = _litters.length;
const aktiv = _litters.filter(l => l.status === 'verfuegbar' || l.status === 'geboren').length;
const geplant = _litters.filter(l => l.status === 'geplant').length;
const welpen = _litters.reduce((s, l) => s + (l.welpen_gesamt || 0), 0);
const verfuegb = _litters.reduce((s, l) => s + (l.welpen_verfuegbar || 0), 0);
const statItems = [
{ icon: 'list-bullets', label: 'Alle Würfe', val: total, filter: null },
{ icon: 'baby', label: 'Aktiv', val: aktiv, filter: ['verfuegbar','geboren'], color: 'var(--c-success)' },
{ icon: 'calendar-dots',label: 'Geplant', val: geplant, filter: ['geplant'] },
{ icon: 'dog', label: 'Welpen ges.', val: welpen, filter: null },
{ icon: 'tag', label: 'Verfügbar', val: verfuegb,filter: ['verfuegbar'], color: verfuegb > 0 ? 'var(--c-primary)' : undefined },
];
bar.style.display = 'flex';
bar.innerHTML = statItems.map((s, i) => {
const isActive = JSON.stringify(_filterStatus) === JSON.stringify(s.filter);
const clickable = true;
return `
<div data-stat-idx="${i}"
style="background:${isActive ? 'var(--c-primary)' : 'var(--c-bg-secondary)'};
border:1px solid ${isActive ? 'var(--c-primary)' : 'var(--c-border)'};
border-radius:var(--radius-md);padding:var(--space-3) var(--space-4);
display:flex;align-items:center;gap:var(--space-2);flex:1;min-width:90px;
${clickable ? 'cursor:pointer;user-select:none;transition:opacity .15s' : ''}">
<span style="color:${isActive ? 'white' : (s.color || 'var(--c-text-muted)')};opacity:.9">${UI.icon(s.icon)}</span>
<div>
<div style="font-size:var(--text-lg);font-weight:700;
color:${isActive ? 'white' : (s.color || 'var(--c-text)')};line-height:1">${s.val}</div>
<div style="font-size:var(--text-xs);color:${isActive ? 'rgba(255,255,255,.75)' : 'var(--c-text-muted)'}">${s.label}</div>
</div>
</div>`;
}).join('');
bar.querySelectorAll('[data-stat-idx]').forEach(chip => {
const s = statItems[parseInt(chip.dataset.statIdx)];
chip.addEventListener('click', () => {
_filterStatus = JSON.stringify(_filterStatus) === JSON.stringify(s.filter) ? null : s.filter;
_renderStats();
_renderFilteredList();
});
});
}
function _renderFilteredList() {
const el = document.getElementById('litters-list');
if (!el) return;
const visible = _filterStatus
? _litters.filter(l => _filterStatus.includes(l.status))
: _litters;
if (!visible.length) {
el.innerHTML = `
<div style="text-align:center;padding:var(--space-8) var(--space-4);
border:1px dashed var(--c-border);border-radius:var(--radius-lg)">
<p style="color:var(--c-text-muted)">Keine Würfe für diesen Filter.</p>
</div>`;
return;
}
el.innerHTML = visible.map(l => _litterCardHTML(l)).join('');
_bindCardEvents(el, visible);
}
function _renderList() {
const el = document.getElementById('litters-list');
if (!el) return;
@ -149,131 +257,150 @@ window.Page_litters = (() => {
return;
}
_renderStats();
_filterStatus = null;
el.innerHTML = _litters.map(l => _litterCardHTML(l)).join('');
// Events
el.querySelectorAll('.litters-card-toggle').forEach(btn => {
btn.addEventListener('click', () => {
const id = parseInt(btn.dataset.id);
_togglePuppies(id);
});
});
el.querySelectorAll('.litters-photos-btn').forEach(btn => {
btn.addEventListener('click', () => {
const id = parseInt(btn.dataset.id);
const litter = _litters.find(l => l.id === id);
if (litter) _showPhotosModal('litter', litter.id, litter.zwingername || `Wurf #${litter.id}`);
});
});
el.querySelectorAll('.litters-parent-photos-btn').forEach(btn => {
btn.addEventListener('click', () => {
const id = parseInt(btn.dataset.id);
const litter = _litters.find(l => l.id === id);
if (!litter) return;
const label = [litter.vater_name, litter.mutter_name].filter(Boolean).join(' × ') || `Eltern Wurf #${id}`;
_showPhotosModal('parent', litter.id, label);
});
});
el.querySelectorAll('.litters-edit-btn').forEach(btn => {
btn.addEventListener('click', () => {
const id = parseInt(btn.dataset.id);
const litter = _litters.find(l => l.id === id);
if (litter) _showLitterForm(litter);
});
});
el.querySelectorAll('.litters-delete-btn').forEach(btn => {
btn.addEventListener('click', () => {
const id = parseInt(btn.dataset.id);
_deleteLitter(id);
});
});
el.querySelectorAll('.litters-ki-announce-btn').forEach(btn => {
btn.addEventListener('click', () => {
const id = parseInt(btn.dataset.id);
_showKiAnnouncement(id);
});
});
el.querySelectorAll('.litters-add-puppy-btn').forEach(btn => {
btn.addEventListener('click', () => {
const id = parseInt(btn.dataset.id);
_showPuppyForm(id, null);
});
});
// Aufgeklappten Wurf wiederherstellen
_bindCardEvents(el, _litters);
if (_openId) _togglePuppies(_openId, true);
}
function _bindCardEvents(el, litters) {
el.querySelectorAll('.litters-card-toggle').forEach(btn => {
btn.addEventListener('click', () => _togglePuppies(parseInt(btn.dataset.id)));
});
el.querySelectorAll('.litters-photos-btn').forEach(btn => {
btn.addEventListener('click', () => {
const id = parseInt(btn.dataset.id);
const l = litters.find(x => x.id === id);
if (l) _showPhotosModal('litter', l.id, l.zwingername || `Wurf #${l.id}`);
});
});
el.querySelectorAll('.litters-parent-photos-btn').forEach(btn => {
btn.addEventListener('click', () => {
const id = parseInt(btn.dataset.id);
const l = litters.find(x => x.id === id);
if (!l) return;
_showPhotosModal('parent', l.id, [l.vater_name, l.mutter_name].filter(Boolean).join(' × ') || `Eltern #${id}`);
});
});
el.querySelectorAll('.litters-edit-btn').forEach(btn => {
btn.addEventListener('click', () => {
const l = litters.find(x => x.id === parseInt(btn.dataset.id));
if (l) _showLitterForm(l);
});
});
el.querySelectorAll('.litters-delete-btn').forEach(btn => {
btn.addEventListener('click', () => _deleteLitter(parseInt(btn.dataset.id)));
});
el.querySelectorAll('.litters-ki-announce-btn').forEach(btn => {
btn.addEventListener('click', () => _showKiAnnouncement(parseInt(btn.dataset.id)));
});
el.querySelectorAll('.litters-add-puppy-btn').forEach(btn => {
btn.addEventListener('click', () => _showPuppyForm(parseInt(btn.dataset.id), null));
});
el.querySelectorAll('.litters-waitlist-btn').forEach(btn => {
btn.addEventListener('click', () => _toggleWaitlist(parseInt(btn.dataset.id)));
});
el.querySelectorAll('.litters-add-waitlist-btn').forEach(btn => {
btn.addEventListener('click', () => _showWaitlistForm(parseInt(btn.dataset.id), null));
});
}
function _daysUntil(dateStr) {
if (!dateStr) return null;
const diff = Math.ceil((new Date(dateStr) - new Date()) / 86400000);
return diff;
}
function _litterCardHTML(l) {
const verfuegbar = l.welpen_verfuegbar != null ? l.welpen_verfuegbar : '?';
const gesamt = l.welpen_gesamt != null ? l.welpen_gesamt : '?';
const datumLabel = l.geburt_datum
? `Geburt: ${_fmtDate(l.geburt_datum)}`
: l.erwartetes_datum
? `Erwartet: ${_fmtDate(l.erwartetes_datum)}`
: '—';
const verfuegbar = l.welpen_verfuegbar != null ? l.welpen_verfuegbar : '?';
const gesamt = l.welpen_gesamt != null ? l.welpen_gesamt : '?';
const elternLabel = [l.vater_name, l.mutter_name].filter(Boolean).map(n => _esc(n)).join(' × ') || '—';
const elternLabel = [l.vater_name, l.mutter_name]
.filter(Boolean)
.map(n => _esc(n))
.join(' × ') || '—';
// Datum + Countdown
let datumChip = '';
const refDate = l.geburt_datum || l.erwartetes_datum;
if (refDate) {
const days = _daysUntil(refDate);
const label = l.geburt_datum ? `Geburt ${_fmtDate(l.geburt_datum)}` : `Erwartet ${_fmtDate(l.erwartetes_datum)}`;
let countdownHtml = '';
if (days !== null && !l.geburt_datum) {
const c = days < 0 ? `<span style="color:var(--c-danger)">überfällig</span>`
: days === 0 ? `<span style="color:var(--c-success)">heute!</span>`
: days <= 7 ? `<span style="color:var(--c-warning,#f59e0b)">${days}d</span>`
: `<span style="color:var(--c-text-muted)">${days}d</span>`;
countdownHtml = ` · ${c}`;
}
datumChip = `<span style="display:inline-flex;align-items:center;gap:4px;font-size:var(--text-xs);color:var(--c-text-secondary)">${UI.icon('calendar-dots')} ${label}${countdownHtml}</span>`;
}
const sichtbarLabel = l.sichtbar
? `<span style="color:var(--c-success);font-size:var(--text-xs)">${UI.icon('eye')} Öffentlich</span>`
: `<span style="color:var(--c-text-muted);font-size:var(--text-xs)">${UI.icon('eye-slash')} Nicht öffentlich</span>`;
const sichtbarChip = l.sichtbar
? `<span style="display:inline-flex;align-items:center;gap:3px;font-size:var(--text-xs);color:var(--c-success)">${UI.icon('eye')} Öffentlich</span>`
: `<span style="display:inline-flex;align-items:center;gap:3px;font-size:var(--text-xs);color:var(--c-text-muted)">${UI.icon('eye-slash')} Nicht öffentlich</span>`;
const welpenChip = `<span style="display:inline-flex;align-items:center;gap:3px;font-size:var(--text-xs);color:var(--c-text-secondary)">${UI.icon('dog')} ${verfuegbar}/${gesamt} verfügbar</span>`;
const preisChip = l.preis_spanne
? `<span style="display:inline-flex;align-items:center;gap:3px;font-size:var(--text-xs);color:var(--c-text-secondary)">${UI.icon('currency-eur')} ${_esc(l.preis_spanne)}</span>`
: '';
return `
<div class="litters-card" id="litter-card-${l.id}">
<div class="litters-card-header">
<div style="flex:1;min-width:0">
<div class="litters-card-title">
${elternLabel}
${_statusBadge(l.status)}
<div class="litters-card" id="litter-card-${l.id}"
style="background:var(--c-bg-secondary);border:1px solid var(--c-border);border-radius:var(--radius-lg);
margin-bottom:var(--space-3);overflow:hidden">
<!-- Card-Header -->
<div style="padding:var(--space-4) var(--space-4) var(--space-3);border-bottom:1px solid var(--c-border)">
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:var(--space-3);flex-wrap:wrap">
<div style="min-width:0">
${(l.wurf_rang || l.wurf_name) ? `
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-1)">
${l.wurf_rang ? `<span style="background:var(--c-primary);color:white;border-radius:999px;padding:1px 10px;font-size:var(--text-xs);font-weight:700">${_esc(l.wurf_rang)}-Wurf</span>` : ''}
${l.wurf_name ? `<span style="font-size:var(--text-base);font-weight:700;color:var(--c-text)">${_esc(l.wurf_name)}</span>` : ''}
</div>` : ''}
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-2)">
<span style="font-size:var(--text-sm);color:var(--c-text-secondary)">${elternLabel}</span>
${_statusBadge(l.status)}
${sichtbarChip}
</div>
<div style="display:flex;align-items:center;gap:var(--space-3);flex-wrap:wrap">
${datumChip}
${welpenChip}
${preisChip}
</div>
</div>
<div class="litters-card-meta">
${UI.icon('calendar-dots')} ${_esc(datumLabel)} &nbsp;·&nbsp;
${UI.icon('dog')} ${verfuegbar}/${gesamt} verfügbar
&nbsp;·&nbsp; ${sichtbarLabel}
<div style="display:flex;align-items:center;gap:var(--space-1);flex-shrink:0;flex-wrap:wrap">
<button class="btn btn-ghost btn-sm litters-card-toggle" data-id="${l.id}" title="Welpen">
${UI.icon('caret-down')} Welpen
</button>
<button class="btn btn-ghost btn-sm litters-waitlist-btn" data-id="${l.id}" title="Warteliste">
${UI.icon('list-bullets')} Warteliste
</button>
<button class="btn btn-ghost btn-sm litters-photos-btn" data-id="${l.id}" title="Wurf-Fotos">
${UI.icon('images')}
</button>
<button class="btn btn-ghost btn-sm litters-parent-photos-btn" data-id="${l.id}" title="Elterntier-Fotos">
${UI.icon('users')}
</button>
${_appState.user?.ki_zucht_wurfankuendigung !== 0 ? `
<button class="btn btn-ghost btn-sm litters-ki-announce-btn" data-id="${l.id}" title="KI: Wurfankündigung">
${UI.icon('sparkle')}
</button>` : ''}
<button class="btn btn-ghost btn-sm litters-edit-btn" data-id="${l.id}" title="Bearbeiten">
${UI.icon('pencil-simple')}
</button>
<button class="btn btn-ghost btn-sm litters-delete-btn" data-id="${l.id}" title="Löschen"
style="color:var(--c-danger)">
${UI.icon('trash')}
</button>
</div>
${l.preis_spanne ? `<div class="litters-card-meta">${UI.icon('currency-eur')} ${_esc(l.preis_spanne)}</div>` : ''}
</div>
<div class="litters-card-actions">
<button class="btn btn-ghost btn-sm litters-card-toggle" data-id="${l.id}"
title="Welpen anzeigen">
${UI.icon('caret-down')} Welpen
</button>
<button class="btn btn-ghost btn-sm litters-photos-btn" data-id="${l.id}"
title="Wurf-Fotos verwalten">
${UI.icon('images')} Fotos
</button>
<button class="btn btn-ghost btn-sm litters-parent-photos-btn" data-id="${l.id}"
title="Elterntier-Fotos verwalten">
${UI.icon('users')} Eltern
</button>
${_appState.user?.ki_zucht_wurfankuendigung !== 0 ? `
<button class="btn btn-ghost btn-sm litters-ki-announce-btn" data-id="${l.id}"
title="KI: Wurfankündigung schreiben">
${UI.icon('sparkle')} Ankündigung
</button>` : ''}
<button class="btn btn-ghost btn-sm litters-edit-btn" data-id="${l.id}"
title="Bearbeiten">
${UI.icon('pencil-simple')}
</button>
<button class="btn btn-ghost btn-sm litters-delete-btn" data-id="${l.id}"
title="Löschen" style="color:var(--c-danger)">
${UI.icon('trash')}
</button>
</div>
${l.beschreibung ? `<p style="margin-top:var(--space-2);font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.5">${_esc(l.beschreibung)}</p>` : ''}
</div>
${l.beschreibung ? `<div class="litters-card-desc">${_esc(l.beschreibung)}</div>` : ''}
<div class="litters-puppies-wrap" id="puppies-wrap-${l.id}" style="display:none">
<div class="litters-puppies-inner" id="puppies-inner-${l.id}">
<!-- Welpen-Bereich -->
<div id="puppies-wrap-${l.id}" style="display:none;padding:var(--space-3) var(--space-4)">
<div id="puppies-inner-${l.id}">
<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Lädt</p>
</div>
<button class="btn btn-secondary btn-sm litters-add-puppy-btn" data-id="${l.id}"
@ -281,6 +408,17 @@ window.Page_litters = (() => {
${UI.icon('plus')} Welpen hinzufügen
</button>
</div>
<!-- Wartelisten-Bereich -->
<div id="waitlist-wrap-${l.id}" style="display:none;padding:var(--space-3) var(--space-4)">
<div id="waitlist-inner-${l.id}">
<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Lädt</p>
</div>
<button class="btn btn-secondary btn-sm litters-add-waitlist-btn" data-id="${l.id}"
style="margin-top:var(--space-3)">
${UI.icon('plus')} Interessent eintragen
</button>
</div>
</div>`;
}
@ -561,6 +699,209 @@ window.Page_litters = (() => {
}
}
// ----------------------------------------------------------
// Warteliste
// ----------------------------------------------------------
const _WL_STATUS = {
anfrage: { label: 'Anfrage', color: '#6b7280' },
vorgemerkt: { label: 'Vorgemerkt', color: '#f59e0b' },
bestaetigt: { label: 'Bestätigt', color: '#3b82f6' },
abgegeben: { label: 'Abgegeben', color: '#16a34a' },
abgesagt: { label: 'Abgesagt', color: '#dc2626' },
};
function _wlStatusBadge(status) {
const s = _WL_STATUS[status] || _WL_STATUS.anfrage;
return `<span style="background:${s.color}1a;color:${s.color};border:1px solid ${s.color}40;border-radius:999px;padding:1px 8px;font-size:var(--text-xs);font-weight:600">${s.label}</span>`;
}
async function _toggleWaitlist(litterId) {
const wrap = document.getElementById(`waitlist-wrap-${litterId}`);
if (!wrap) return;
const isOpen = wrap.style.display !== 'none';
if (isOpen) { wrap.style.display = 'none'; return; }
wrap.style.display = '';
await _loadWaitlist(litterId);
}
async function _loadWaitlist(litterId) {
const inner = document.getElementById(`waitlist-inner-${litterId}`);
if (!inner) return;
try {
const entries = await API.litters.waitlist(litterId);
_renderWaitlist(inner, litterId, entries);
// Badge am Button aktualisieren
const btn = document.querySelector(`.litters-waitlist-btn[data-id="${litterId}"]`);
if (btn) {
const active = entries.filter(e => e.status !== 'abgesagt').length;
btn.innerHTML = `${UI.icon('list-bullets')} Warteliste${active ? ` <span style="background:var(--c-primary);color:white;border-radius:999px;padding:0 6px;font-size:10px;font-weight:700">${active}</span>` : ''}`;
}
} catch (err) {
inner.innerHTML = `<p style="color:var(--c-danger);font-size:var(--text-sm)">${_esc(err.message || 'Fehler.')}</p>`;
}
}
function _renderWaitlist(container, litterId, entries) {
const active = entries.filter(e => e.status !== 'abgesagt');
const statusCounts = {};
entries.forEach(e => { statusCounts[e.status] = (statusCounts[e.status] || 0) + 1; });
const summaryPills = Object.entries(statusCounts).map(([s, n]) => {
const cfg = _WL_STATUS[s] || _WL_STATUS.anfrage;
return `<span style="background:${cfg.color}1a;color:${cfg.color};border:1px solid ${cfg.color}30;border-radius:999px;padding:1px 8px;font-size:11px;font-weight:600">${cfg.label}: ${n}</span>`;
}).join('');
const header = `
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-3);flex-wrap:wrap;gap:var(--space-2)">
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap">
<span style="font-size:var(--text-sm);font-weight:700;color:var(--c-text)">${entries.length} Interessent${entries.length !== 1 ? 'en' : ''}</span>
${summaryPills}
</div>
</div>`;
if (!entries.length) {
container.innerHTML = `
<div style="text-align:center;padding:var(--space-6) var(--space-4);border:1px dashed var(--c-border);border-radius:var(--radius-md)">
<div style="font-size:2rem;margin-bottom:var(--space-2)">${UI.icon('users')}</div>
<p style="font-weight:600;font-size:var(--text-sm);color:var(--c-text);margin-bottom:var(--space-1)">Noch keine Interessenten</p>
<p style="font-size:var(--text-xs);color:var(--c-text-muted)">Trage Anfragen ein mit Wunsch-Geschlecht, Kontaktdaten und Status.</p>
</div>`;
return;
}
container.innerHTML = header + `
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
${entries.map((e, i) => `
<div style="background:var(--c-bg-secondary);border-radius:var(--radius-md);padding:var(--space-3) var(--space-3);display:flex;gap:var(--space-3);align-items:flex-start" data-entry-id="${e.id}">
<div style="background:var(--c-primary);color:white;border-radius:50%;width:1.6rem;height:1.6rem;display:flex;align-items:center;justify-content:center;font-size:var(--text-xs);font-weight:700;flex-shrink:0;margin-top:2px">${i + 1}</div>
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-1)">
<span style="font-weight:600;font-size:var(--text-sm)">${_esc(e.name)}</span>
${_wlStatusBadge(e.status)}
${e.wunsch_geschlecht && e.wunsch_geschlecht !== 'egal' ? `<span style="font-size:var(--text-xs);color:var(--c-text-secondary)">${e.wunsch_geschlecht === 'maennlich' ? '♂ Rüde' : '♀ Hündin'}</span>` : ''}
${e.wunsch_farbe ? `<span style="font-size:var(--text-xs);color:var(--c-text-secondary)">${_esc(e.wunsch_farbe)}</span>` : ''}
</div>
<div style="display:flex;gap:var(--space-4);flex-wrap:wrap;font-size:var(--text-xs);color:var(--c-text-secondary)">
${e.email ? `<span>${UI.icon('envelope')} ${_esc(e.email)}</span>` : ''}
${e.telefon ? `<span>${UI.icon('phone')} ${_esc(e.telefon)}</span>` : ''}
<span>${UI.icon('calendar-dots')} ${e.created_at ? e.created_at.slice(0, 10) : '—'}</span>
</div>
${e.nachricht ? `<div style="margin-top:var(--space-1);font-size:var(--text-xs);color:var(--c-text-secondary);font-style:italic">"${_esc(e.nachricht)}"</div>` : ''}
${e.notiz ? `<div style="margin-top:var(--space-1);font-size:var(--text-xs);background:var(--c-warning-bg,#fffbeb);color:#92400e;border-radius:4px;padding:2px 6px">${UI.icon('note-pencil')} ${_esc(e.notiz)}</div>` : ''}
</div>
<div style="display:flex;gap:var(--space-1);flex-shrink:0">
<button class="btn btn-ghost btn-xs wl-edit-btn" data-entry-id="${e.id}" title="Bearbeiten">${UI.icon('pencil-simple')}</button>
<button class="btn btn-ghost btn-xs wl-delete-btn" data-entry-id="${e.id}" title="Entfernen" style="color:var(--c-danger)">${UI.icon('trash')}</button>
</div>
</div>`).join('')}
</div>`;
container.querySelectorAll('.wl-edit-btn').forEach(btn => {
btn.addEventListener('click', () => {
const entry = entries.find(e => e.id === parseInt(btn.dataset.entryId));
if (entry) _showWaitlistForm(litterId, entry);
});
});
container.querySelectorAll('.wl-delete-btn').forEach(btn => {
btn.addEventListener('click', async () => {
if (!window.confirm('Interessenten aus der Warteliste entfernen?')) return;
try {
await API.litters.removeWaitlist(parseInt(btn.dataset.entryId));
await _loadWaitlist(litterId);
} catch (err) { UI.toast.error(err.message || 'Fehler.'); }
});
});
}
function _showWaitlistForm(litterId, entry) {
const isEdit = !!entry;
const v = entry || {};
UI.modal.open({
title: isEdit ? 'Interessent bearbeiten' : 'Interessent eintragen',
body: `
<form id="wl-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Name *</label>
<input class="form-control" name="name" required value="${_esc(v.name || '')}">
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">E-Mail</label>
<input class="form-control" type="email" name="email" value="${_esc(v.email || '')}">
</div>
<div class="form-group">
<label class="form-label">Telefon</label>
<input class="form-control" name="telefon" value="${_esc(v.telefon || '')}">
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Wunsch Geschlecht</label>
<select class="form-control" name="wunsch_geschlecht">
<option value="egal" ${(!v.wunsch_geschlecht || v.wunsch_geschlecht === 'egal') ? 'selected' : ''}>Egal</option>
<option value="maennlich" ${v.wunsch_geschlecht === 'maennlich' ? 'selected' : ''}>Rüde </option>
<option value="weiblich" ${v.wunsch_geschlecht === 'weiblich' ? 'selected' : ''}>Hündin </option>
</select>
</div>
<div class="form-group">
<label class="form-label">Wunsch Farbe</label>
<input class="form-control" name="wunsch_farbe" placeholder="z.B. schwarz-weiß" value="${_esc(v.wunsch_farbe || '')}">
</div>
</div>
<div class="form-group">
<label class="form-label">Nachricht des Interessenten</label>
<textarea class="form-control" name="nachricht" rows="2" placeholder="Was hat der Interessent geschrieben?">${_esc(v.nachricht || '')}</textarea>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Status</label>
<select class="form-control" name="status">
${Object.entries(_WL_STATUS).map(([k, s]) => `<option value="${k}" ${(v.status || 'anfrage') === k ? 'selected' : ''}>${s.label}</option>`).join('')}
</select>
</div>
<div class="form-group">
<label class="form-label">Position</label>
<input class="form-control" type="number" name="prioritaet" min="0" value="${v.prioritaet ?? 0}">
</div>
</div>
<div class="form-group">
<label class="form-label">Interne Notiz</label>
<input class="form-control" name="notiz" placeholder="Nur für dich sichtbar" value="${_esc(v.notiz || '')}">
</div>
</form>`,
footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" form="wl-form" type="submit">${isEdit ? 'Speichern' : 'Eintragen'}</button>`,
});
document.getElementById('wl-form').addEventListener('submit', async e => {
e.preventDefault();
const fd = new FormData(e.target);
const data = {
name: fd.get('name')?.trim(),
email: fd.get('email')?.trim() || null,
telefon: fd.get('telefon')?.trim() || null,
nachricht: fd.get('nachricht')?.trim() || null,
wunsch_geschlecht: fd.get('wunsch_geschlecht'),
wunsch_farbe: fd.get('wunsch_farbe')?.trim() || null,
prioritaet: parseInt(fd.get('prioritaet')) || 0,
status: fd.get('status'),
notiz: fd.get('notiz')?.trim() || null,
};
try {
if (isEdit) {
await API.litters.updateWaitlist(entry.id, data);
} else {
await API.litters.addWaitlist(litterId, data);
}
UI.modal.close();
await _loadWaitlist(litterId);
UI.toast.success(isEdit ? 'Gespeichert.' : 'Interessent eingetragen.');
} catch (err) { UI.toast.error(err.message || 'Fehler.'); }
});
}
// ----------------------------------------------------------
// Wurf-Formular (neu / bearbeiten)
// ----------------------------------------------------------
@ -589,9 +930,29 @@ window.Page_litters = (() => {
value="${_esc(currentName || '')}" placeholder="oder Namen frei eingeben">`;
};
const rangOpts = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('').map(l =>
`<option value="${l}" ${v.wurf_rang === l ? 'selected' : ''}>${l}-Wurf</option>`
).join('');
const body = `
<form id="litter-form" autocomplete="off">
<div style="display:grid;grid-template-columns:1fr 2fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Wurf-Buchstabe</label>
<select class="form-control" name="wurf_rang">
<option value=""> kein </option>
${rangOpts}
</select>
</div>
<div class="form-group">
<label class="form-label">Wurf-Name <span style="font-weight:normal;color:var(--c-text-muted)">(optional)</span></label>
<input class="form-control" type="text" name="wurf_name"
placeholder="z.B. Vatertags-Wurf, Frühlings-Wurf …"
value="${_esc(v.wurf_name || '')}">
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Vater</label>
@ -706,6 +1067,8 @@ window.Page_litters = (() => {
const fd = new FormData(e.target);
const payload = {
wurf_rang: fd.get('wurf_rang') || null,
wurf_name: fd.get('wurf_name')?.trim() || null,
vater_name: fd.get('vater_name')?.trim() || null,
mutter_name: fd.get('mutter_name')?.trim() || null,
vater_id: fd.get('vater_id') ? parseInt(fd.get('vater_id')) : null,

View file

@ -700,7 +700,7 @@ window.Page_routes = (() => {
ovl.querySelector('#rk-rec-startbtn').addEventListener('click', _startRecInOvl);
}
function _startRecInOvl() {
async function _startRecInOvl() {
if (!navigator.geolocation) { UI.toast.error('GPS nicht verfügbar.'); return; }
_recActive = true;
_recTrack = []; _recDistKm = 0; _recStartTime = Date.now();

View file

@ -11,6 +11,8 @@ window.Page_zuchthunde = (() => {
let _hunde = []; // geladene Hunde
let _query = ''; // Suchtext
let _openSections = {}; // { <hundId>: 'health'|'genetic'|'titles'|null }
let _breederId = null; // ID des Züchter-Profils
let _breederInfo = null; // { zwingername, logo_url }
// ----------------------------------------------------------
// Hilfsfunktionen
@ -97,28 +99,68 @@ window.Page_zuchthunde = (() => {
// ----------------------------------------------------------
// Grundstruktur
// ----------------------------------------------------------
function _privateHeader() {
const zwinger = _breederInfo?.zwingername || 'Mein Zwinger';
const logoUrl = _breederInfo?.logo_url || null;
const logoHtml = logoUrl
? `<img src="${_esc(logoUrl)}" alt="Logo"
style="width:48px;height:48px;border-radius:50%;object-fit:cover;
border:2px solid rgba(196,132,58,.5);flex-shrink:0"
onerror="this.style.display='none'">`
: `<div style="width:48px;height:48px;border-radius:50%;background:rgba(196,132,58,.15);
border:2px solid rgba(196,132,58,.4);display:flex;align-items:center;
justify-content:center;flex-shrink:0">
<svg style="width:24px;height:24px;color:var(--c-primary)" viewBox="0 0 256 256">
<use href="/icons/phosphor.svg#paw-print"></use>
</svg>
</div>`;
return `
<div id="breeder-private-header" style="background:linear-gradient(135deg,#1a1208,#2d1f0e);
border-bottom:1px solid rgba(196,132,58,.25);
padding:var(--space-3) var(--space-4);
display:flex;align-items:center;gap:var(--space-3)">
${logoHtml}
<div style="flex:1;min-width:0">
<h2 style="margin:0 0 2px;font-size:var(--text-lg);font-weight:700;
color:rgba(255,255,255,.95);white-space:nowrap;overflow:hidden;
text-overflow:ellipsis;line-height:1.2">${_esc(zwinger)}</h2>
<div style="display:flex;align-items:center;gap:var(--space-2)">
<svg style="width:11px;height:11px;color:var(--c-primary);flex-shrink:0" viewBox="0 0 256 256">
<use href="/icons/phosphor.svg#lock-key"></use>
</svg>
<span style="font-size:var(--text-xs);color:rgba(196,132,58,.7)">Privater Bereich · Nur du siehst das</span>
</div>
</div>
</div>`;
}
function _render() {
_container.innerHTML = `
<div class="zh-layout">
<div class="by-toolbar">
<h2 style="margin:0;font-size:var(--text-lg);font-weight:var(--weight-semibold)">
${_privateHeader()}
<div class="by-toolbar" style="flex-wrap:wrap">
<h2 style="margin:0;font-size:var(--text-lg);font-weight:var(--weight-semibold);flex-shrink:0;white-space:nowrap">
${UI.icon('dog')} Zuchtkartei
</h2>
<button class="btn btn-primary btn-sm" id="zh-new-btn">
<button class="btn btn-primary btn-sm" id="zh-new-btn" style="flex-shrink:0">
${UI.icon('plus')} Hund anlegen
</button>
<button class="btn btn-secondary btn-sm" id="zh-trial-btn">
<button class="btn btn-secondary btn-sm" id="zh-trial-btn" style="flex-shrink:0;white-space:nowrap">
${UI.icon('heart-fill')} Probeverpaarung
</button>
<button class="btn btn-ghost btn-sm" id="zh-photos-btn" style="flex-shrink:0;white-space:nowrap"
title="Fotos & Logo für das Züchter-Profil verwalten">
${UI.icon('images')} Profilfotos
</button>
<a href="/api/breeder/export" download class="btn btn-ghost btn-sm" id="zh-export-btn"
title="Alle Daten herunterladen (HTML + ODS)">
style="flex-shrink:0" title="Alle Daten herunterladen (HTML + ODS)">
${UI.icon('download-simple')} Export
</a>
${_appState?.user?.ki_zucht_jahresbericht !== 0 ? `
<a class="btn btn-ghost btn-sm" id="zh-jahresbericht-btn">
<a class="btn btn-ghost btn-sm" id="zh-jahresbericht-btn" style="flex-shrink:0;white-space:nowrap">
${UI.icon('chart-bar')} Jahresbericht
</a>
<a class="btn btn-ghost btn-sm" id="zh-jahresbericht-archiv-btn" title="Frühere Berichte">
<a class="btn btn-ghost btn-sm" id="zh-jahresbericht-archiv-btn" style="flex-shrink:0" title="Frühere Berichte">
${UI.icon('archive')}
</a>` : ''}
</div>
@ -136,6 +178,10 @@ window.Page_zuchthunde = (() => {
document.getElementById('zh-trial-btn')?.addEventListener('click', () => _showTrialMatingModal());
document.getElementById('zh-jahresbericht-btn')?.addEventListener('click', () => _showJahresbericht());
document.getElementById('zh-jahresbericht-archiv-btn')?.addEventListener('click', () => _showJahresberichtArchiv());
document.getElementById('zh-photos-btn')?.addEventListener('click', () => {
if (!_breederId) { UI.toast.warning('Züchter-Profil noch nicht geladen.'); return; }
_showBreederPhotosModal(_breederId);
});
document.getElementById('zh-search')?.addEventListener('input', e => {
_query = e.target.value.toLowerCase().trim();
@ -148,7 +194,15 @@ window.Page_zuchthunde = (() => {
// ----------------------------------------------------------
async function _load() {
try {
_hunde = await API.zuchthunde.list();
[_hunde] = await Promise.all([
API.zuchthunde.list(),
API.breeder.status().then(s => {
_breederId = s?.profile?.id || null;
_breederInfo = s?.profile ? { zwingername: s.profile.zwingername, logo_url: s.profile.logo_url } : null;
const h = _container?.querySelector('#breeder-private-header');
if (h) h.outerHTML = _privateHeader();
}).catch(() => {}),
]);
_renderList();
} catch (err) {
const el = document.getElementById('zh-list');
@ -298,14 +352,14 @@ window.Page_zuchthunde = (() => {
</div>
</div>
<div class="zh-section-buttons">
<button class="btn btn-ghost btn-sm zh-section-btn" data-id="${h.id}" data-section="health">
<div class="by-tabs" style="padding:var(--space-2) var(--space-3) var(--space-3);flex-wrap:wrap;overflow-x:visible">
<button class="by-tab zh-section-btn" data-id="${h.id}" data-section="health">
${UI.icon('heart')} Gesundheit
</button>
<button class="btn btn-ghost btn-sm zh-section-btn" data-id="${h.id}" data-section="genetic">
<button class="by-tab zh-section-btn" data-id="${h.id}" data-section="genetic">
${UI.icon('dna')} Genetik
</button>
<button class="btn btn-ghost btn-sm zh-section-btn" data-id="${h.id}" data-section="titles">
<button class="by-tab zh-section-btn" data-id="${h.id}" data-section="titles">
${UI.icon('trophy')} Titel
</button>
</div>
@ -350,9 +404,7 @@ window.Page_zuchthunde = (() => {
const card = document.getElementById(`zh-card-${hundId}`);
if (!card) return;
card.querySelectorAll('.zh-section-btn').forEach(btn => {
const isActive = btn.dataset.section === activeSection;
btn.style.fontWeight = isActive ? 'var(--weight-semibold)' : '';
btn.style.color = isActive ? 'var(--c-primary)' : '';
btn.classList.toggle('active', btn.dataset.section === activeSection);
});
}
@ -1649,6 +1701,137 @@ window.Page_zuchthunde = (() => {
document.head.appendChild(s);
})();
// ----------------------------------------------------------
// Profilfotos & Logo verwalten
// ----------------------------------------------------------
async function _showBreederPhotosModal(breederId) {
const galleryId = 'bp-gallery';
const visLabels = {
public: { text: 'Öffentlich', color: '#16a34a' },
inquiry: { text: 'Anfrage', color: '#f59e0b' },
private: { text: 'Privat', color: '#6b7280' },
};
const visOrder = ['public', 'inquiry', 'private'];
UI.modal.open({
title: `${UI.icon('images')} Züchter-Profilfotos & Logo`,
body: `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0 0 var(--space-3)">
Diese Fotos erscheinen im öffentlichen Züchterprofil. Das primäre Foto wird als <strong>Logo</strong> im Hero angezeigt.
</p>
<div id="${galleryId}" style="margin-bottom:var(--space-4)">
<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Lädt</p>
</div>
<hr style="margin:var(--space-3) 0;border:none;border-top:1px solid var(--c-border)">
<form id="bp-upload-form" style="display:flex;flex-direction:column;gap:var(--space-2)">
<label style="font-size:var(--text-sm);font-weight:600">${UI.icon('upload-simple')} Foto hochladen</label>
<input class="form-control" type="file" name="file" accept="image/*" required>
<input class="form-control" type="text" name="caption" placeholder="Bildunterschrift (optional)">
</form>`,
footer: `<button type="submit" form="bp-upload-form" class="btn btn-primary" id="bp-upload-btn">
${UI.icon('upload-simple')} Hochladen
</button>`,
});
async function _loadGallery() {
const el = document.getElementById(galleryId);
if (!el) return;
try {
const photos = await API.breederPhotos.list('breeder', breederId);
if (!photos.length) {
el.innerHTML = `<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Noch keine Fotos — lade das erste hoch.</p>`;
return;
}
el.innerHTML = `
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(100px,1fr));gap:var(--space-2)">
${photos.map(ph => {
const thumb = ph.thumbnail_url || ph.url || '';
const vis = visLabels[ph.visibility] || visLabels.private;
const isPrimary = ph.is_primary;
return `
<div style="position:relative;border-radius:var(--radius-md);overflow:hidden;
border:${isPrimary ? '2px solid var(--c-primary)' : '1px solid var(--c-border)'};aspect-ratio:1"
data-photo-id="${ph.id}">
<a href="${_esc(ph.url || '')}" target="_blank" rel="noopener noreferrer">
<img src="${_esc(thumb)}" alt="${_esc(ph.caption || '')}"
loading="lazy" style="width:100%;height:100%;object-fit:cover;display:block"
onerror="this.parentElement.parentElement.style.opacity='.4'">
</a>
${isPrimary ? `<span style="position:absolute;top:3px;left:3px;background:var(--c-primary);color:white;
font-size:9px;font-weight:700;border-radius:999px;padding:1px 5px">Logo</span>` : ''}
<!-- Sichtbarkeit -->
<button class="bp-vis-btn" data-photo-id="${ph.id}" data-vis="${_esc(ph.visibility)}"
style="position:absolute;bottom:0;left:0;right:0;background:${vis.color};color:#fff;
border:none;cursor:pointer;font-size:9px;padding:2px 4px;font-weight:700">
${vis.text}
</button>
<!-- Als Logo setzen -->
${!isPrimary ? `<button class="bp-primary-btn" data-photo-id="${ph.id}" title="Als Logo setzen"
style="position:absolute;top:2px;right:24px;background:rgba(0,0,0,.5);color:#fff;
border:none;border-radius:50%;cursor:pointer;width:20px;height:20px;
display:flex;align-items:center;justify-content:center;font-size:10px">
${UI.icon('star')}
</button>` : ''}
<!-- Löschen -->
<button class="bp-del-btn" data-photo-id="${ph.id}" title="Löschen"
style="position:absolute;top:2px;right:2px;background:rgba(0,0,0,.5);color:#fff;
border:none;border-radius:50%;cursor:pointer;width:20px;height:20px;
display:flex;align-items:center;justify-content:center;font-size:10px">
${UI.icon('x')}
</button>
</div>`;
}).join('')}
</div>`;
el.querySelectorAll('.bp-vis-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const next = visOrder[(visOrder.indexOf(btn.dataset.vis) + 1) % visOrder.length];
try { await API.breederPhotos.updateVisibility(parseInt(btn.dataset.photoId), next); _loadGallery(); }
catch (err) { UI.toast.error(err.message); }
});
});
el.querySelectorAll('.bp-primary-btn').forEach(btn => {
btn.addEventListener('click', async () => {
try { await API.breederPhotos.setPrimary(parseInt(btn.dataset.photoId)); _loadGallery(); UI.toast.success('Als Logo gesetzt.'); }
catch (err) { UI.toast.error(err.message); }
});
});
el.querySelectorAll('.bp-del-btn').forEach(btn => {
btn.addEventListener('click', async () => {
if (!window.confirm('Foto löschen?')) return;
try { await API.breederPhotos.remove(parseInt(btn.dataset.photoId)); _loadGallery(); }
catch (err) { UI.toast.error(err.message); }
});
});
} catch (err) {
const el = document.getElementById(galleryId);
if (el) el.innerHTML = `<p style="color:var(--c-danger)">${_esc(err.message || 'Fehler')}</p>`;
}
}
_loadGallery();
document.getElementById('bp-upload-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = document.getElementById('bp-upload-btn');
const fileInput = e.target.querySelector('[name="file"]');
const caption = e.target.querySelector('[name="caption"]')?.value?.trim() || '';
if (!fileInput?.files?.length) { UI.toast.error('Bitte Datei auswählen.'); return; }
const fd = new FormData();
fd.append('entity_type', 'breeder');
fd.append('entity_id', String(breederId));
fd.append('visibility', 'public');
fd.append('caption', caption);
fd.append('file', fileInput.files[0]);
await UI.asyncButton(btn, async () => {
await API.breederPhotos.upload(fd);
UI.toast.success('Foto hochgeladen.');
e.target.reset();
await _loadGallery();
});
});
}
return { init, refresh, onDogChange };
})();

View file

@ -76,6 +76,9 @@ const UI = (() => {
`;
overlay.querySelector('.modal-close-btn')?.addEventListener('click', close);
overlay.addEventListener('click', e => {
if (e.target.closest('[data-modal-close]')) close();
});
document.getElementById('modal-container').appendChild(overlay);
document.documentElement.classList.add('modal-open');

View file

@ -546,6 +546,7 @@ window.Worlds = (() => {
fab:[{ icon:'tree-structure', color:'#8B5CF6', label:'Zuchthund eintragen', sub:'Neuen Hund anlegen', page:'zuchthunde', action:'openNew' }] },
{ icon:'notebook', label:'Wurfverw.', page:'litters', role:'breeder',
fab:[{ icon:'notebook', color:'#10B981', label:'Wurf anlegen', sub:'Neuen Wurf eintragen', page:'litters', action:'openNew' }] },
{ icon:'thermometer', label:'Läufigkeit', page:'laeufi', role:'breeder' },
{ icon:'sparkle', label:'Social', page:'social', role:'social',
fab:[{ icon:'sparkle', color:'#EC4899', label:'Social-Post', sub:'Beitrag erstellen', page:'social', action:'openNew' }] },
{ icon:'shield-check', label:'Moderation', page:'moderation', role:'mod' },
@ -560,7 +561,7 @@ window.Worlds = (() => {
const _DEFAULT_CONFIG = {
jetzt: ['notes','expenses','erste-hilfe','playdate','chat','wetter','social','moderation','admin'],
hund: ['diary','health','uebungen','trainingsplaene','adoption','sitting','wiki','wurfboerse',
'litters','zuchthunde','ernaehrung','personality'],
'litters','zuchthunde','laeufi','ernaehrung','personality'],
welt: ['map','forum','friends','walks','poison','recalls','lost','routes','events',
'jobs','knigge','movies','reise'],
};
@ -568,24 +569,38 @@ window.Worlds = (() => {
// _cfgCache: wird beim Init aus DB geladen, Fallback localStorage → Default
let _cfgCache = null;
function _mergeDefaults(cfg) {
// Neue Default-Chips die noch nicht in der gespeicherten Config sind → anhängen
const result = JSON.parse(JSON.stringify(cfg));
for (const world of ['jetzt', 'hund', 'welt']) {
const def = _DEFAULT_CONFIG[world] || [];
const saved = result[world] || [];
for (const page of def) {
if (!saved.includes(page)) saved.push(page);
}
result[world] = saved;
}
return result;
}
async function _loadConfigFromServer() {
try {
const res = await API.get('/profile/world-config');
if (res?.config) {
_cfgCache = res.config;
_cfgCache = _mergeDefaults(res.config);
try { localStorage.setItem('world_chips', JSON.stringify(_cfgCache)); } catch {}
return;
}
// Noch nichts in DB: lokale Config hochladen (einmalige Migration)
const local = (() => { try { return JSON.parse(localStorage.getItem('world_chips') || 'null'); } catch { return null; } })();
if (local) {
_cfgCache = local;
API.put('/profile/world-config', { config: local }).catch(() => {});
_cfgCache = _mergeDefaults(local);
API.put('/profile/world-config', { config: _cfgCache }).catch(() => {});
return;
}
} catch {}
// Fallback: localStorage → Default
try { _cfgCache = JSON.parse(localStorage.getItem('world_chips') || 'null') || _DEFAULT_CONFIG; }
try { _cfgCache = _mergeDefaults(JSON.parse(localStorage.getItem('world_chips') || 'null') || _DEFAULT_CONFIG); }
catch { _cfgCache = _DEFAULT_CONFIG; }
}
@ -979,12 +994,15 @@ window.Worlds = (() => {
return u.rolle === 'admin' || u.rolle === 'moderator' || u.is_moderator || u.is_social_media;
}
function _chip(icon, label, page, locked = false, proBadge = false) {
function _chip(icon, label, page, locked = false, proBadge = false, breederBadge = false) {
const style = locked ? 'opacity:0.25;cursor:default;' : '';
const badge = proBadge
? `<span style="position:absolute;top:2px;left:3px;font-size:8px;font-weight:800;
color:#fff;background:#92400e;border-radius:3px;padding:0 3px;line-height:14px">P</span>`
: '';
: breederBadge
? `<span style="position:absolute;top:2px;left:3px;font-size:8px;font-weight:800;
color:#fff;background:#1d4ed8;border-radius:3px;padding:0 3px;line-height:14px">Z</span>`
: '';
return `
<div class="world-chip" ${locked ? '' : `data-wnav="${page}"`}
style="${style}position:relative">
@ -1139,7 +1157,7 @@ window.Worlds = (() => {
<div class="world-bottom">
<div class="world-section-label">Deine Bereiche</div>
<div class="world-chips-grid">
${features.map(f => _chip(f.icon, f.label, f.page, false, f.pro && _isRoleBasedPro())).join('')}
${features.map(f => _chip(f.icon, f.label, f.page, false, f.pro && _isRoleBasedPro(), f.role === 'breeder')).join('')}
</div>
<div class="world-footer-links">
<span data-wnav="impressum">Impressum</span>
@ -1430,7 +1448,7 @@ window.Worlds = (() => {
` : ''}
<div class="world-section-label">Alles über ${_esc(dog.name)}</div>
<div class="world-chips-grid">
${chips.map(c => _chip(c.icon, c.label, c.page, false, c.pro && _isRoleBasedPro())).join('')}
${chips.map(c => _chip(c.icon, c.label, c.page, false, c.pro && _isRoleBasedPro(), c.role === 'breeder')).join('')}
</div>
<div class="world-footer-links">
<span data-wnav="gruender">Die 100 Gründer</span>
@ -1604,7 +1622,7 @@ window.Worlds = (() => {
<div class="world-bottom">
<div class="world-section-label">Die Welt da draußen</div>
<div class="world-chips-grid">
${chips.map(c => _chip(c.icon, c.label, c.page, false, c.pro && _isRoleBasedPro())).join('')}
${chips.map(c => _chip(c.icon, c.label, c.page, false, c.pro && _isRoleBasedPro(), c.role === 'breeder')).join('')}
</div>
<div class="world-footer-links">
<span data-wnav="datenschutz">Datenschutz</span>

View file

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

View file

@ -0,0 +1,572 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ban Yaro für Züchter — Zucht-Management für seriöse Hundezüchter</title>
<meta name="description" content="Stammbaum, Inzuchtkoeffizient, Gesundheitstests, Wurfverwaltung, Kaufvertrag, KI-Assistent — das komplette Züchter-Tool. Kostenlos starten, DSGVO-konform, Server in Deutschland.">
<meta name="keywords" content="Hundezucht Software, Züchter App Deutschland, Wurfverwaltung App, Stammbaum Hund App kostenlos, Inzuchtkoeffizient berechnen, Zuchtkartei digital, Welpen App Züchter, Kaufvertrag Hundewelpen, Gesundheitstests Hund Zucht, HD ED Dokumentation, Gentests Hund, VDH Züchter App, Wurfprotokoll App">
<meta name="robots" content="index, follow">
<link rel="canonical" href="https://banyaro.app/zuechter">
<meta property="og:type" content="website">
<meta property="og:title" content="Ban Yaro für Züchter — Zucht-Management für seriöse Hundezüchter">
<meta property="og:description" content="Stammbaum, IK-Berechnung, Gesundheitstests, Wurfverwaltung, Kaufvertrag, KI-Assistent — kostenlos, DSGVO-konform, Made in Germany.">
<meta property="og:url" content="https://banyaro.app/zuechter">
<meta property="og:image" content="https://banyaro.app/icons/icon-512.png">
<meta property="og:locale" content="de_DE">
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "Ban Yaro — Züchter-Tool",
"description": "Digitales Zucht-Management für seriöse Hundezüchter: Stammbaum, Inzuchtkoeffizient nach Wright, Gesundheitstests, Gentests, Wurfverwaltung, Kaufvertrag-Generator, KI-Assistent.",
"url": "https://banyaro.app/zuechter",
"applicationCategory": "BusinessApplication",
"operatingSystem": "iOS, Android, Web",
"inLanguage": "de",
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "EUR" },
"audience": { "@type": "Audience", "audienceType": "Hundezüchter, VDH-Züchter, Rassehundzüchter" },
"featureList": [
"Stammbaum bis 4 Generationen grafisch",
"Inzuchtkoeffizient nach Wright mit Ampel-Bewertung",
"Probeverpaarung mit IK-Simulation",
"Gesundheitstests HD ED OCD Augen Herz Patella ZTP",
"Gentests mit genetischer Risikoanalyse",
"Wurfverwaltung mit Welpengewichten und Fotos",
"Kaufvertrag automatisch generieren",
"KI-Assistent für Wurfankündigungen und Paarungsanalyse",
"Tierschutz-Check automatisch bei jeder Verpaarung",
"Öffentliche Wurfbörse",
"Datenexport HTML und ODS"
]
}
</script>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--primary: #C4843A;
--primary-dark: #a86e2e;
--primary-light: #f5e6d3;
--text: #1a1a1a;
--text-secondary: #555;
--text-muted: #888;
--bg: #FAF7F2;
--surface: #fff;
--border: #e8ddd0;
--radius: 12px;
--green: #16a34a;
--red: #dc2626;
}
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: var(--bg); color: var(--text); line-height: 1.6; }
a { color: var(--primary); text-decoration: none; }
a:hover { text-decoration: underline; }
.container { max-width: 900px; margin: 0 auto; padding: 0 1.5rem; }
/* Header */
header {
background: linear-gradient(135deg, #2d1f0e 0%, #6b3d1a 50%, #C4843A 100%);
color: white;
padding: 3.5rem 0 4.5rem;
text-align: center;
}
.header-logo { display: flex; align-items: center; justify-content: center; gap: 1rem; margin-bottom: 1.5rem; }
.header-logo img { width: 56px; height: 56px; border-radius: 14px; box-shadow: 0 4px 20px rgba(0,0,0,.3); }
.header-logo .logo-name { font-size: 1.6rem; font-weight: 800; letter-spacing: -0.02em; }
.header-eyebrow { font-size: 0.8rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.12em; opacity: 0.7; margin-bottom: 0.75rem; }
header h1 { font-size: clamp(1.6rem, 4.5vw, 2.8rem); font-weight: 800; margin-bottom: 1.25rem; line-height: 1.15; }
header h1 em { font-style: normal; color: #f5c07a; }
header p { font-size: 1.1rem; opacity: 0.88; max-width: 580px; margin: 0 auto 2rem; }
.header-badges { display: flex; gap: 0.75rem; justify-content: center; flex-wrap: wrap; margin-bottom: 2rem; }
.badge { background: rgba(255,255,255,.15); border: 1px solid rgba(255,255,255,.35); border-radius: 999px; padding: 0.35rem 1rem; font-size: 0.82rem; font-weight: 600; }
.cta-btn { display: inline-block; background: white; color: var(--primary-dark); font-weight: 700; font-size: 1.05rem; padding: 0.85rem 2.5rem; border-radius: 999px; box-shadow: 0 4px 20px rgba(0,0,0,.2); transition: transform .15s, box-shadow .15s; }
.cta-btn:hover { transform: translateY(-2px); box-shadow: 0 6px 28px rgba(0,0,0,.28); text-decoration: none; }
.cta-btn-secondary { display: inline-block; color: rgba(255,255,255,.8); font-size: 0.9rem; margin-left: 1.5rem; }
.cta-btn-secondary:hover { color: white; text-decoration: none; }
/* Nav */
nav { background: white; border-bottom: 1px solid var(--border); padding: 0.75rem 0; position: sticky; top: 0; z-index: 10; }
nav .container { display: flex; gap: 1.25rem; flex-wrap: wrap; align-items: center; }
nav a { font-size: 0.88rem; font-weight: 500; color: var(--text-secondary); }
nav a:hover { color: var(--primary); text-decoration: none; }
nav .nav-brand { font-weight: 800; color: var(--primary); margin-right: auto; font-size: 0.95rem; }
.nav-cta { background: var(--primary); color: white !important; padding: 0.35rem 1rem; border-radius: 999px; font-weight: 700 !important; }
.nav-cta:hover { opacity: 0.9; }
/* Sections */
section { padding: 4rem 0; }
section:nth-child(even) { background: white; }
h2 { font-size: clamp(1.4rem, 3vw, 2rem); font-weight: 700; color: var(--text); margin-bottom: 0.5rem; }
.section-intro { color: var(--text-secondary); font-size: 1.05rem; margin-bottom: 2.5rem; max-width: 620px; }
.label { font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.1em; color: var(--primary-dark); background: var(--primary-light); display: inline-block; padding: 0.25rem 0.75rem; border-radius: 999px; margin-bottom: 0.75rem; }
/* Pain points */
.pain-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 1rem; margin-top: 2rem; }
.pain-card { background: #fff7f0; border: 1px solid #f5dcc4; border-radius: var(--radius); padding: 1.25rem 1.5rem; }
.pain-card .pain-icon { font-size: 1.5rem; margin-bottom: 0.5rem; }
.pain-card h3 { font-size: 0.95rem; font-weight: 700; margin-bottom: 0.3rem; }
.pain-card p { font-size: 0.85rem; color: var(--text-secondary); margin: 0; }
/* Features */
.feature-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 1rem; margin-top: 2rem; }
.feature-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.25rem 1.5rem; display: flex; gap: 1rem; align-items: flex-start; }
section:nth-child(even) .feature-card { background: var(--bg); }
.feature-icon { width: 2.2rem; height: 2.2rem; flex-shrink: 0; background: var(--primary-light); border-radius: 10px; display: flex; align-items: center; justify-content: center; }
.feature-icon svg { width: 1.3rem; height: 1.3rem; color: var(--primary); }
.feature-card h3 { font-size: 0.95rem; font-weight: 700; margin-bottom: 0.25rem; }
.feature-card p { font-size: 0.84rem; color: var(--text-secondary); line-height: 1.5; margin: 0; }
.feature-tag { display: inline-block; background: var(--primary-light); color: var(--primary-dark); font-size: 0.68rem; font-weight: 700; padding: 0.15rem 0.5rem; border-radius: 999px; margin-top: 0.4rem; text-transform: uppercase; letter-spacing: 0.04em; }
/* Comparison */
.table-wrap { overflow-x: auto; margin-top: 2rem; border-radius: var(--radius); border: 1px solid var(--border); }
table { width: 100%; border-collapse: collapse; font-size: 0.88rem; min-width: 560px; }
thead th { background: #2d1f0e; color: white; font-weight: 600; padding: 0.8rem 1rem; text-align: left; }
thead th:first-child { border-radius: var(--radius) 0 0 0; }
thead th:last-child { border-radius: 0 var(--radius) 0 0; }
thead th.highlight { background: var(--primary); }
td { padding: 0.65rem 1rem; border-bottom: 1px solid var(--border); }
tr:last-child td { border-bottom: none; }
tr:nth-child(even) td { background: #faf7f2; }
.check { color: var(--green); font-weight: 700; }
.cross { color: #aaa; }
.hl { font-weight: 700; color: var(--primary-dark); }
/* USP strip */
.moat-box { background: linear-gradient(135deg, #2d1f0e, #6b3d1a); border-radius: 16px; padding: 2.5rem; color: white; margin-top: 2.5rem; }
.moat-box h3 { font-size: 1.3rem; font-weight: 800; margin-bottom: 0.75rem; color: #f5c07a; }
.moat-box p { font-size: 0.95rem; opacity: 0.88; line-height: 1.7; margin: 0; }
/* Steps */
.steps { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1.5rem; margin-top: 2.5rem; counter-reset: step; }
.step { counter-increment: step; position: relative; padding: 1.5rem; background: white; border-radius: var(--radius); border: 1px solid var(--border); }
.step::before { content: counter(step); position: absolute; top: -14px; left: 1.5rem; background: var(--primary); color: white; width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 800; font-size: 0.85rem; }
.step h3 { font-size: 0.95rem; font-weight: 700; margin-bottom: 0.3rem; }
.step p { font-size: 0.84rem; color: var(--text-secondary); margin: 0; }
/* CTA section */
.cta-section { background: linear-gradient(135deg, #2d1f0e, #6b3d1a); text-align: center; padding: 5rem 0; }
.cta-section h2 { color: white; margin-bottom: 1rem; }
.cta-section p { color: rgba(255,255,255,.75); font-size: 1.05rem; max-width: 500px; margin: 0 auto 2rem; }
.cta-section .cta-btn { font-size: 1.1rem; padding: 1rem 3rem; }
/* Footer */
footer { background: #1a1a1a; color: #aaa; padding: 2.5rem 0; text-align: center; font-size: 0.85rem; }
footer a { color: var(--primary); }
footer .footer-links { margin-top: 0.75rem; display: flex; gap: 1.5rem; justify-content: center; flex-wrap: wrap; }
@media (max-width: 600px) {
.cta-btn-secondary { display: block; margin: 1rem auto 0; }
}
</style>
</head>
<body>
<header>
<div class="container">
<div class="header-logo">
<img src="/icons/icon-180.png" alt="Ban Yaro">
<span class="logo-name">Ban Yaro</span>
</div>
<p class="header-eyebrow">Für Züchter</p>
<h1>Zucht-Management für<br><em>seriöse Hundezüchter</em></h1>
<p>Stammbaum, IK-Berechnung, Gesundheitstests, Wurfverwaltung, Kaufvertrag, KI-Assistent — alles in einer App. Kostenlos. In Deutschland.</p>
<div class="header-badges">
<span class="badge">Kostenlos starten</span>
<span class="badge">Server in Deutschland</span>
<span class="badge">DSGVO-konform</span>
<span class="badge">VDH-kompatibel</span>
<span class="badge">Kein App Store nötig</span>
</div>
<a href="/#register?rolle=breeder" class="cta-btn" onclick="sessionStorage.setItem('by_stay_in_app','1')">Jetzt als Züchter registrieren</a>
<a href="#funktionen" class="cta-btn-secondary">Alle Features ansehen ↓</a>
</div>
</header>
<nav>
<div class="container">
<span class="nav-brand">Ban Yaro · Züchter</span>
<a href="#schmerz">Probleme</a>
<a href="#funktionen">Features</a>
<a href="#vergleich">Vergleich</a>
<a href="#vorteil">Alleinstellung</a>
<a href="#start">Loslegen</a>
<a href="/" onclick="sessionStorage.setItem('by_stay_in_app','1')">Zur App</a>
<a href="/#register?rolle=breeder" class="nav-cta" onclick="sessionStorage.setItem('by_stay_in_app','1')">Registrieren</a>
</div>
</nav>
<!-- Schmerz-Sektion -->
<section id="schmerz">
<div class="container">
<span class="label">Das kennen alle Züchter</span>
<h2>Züchten ist ein Vollzeitjob.<br>Die Verwaltung dazu auch.</h2>
<p class="section-intro">Gesundheitstests in einer Tabelle, Wurfgewichte in einer anderen, Käufer in WhatsApp, Kaufvertrag in Word. Nichts ist verknüpft. Das kostet Zeit — die du besser mit deinen Hunden verbringst.</p>
<div class="pain-grid">
<div class="pain-card">
<div class="pain-icon">📊</div>
<h3>Excel-Chaos</h3>
<p>Welpengewichte in einer Datei, Gesundheitstests in einer anderen, Kosten irgendwo sonst. Nichts hängt zusammen.</p>
</div>
<div class="pain-card">
<div class="pain-icon">🧮</div>
<h3>IK manuell berechnen</h3>
<p>Inzuchtkoeffizient vor der Verpaarung prüfen? Drei Websites, manuelles Eintippen der Ahnentafel, Kopfrechnen.</p>
</div>
<div class="pain-card">
<div class="pain-icon">📱</div>
<h3>Warteliste in WhatsApp</h3>
<p>Wer wollte ein Mädchen? Wer hat wann angefragt? Wer steht wo auf der Liste? Alles in Chatverläufen vergraben.</p>
</div>
<div class="pain-card">
<div class="pain-icon">📝</div>
<h3>Kaufvertrag kopieren</h3>
<p>Altes Word-Dokument öffnen, Namen ändern, vergessen zwei Felder anzupassen, ausdrucken, faxen.</p>
</div>
<div class="pain-card">
<div class="pain-icon"></div>
<h3>ECVO vergessen</h3>
<p>Der Augentest muss jährlich erneuert werden. Wann war der letzte? Irgendwo auf einem Zettel steht das Datum.</p>
</div>
<div class="pain-card">
<div class="pain-icon">💸</div>
<h3>Was hat der Wurf gekostet?</h3>
<p>Deckgebühr, Progesterontests, Tierarzt, Futter, Material — kein Züchter kann das am Ende wirklich sagen.</p>
</div>
</div>
</div>
</section>
<!-- Feature-Sektion -->
<section id="funktionen">
<div class="container">
<span class="label">Features</span>
<h2>Alles was seriöse Züchter brauchen.<br>In einer App.</h2>
<p class="section-intro">Von der Zuchtzulassung bis zur Welpenabgabe — Ban Yaro begleitet jeden Schritt. Kostenlos, ohne Installation, von jedem Gerät.</p>
<!-- Zuchtkartei -->
<h3 style="font-size:1rem;color:var(--text-muted);font-weight:600;text-transform:uppercase;letter-spacing:.08em;margin-bottom:1rem;margin-top:0.5rem">Zuchtkartei & Genetik</h3>
<div class="feature-grid">
<div class="feature-card">
<span class="feature-icon"><svg viewBox="0 0 256 256"><use href="/icons/phosphor.svg#tree-structure"></use></svg></span>
<div>
<h3>Stammbaum bis 4 Generationen</h3>
<p>Grafische Darstellung aller Vorfahren — Vater, Mutter, Großeltern, Urgroßeltern. Sofort sichtbar welche Linien sich kreuzen.</p>
</div>
</div>
<div class="feature-card">
<span class="feature-icon"><svg viewBox="0 0 256 256"><use href="/icons/phosphor.svg#calculator"></use></svg></span>
<div>
<h3>Inzuchtkoeffizient nach Wright</h3>
<p>Automatische IK-Berechnung über 8 Generationen. Ampel-Bewertung: optimal · akzeptabel · erhöht · kritisch. Keine Website, kein Kopfrechnen.</p>
<span class="feature-tag">Einzigartig</span>
</div>
</div>
<div class="feature-card">
<span class="feature-icon"><svg viewBox="0 0 256 256"><use href="/icons/phosphor.svg#test-tube"></use></svg></span>
<div>
<h3>Probeverpaarung</h3>
<p>IK simulieren bevor du den Deckrüden anfragst. Welche Kombination ist genetisch am besten? Sofort sichtbar.</p>
<span class="feature-tag">Einzigartig</span>
</div>
</div>
<div class="feature-card">
<span class="feature-icon"><svg viewBox="0 0 256 256"><use href="/icons/phosphor.svg#heartbeat"></use></svg></span>
<div>
<h3>Gesundheitstests</h3>
<p>HD, ED, OCD, Augen (ECVO), Herz, Patella, ZTP — mit Datum, Gutachter, Zertifikatsnummer, Gültigkeitsdatum und automatischen Erinnerungen.</p>
</div>
</div>
<div class="feature-card">
<span class="feature-icon"><svg viewBox="0 0 256 256"><use href="/icons/phosphor.svg#dna"></use></svg></span>
<div>
<h3>Gentests & Risikoanalyse</h3>
<p>MDR1, PRA, DM, vWD und weitere Marker. Automatische Risikoanalyse für die Kombination: klar × Träger × betroffen — welche Nachkommen sind zu erwarten?</p>
</div>
</div>
<div class="feature-card">
<span class="feature-icon"><svg viewBox="0 0 256 256"><use href="/icons/phosphor.svg#trophy"></use></svg></span>
<div>
<h3>Titel & Auszeichnungen</h3>
<p>Ausstellungs-, Arbeits-, Sport-, Zucht- und Champion-Titel dokumentieren — mit Datum, Richter, Formwert. Für den öffentlichen Steckbrief des Hundes.</p>
</div>
</div>
</div>
<!-- Wurfverwaltung -->
<h3 style="font-size:1rem;color:var(--text-muted);font-weight:600;text-transform:uppercase;letter-spacing:.08em;margin:2.5rem 0 1rem">Wurfverwaltung & Welpen</h3>
<div class="feature-grid">
<div class="feature-card">
<span class="feature-icon"><svg viewBox="0 0 256 256"><use href="/icons/phosphor.svg#baby"></use></svg></span>
<div>
<h3>Wurfverwaltung</h3>
<p>Jeden Wurf anlegen mit geplanten und tatsächlichem Geburtsdatum, Elterntieren, Anzahl Welpen und Verfügbarkeitsstatus.</p>
</div>
</div>
<div class="feature-card">
<span class="feature-icon"><svg viewBox="0 0 256 256"><use href="/icons/phosphor.svg#chart-line-up"></use></svg></span>
<div>
<h3>Welpengewichte & Entwicklung</h3>
<p>Tägliche Gewichtserfassung für jeden Welpen einzeln. Wachstumskurve auf einen Blick — aus dem Smartphone heraus am Wurfplatz.</p>
</div>
</div>
<div class="feature-card">
<span class="feature-icon"><svg viewBox="0 0 256 256"><use href="/icons/phosphor.svg#camera"></use></svg></span>
<div>
<h3>Fotos für jeden Welpen</h3>
<p>Fotos hochladen und je Welpe zuordnen — mit Sichtbarkeitssteuerung: öffentlich, nur auf Anfrage oder privat.</p>
</div>
</div>
<div class="feature-card">
<span class="feature-icon"><svg viewBox="0 0 256 256"><use href="/icons/phosphor.svg#file-text"></use></svg></span>
<div>
<h3>Kaufvertrag-Generator</h3>
<p>Rechtssicherer Kaufvertrag automatisch befüllt — Züchter, Käufer, Welpe, Chip, Gesundheitsstatus, Impfungen. Zum Ausdrucken oder digital unterschreiben.</p>
<span class="feature-tag">Automatisch</span>
</div>
</div>
<div class="feature-card">
<span class="feature-icon"><svg viewBox="0 0 256 256"><use href="/icons/phosphor.svg#shield-check"></use></svg></span>
<div>
<h3>Tierschutz-Check</h3>
<p>Automatische Bewertung jeder Verpaarung auf Basis von IK und Gentests. Bei kritischen Werten: Warnung und Admin-Meldung. Verantwortungsvolle Zucht by Default.</p>
<span class="feature-tag">Einzigartig</span>
</div>
</div>
<div class="feature-card">
<span class="feature-icon"><svg viewBox="0 0 256 256"><use href="/icons/phosphor.svg#export"></use></svg></span>
<div>
<h3>Datenexport</h3>
<p>Kompletten Zuchtbericht als HTML (druckbereit, mit Stammbaum) oder ODS-Spreadsheet (für Excel/LibreOffice) exportieren. Deine Daten gehören dir.</p>
</div>
</div>
</div>
<!-- KI & Öffentlichkeit -->
<h3 style="font-size:1rem;color:var(--text-muted);font-weight:600;text-transform:uppercase;letter-spacing:.08em;margin:2.5rem 0 1rem">KI-Assistent & Präsenz</h3>
<div class="feature-grid">
<div class="feature-card">
<span class="feature-icon"><svg viewBox="0 0 256 256"><use href="/icons/phosphor.svg#robot"></use></svg></span>
<div>
<h3>KI-Wurfankündigung</h3>
<p>Professioneller Ankündigungstext für deinen Wurf — aus den Elterndaten, Gesundheitstests und Linienbeschreibung. In Sekunden, anpassbar.</p>
<span class="feature-tag">KI</span>
</div>
</div>
<div class="feature-card">
<span class="feature-icon"><svg viewBox="0 0 256 256"><use href="/icons/phosphor.svg#intersect"></use></svg></span>
<div>
<h3>KI-Paarungsanalyse</h3>
<p>KI bewertet die geplante Verpaarung: IK, Gesundheitsprofil beider Eltern, genetische Risiken — mit klarer Empfehlung: empfohlen / bedingt / nicht empfohlen.</p>
<span class="feature-tag">KI</span>
</div>
</div>
<div class="feature-card">
<span class="feature-icon"><svg viewBox="0 0 256 256"><use href="/icons/phosphor.svg#storefront"></use></svg></span>
<div>
<h3>Öffentliche Wurfbörse</h3>
<p>Deine verfügbaren Würfe erscheinen automatisch in der Banyaro-Wurfbörse — für über tausend Hundebesitzer sichtbar, filterbar nach Rasse.</p>
</div>
</div>
<div class="feature-card">
<span class="feature-icon"><svg viewBox="0 0 256 256"><use href="/icons/phosphor.svg#user-circle"></use></svg></span>
<div>
<h3>Öffentliches Züchter-Profil</h3>
<p>Dein Zwingername, deine Rassen, deine verifizierten Zuchthunde — öffentlich einsehbar für Käufer die aktiv nach deiner Linie suchen.</p>
</div>
</div>
<div class="feature-card">
<span class="feature-icon"><svg viewBox="0 0 256 256"><use href="/icons/phosphor.svg#chart-bar"></use></svg></span>
<div>
<h3>KI-Jahresbericht</h3>
<p>Automatischer Jahresrückblick für deinen Zuchtstätte: Würfe, Gesundheitstrends, Auszeichnungen — als professionelles Dokument.</p>
<span class="feature-tag">KI</span>
</div>
</div>
<div class="feature-card">
<span class="feature-icon"><svg viewBox="0 0 256 256"><use href="/icons/phosphor.svg#translate"></use></svg></span>
<div>
<h3>Genetik-Erklärung für Käufer</h3>
<p>Komplizierte Gentestergebnisse in verständlichem Deutsch für Käufer erklärt — automatisch, auf Knopfdruck.</p>
<span class="feature-tag">KI</span>
</div>
</div>
</div>
</div>
</section>
<!-- Vergleich -->
<section id="vergleich">
<div class="container">
<span class="label">Marktvergleich</span>
<h2>Was andere können.<br>Was nur Ban Yaro kann.</h2>
<p class="section-intro">Der deutsche Markt für Züchter-Software ist dünn. Hier ein ehrlicher Vergleich mit den relevantesten Alternativen.</p>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Feature</th>
<th class="highlight">Ban Yaro</th>
<th>Webreed</th>
<th>Puppy Center</th>
<th>Hundescout</th>
</tr>
</thead>
<tbody>
<tr><td>Stammbaum grafisch</td><td class="check hl"></td><td class="check"></td><td class="cross"></td><td class="check"></td></tr>
<tr><td>IK-Berechnung (Wright)</td><td class="check hl">✓ 8 Gen.</td><td class="check"></td><td class="cross"></td><td class="check"></td></tr>
<tr><td>Probeverpaarung mit IK</td><td class="check hl"></td><td class="cross"></td><td class="cross"></td><td class="cross"></td></tr>
<tr><td>Gesundheitstests (HD, ED, …)</td><td class="check hl">✓ 7 Typen</td><td class="check"></td><td class="check"></td><td class="check"></td></tr>
<tr><td>Gentests + Risikoanalyse</td><td class="check hl"></td><td class="check"></td><td class="cross"></td><td class="cross"></td></tr>
<tr><td>Tierschutz-Check automatisch</td><td class="check hl"></td><td class="cross"></td><td class="cross"></td><td class="cross"></td></tr>
<tr><td>Wurfverwaltung + Welpen</td><td class="check hl"></td><td class="check"></td><td class="check"></td><td class="cross"></td></tr>
<tr><td>Kaufvertrag-Generator</td><td class="check hl"></td><td class="check"></td><td class="cross"></td><td class="check"></td></tr>
<tr><td>KI-Assistent (Texte, Analyse)</td><td class="check hl">✓ 5 Features</td><td class="cross"></td><td class="cross"></td><td class="cross"></td></tr>
<tr><td>Käufer nutzen dieselbe App</td><td class="check hl"></td><td class="cross"></td><td class="cross"></td><td class="cross"></td></tr>
<tr><td>Native Mobile App</td><td class="check hl">✓ iOS + Android</td><td class="cross">Web</td><td class="check"></td><td class="cross">Windows only</td></tr>
<tr><td>Deutschsprachig</td><td class="check hl"></td><td class="check"></td><td class="check"></td><td class="check"></td></tr>
<tr><td>Server in Deutschland</td><td class="check hl"></td><td class="cross">Frankreich</td><td class="cross"></td><td class="cross"></td></tr>
<tr><td>Kostenlos starten</td><td class="check hl"></td><td class="check">✓ (mit Werbung)</td><td class="check">✓ (1 Hündin)</td><td class="cross">ab 99 €</td></tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- Alleinstellungsmerkmal -->
<section id="vorteil">
<div class="container">
<span class="label">Der entscheidende Unterschied</span>
<h2>Züchter und Käufer.<br>In einer App.</h2>
<p class="section-intro">Kein anderes Züchter-Tool kann das: Webreed, Hundescout, Breedera — sie alle enden bei der Welpenabgabe. Was danach kommt, passiert woanders.</p>
<div class="moat-box">
<h3>Der Welpe geht mit.</h3>
<p>
Ein Käufer der Ban Yaro nutzt kann deinen Wurf direkt in der App entdecken, anfragen und den Welpen reservieren.
Bei der Abgabe überträgst du das komplette Profil — Gewichtsverlauf, alle Impfungen, Gesundheitsstatus, Stammbaum —
direkt in die App des Käufers. Kein PDF, kein Papierstapel, kein vergessener Impfausweis.
Der Käufer hat alles. Und bleibt mit dir verbunden.
</p>
</div>
<div class="feature-grid" style="margin-top:2rem">
<div class="feature-card">
<span class="feature-icon"><svg viewBox="0 0 256 256"><use href="/icons/phosphor.svg#users"></use></svg></span>
<div>
<h3>Community die dich findet</h3>
<p>Über tausend aktive Hundehalter nutzen Ban Yaro — die deinen Wurf in der Wurfbörse sehen, ohne dass du Werbung schalten musst.</p>
</div>
</div>
<div class="feature-card">
<span class="feature-icon"><svg viewBox="0 0 256 256"><use href="/icons/phosphor.svg#handshake"></use></svg></span>
<div>
<h3>Nachsorge ohne Aufwand</h3>
<p>Käufer können dich direkt in der App kontaktieren. Wie entwickelt sich der Welpe? Hat er Fragen? Du bist erreichbar — ohne WhatsApp-Chaos.</p>
</div>
</div>
<div class="feature-card">
<span class="feature-icon"><svg viewBox="0 0 256 256"><use href="/icons/phosphor.svg#seal-check"></use></svg></span>
<div>
<h3>Verifizierter Züchter-Badge</h3>
<p>Nach Prüfung durch unser Team bekommst du den Verifiziert-Badge. Das schafft Vertrauen — und unterscheidet dich klar von Vermehrern.</p>
</div>
</div>
</div>
</div>
</section>
<!-- Loslegen -->
<section id="start">
<div class="container">
<span class="label">So einfach geht's</span>
<h2>In 10 Minuten startklar.</h2>
<p class="section-intro">Kein App Store, keine Installation. Ban Yaro läuft im Browser — auf dem Smartphone genauso wie am Laptop.</p>
<div class="steps">
<div class="step">
<h3>Registrieren</h3>
<p>Konto anlegen und Züchter-Antrag stellen — dauert 2 Minuten. E-Mail + Zwingername reicht für den Anfang.</p>
</div>
<div class="step">
<h3>Verifiziert werden</h3>
<p>Wir prüfen deinen Antrag und schalten deinen Züchter-Bereich frei — meist innerhalb von 24 Stunden.</p>
</div>
<div class="step">
<h3>Zuchthunde anlegen</h3>
<p>Deine Zuchthündinnen und -rüden mit Gesundheitstests, Gentests und Titeln erfassen. Der Stammbaum baut sich automatisch auf.</p>
</div>
<div class="step">
<h3>Ersten Wurf eintragen</h3>
<p>Wurf anlegen, Welpen erfassen, Gewichte tracken, Fotos hochladen — und direkt in der Wurfbörse veröffentlichen.</p>
</div>
</div>
</div>
</section>
<!-- Sicherheit & Datenschutz -->
<section>
<div class="container">
<span class="label">Vertrauen & Datenschutz</span>
<h2>Deine Zuchtdaten. Dein Eigentum.</h2>
<p class="section-intro">Genealogiedaten, Gesundheitszertifikate, Kaufverträge — das sind sensible Informationen. Sie gehören dir, nicht uns.</p>
<div class="feature-grid">
<div class="feature-card">
<span class="feature-icon"><svg viewBox="0 0 256 256"><use href="/icons/phosphor.svg#map-pin"></use></svg></span>
<div>
<h3>Server in Deutschland</h3>
<p>Alle Daten liegen auf unserem Server in Deutschland. Kein US-Anbieter, kein Drittland-Transfer. DSGVO-konform by Default.</p>
</div>
</div>
<div class="feature-card">
<span class="feature-icon"><svg viewBox="0 0 256 256"><use href="/icons/phosphor.svg#export"></use></svg></span>
<div>
<h3>Vollständiger Datenexport</h3>
<p>Alle deine Daten jederzeit als HTML oder ODS exportieren. Du bist nie eingesperrt — deine Zuchtdaten gehören dir.</p>
</div>
</div>
<div class="feature-card">
<span class="feature-icon"><svg viewBox="0 0 256 256"><use href="/icons/phosphor.svg#lock"></use></svg></span>
<div>
<h3>Sichtbarkeit selbst steuern</h3>
<p>Gesundheitstests, Fotos, Welpen-Status — für jeden Eintrag bestimmst du: öffentlich, nur auf Anfrage, oder privat.</p>
</div>
</div>
<div class="feature-card">
<span class="feature-icon"><svg viewBox="0 0 256 256"><use href="/icons/phosphor.svg#envelope"></use></svg></span>
<div>
<h3>Echter Ansprechpartner</h3>
<p><a href="mailto:hallo@banyaro.app">hallo@banyaro.app</a> — kein Ticket-System, kein Bot. René antwortet persönlich.</p>
</div>
</div>
</div>
</div>
</section>
<!-- CTA -->
<section class="cta-section">
<div class="container">
<h2>Jetzt kostenlos starten.</h2>
<p>Züchter-Antrag stellen, Zuchthunde anlegen, ersten Wurf veröffentlichen — alles kostenlos, kein App Store, keine Kreditkarte.</p>
<a href="/#register?rolle=breeder" class="cta-btn" onclick="sessionStorage.setItem('by_stay_in_app','1')">Als Züchter registrieren</a>
<p style="margin-top:1.5rem;font-size:0.85rem;opacity:0.55">Fragen? <a href="mailto:hallo@banyaro.app" style="color:rgba(255,255,255,.7)">hallo@banyaro.app</a></p>
</div>
</section>
<footer>
<div class="container">
<p><strong style="color:white">Ban Yaro</strong> — Zucht-Management für seriöse Hundezüchter</p>
<p style="margin-top:0.5rem">banyaro.app/zuechter · DSGVO-konform · Server in Deutschland · <a href="/info">Alle Features</a></p>
<div class="footer-links">
<a href="/#impressum">Impressum</a>
<a href="/#datenschutz">Datenschutz</a>
<a href="/info">Über Ban Yaro</a>
<a href="/presse">Presse</a>
</div>
</div>
</footer>
</body>
</html>