Merge branch 'develop'

This commit is contained in:
rene 2026-05-14 13:04:18 +02:00
commit f42f181e44
22 changed files with 960 additions and 128 deletions

View file

@ -2341,6 +2341,24 @@ def _migrate(conn_factory):
except Exception: except Exception:
pass pass
# upgrade_requests: Abo-Upgrade-Anfragen von Nutzern
try:
conn.execute("""
CREATE TABLE IF NOT EXISTS upgrade_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tier TEXT NOT NULL,
message TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
fulfilled_at TEXT,
fulfilled_by INTEGER REFERENCES users(id)
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_upgrade_req_pending ON upgrade_requests(fulfilled_at, created_at DESC)")
logger.info("Migration: upgrade_requests bereit.")
except Exception as e:
logger.warning(f"Migration upgrade_requests: {e}")
# route_dogs: bestehende Routen allen Hunden des Users zuweisen # route_dogs: bestehende Routen allen Hunden des Users zuweisen
try: try:
existing = conn.execute("SELECT COUNT(*) FROM route_dogs").fetchone()[0] existing = conn.execute("SELECT COUNT(*) FROM route_dogs").fetchone()[0]

View file

@ -406,7 +406,7 @@ async def serve_media(path: str, request: _Request):
raise _HE(404, "Nicht gefunden.") raise _HE(404, "Nicht gefunden.")
return _media_response(filepath) return _media_response(filepath)
APP_VER = "918" # muss mit APP_VER in app.js übereinstimmen APP_VER = "939" # muss mit APP_VER in app.js übereinstimmen
@app.get("/.well-known/assetlinks.json") @app.get("/.well-known/assetlinks.json")
async def assetlinks(): async def assetlinks():
@ -465,10 +465,11 @@ async def sitemap():
today = date.today().isoformat() today = date.today().isoformat()
urls = [ urls = [
("https://banyaro.app/", "weekly", "1.0"), ("https://banyaro.app/", "weekly", "1.0"),
("https://banyaro.app/info", "monthly", "0.9"), ("https://banyaro.app/zuechter", "weekly", "0.9"),
("https://banyaro.app/presse", "monthly", "0.8"), ("https://banyaro.app/info", "monthly", "0.8"),
("https://banyaro.app/presse", "monthly", "0.7"),
("https://banyaro.app/wiki/rassen", "weekly", "0.8"), ("https://banyaro.app/wiki/rassen", "weekly", "0.8"),
("https://banyaro.app/knigge", "monthly", "0.8"), ("https://banyaro.app/knigge", "monthly", "0.7"),
("https://banyaro.app/wurfboerse", "daily", "0.8"), ("https://banyaro.app/wurfboerse", "daily", "0.8"),
] ]

View file

@ -56,7 +56,12 @@ def safe_media_path(media_dir: str, url: str) -> str | None:
Konstruiert einen sicheren Dateipfad aus einer gespeicherten URL. Konstruiert einen sicheren Dateipfad aus einer gespeicherten URL.
Gibt None zurück wenn der Pfad außerhalb von media_dir liegt (Path-Traversal-Schutz). Gibt None zurück wenn der Pfad außerhalb von media_dir liegt (Path-Traversal-Schutz).
""" """
relative = url.lstrip("/media/").lstrip("/") if url.startswith("/media/"):
relative = url[len("/media/"):]
elif url.startswith("/"):
relative = url[1:]
else:
relative = url
candidate = os.path.realpath(os.path.join(media_dir, relative)) candidate = os.path.realpath(os.path.join(media_dir, relative))
real_base = os.path.realpath(media_dir) real_base = os.path.realpath(media_dir)
if not candidate.startswith(real_base + os.sep) and candidate != real_base: if not candidate.startswith(real_base + os.sep) and candidate != real_base:

View file

@ -124,13 +124,20 @@ async def action_items(user=Depends(require_mod)):
users_today = conn.execute( users_today = conn.execute(
"SELECT COUNT(*) FROM users WHERE DATE(created_at)=DATE('now')" "SELECT COUNT(*) FROM users WHERE DATE(created_at)=DATE('now')"
).fetchone()[0] ).fetchone()[0]
try:
upgrades_pending = conn.execute(
"SELECT COUNT(*) FROM upgrade_requests WHERE fulfilled_at IS NULL"
).fetchone()[0]
except Exception:
upgrades_pending = 0
return { return {
"jobs_pending": jobs, "jobs_pending": jobs,
"breeder_pending": breeders, "breeder_pending": breeders,
"reports_open": reports, "reports_open": reports,
"fotos_pending": fotos, "fotos_pending": fotos,
"poi_edits_pending": poi_edits, "poi_edits_pending": poi_edits,
"users_today": users_today, "users_today": users_today,
"upgrades_pending": upgrades_pending,
} }
@ -1054,21 +1061,25 @@ async def ors_stats(user=Depends(require_mod)):
@router.post("/media/generate-previews") @router.post("/media/generate-previews")
async def generate_media_previews(user=Depends(require_admin)): async def generate_media_previews(user=Depends(require_admin)):
"""Generiert fehlende _preview.jpg für alle Bilder in /data/media.""" """Generiert fehlende _preview.webp für alle Bilder in /data/media."""
import io as _io import logging as _log
from media_utils import generate_preview, _PREVIEW_EXTS from media_utils import generate_preview, _PREVIEW_EXTS
_logger = _log.getLogger(__name__)
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
generated = 0 generated = 0
skipped = 0 skipped = 0
errors = 0 errors = 0
dirs_info = {}
for subdir in ("diary", "forum"): for subdir in ("diary", "forum", "breeds", "breeds/gallery", "breeds/submissions"):
folder = os.path.join(MEDIA_DIR, subdir) folder = os.path.join(MEDIA_DIR, subdir)
if not os.path.isdir(folder): if not os.path.isdir(folder):
dirs_info[subdir] = "not found"
continue continue
for fname in os.listdir(folder): files = os.listdir(folder)
# Nur Original-Bilder (keine _preview, _thumb, Videos, PDFs) dirs_info[subdir] = f"{len(files)} files"
for fname in files:
low = fname.lower() low = fname.lower()
if "_preview" in low or "_thumb" in low: if "_preview" in low or "_thumb" in low:
continue continue
@ -1087,7 +1098,73 @@ async def generate_media_previews(user=Depends(require_admin)):
generated += 1 generated += 1
else: else:
skipped += 1 skipped += 1
_logger.warning(f"Preview None für {subdir}/{fname}")
except Exception as exc: except Exception as exc:
errors += 1 errors += 1
_logger.error(f"Preview-Fehler {subdir}/{fname}: {exc}")
return {"generated": generated, "skipped": skipped, "errors": errors} _logger.info(f"generate-previews: {generated} neu, {skipped} vorhanden, {errors} Fehler | dirs: {dirs_info}")
return {"generated": generated, "skipped": skipped, "errors": errors, "dirs": dirs_info}
# ------------------------------------------------------------------
# GET /api/admin/upgrade-requests — offene Upgrade-Anfragen
# POST /api/admin/upgrade-requests/{id}/fulfill — Tier setzen + Mail
# ------------------------------------------------------------------
@router.get("/upgrade-requests")
async def list_upgrade_requests(user=Depends(require_admin)):
with db() as conn:
rows = conn.execute("""
SELECT r.id, r.user_id, r.tier, r.message, r.created_at, r.fulfilled_at,
u.name, u.email
FROM upgrade_requests r
JOIN users u ON u.id = r.user_id
ORDER BY r.fulfilled_at IS NOT NULL, r.created_at DESC
LIMIT 100
""").fetchall()
return [dict(r) for r in rows]
@router.post("/upgrade-requests/{req_id}/fulfill")
async def fulfill_upgrade_request(req_id: int, user=Depends(require_admin)):
with db() as conn:
req = conn.execute(
"SELECT r.*, u.name, u.email FROM upgrade_requests r JOIN users u ON u.id=r.user_id WHERE r.id=?",
(req_id,)
).fetchone()
if not req:
raise HTTPException(404, "Anfrage nicht gefunden.")
if req["fulfilled_at"]:
raise HTTPException(400, "Bereits erledigt.")
if req["tier"] not in _VALID_TIERS:
raise HTTPException(400, "Ungültiger Tier.")
conn.execute(
"UPDATE users SET subscription_tier=? WHERE id=?",
(req["tier"], req["user_id"])
)
conn.execute(
"UPDATE upgrade_requests SET fulfilled_at=strftime('%Y-%m-%dT%H:%M:%fZ','now'), fulfilled_by=? WHERE id=?",
(user["id"], req_id)
)
_audit(conn, user, "fulfill_upgrade", f"user:{req['user_id']}", f"tier={req['tier']}")
tier_labels = {"pro": "Ban Yaro Pro", "breeder": "Züchter"}
tier_label = tier_labels.get(req["tier"], req["tier"])
try:
from mailer import send_email, email_html
body_html = f"""
<p>Hallo {req['name']},</p>
<p>dein Account wurde soeben auf <strong>{tier_label}</strong> freigeschaltet.</p>
<p>Du kannst alle {tier_label}-Features ab sofort in der App nutzen.
Öffne Ban Yaro und lade die App einmal neu dann ist dein neuer Tarif aktiv.</p>
<p>Vielen Dank für dein Vertrauen!</p>
<p>Viele Grüße<br>René &amp; das Ban Yaro Team</p>"""
html = email_html(body_html, cta_url="https://banyaro.app", cta_label="Ban Yaro öffnen")
plain = (f"Hallo {req['name']},\n\ndein Account wurde auf {tier_label} freigeschaltet.\n"
f"Öffne Ban Yaro und lade die App neu.\n\nViele Grüße\nRené")
await send_email(req["email"], f"Dein {tier_label}-Zugang ist aktiv", html, plain)
except Exception as e:
import logging
logging.getLogger(__name__).warning(f"Bestätigungsmail fehlgeschlagen: {e}")
return {"ok": True, "tier": req["tier"], "user": req["name"]}

View file

@ -240,7 +240,7 @@ async def me(user=Depends(get_current_user)):
profil_sichtbarkeit, avatar_url, created_at, profil_sichtbarkeit, avatar_url, created_at,
is_founder, is_partner, founder_number, is_founder_pending, is_founder, is_partner, founder_number, is_founder_pending,
notes_ki_enabled, gassi_stunde_push, notes_ki_enabled, gassi_stunde_push,
preferred_theme preferred_theme, subscription_tier
FROM users WHERE id=?""", FROM users WHERE id=?""",
(user["id"],) (user["id"],)
).fetchone() ).fetchone()
@ -335,6 +335,46 @@ async def forgot_password(data: ForgotPasswordRequest, request: Request):
return {"ok": True} return {"ok": True}
class UpgradeRequestBody(BaseModel):
tier: str
message: Optional[str] = None
@router.post("/upgrade-request")
async def create_upgrade_request(data: UpgradeRequestBody, user=Depends(get_current_user)):
_VALID = {"pro", "breeder"}
if data.tier not in _VALID:
raise HTTPException(400, "Ungültiger Tarif.")
with db() as conn:
existing = conn.execute(
"SELECT id FROM upgrade_requests WHERE user_id=? AND tier=? AND fulfilled_at IS NULL",
(user["id"], data.tier)
).fetchone()
if existing:
return {"ok": True, "already": True}
conn.execute(
"INSERT INTO upgrade_requests (user_id, tier, message) VALUES (?,?,?)",
(user["id"], data.tier, data.message or None)
)
email = conn.execute("SELECT email FROM users WHERE id=?", (user["id"],)).fetchone()["email"]
tier_labels = {"pro": "Ban Yaro Pro", "breeder": "Züchter"}
tier_label = tier_labels[data.tier]
admin_email = os.getenv("ADMIN_EMAIL", "")
if admin_email:
try:
from routes.outreach import _send_smtp
subject = f"[Ban Yaro] Upgrade-Anfrage: {tier_label}{user['name']}"
body = (f"Neue Upgrade-Anfrage:\n\n"
f"Nutzer: {user['name']} ({email})\n"
f"Tarif: {tier_label}\n"
f"Nachricht: {data.message or ''}\n\n"
f"Admin-Panel: https://banyaro.app/#admin")
_send_smtp(admin_email, subject, body, "support")
except Exception:
pass
return {"ok": True}
@router.post("/reset-password") @router.post("/reset-password")
async def reset_password(data: ResetPasswordRequest, request: Request): async def reset_password(data: ResetPasswordRequest, request: Request):
rl_check(request, max_requests=5, window_seconds=3600, key="reset_pw") rl_check(request, max_requests=5, window_seconds=3600, key="reset_pw")

View file

@ -87,7 +87,7 @@ async def breeder_apply(
stadt: str = Form(...), stadt: str = Form(...),
website: str = Form(""), website: str = Form(""),
beschreibung: str = Form(""), beschreibung: str = Form(""),
dokument: UploadFile = File(...), dokument: UploadFile = File(None),
user=Depends(get_current_user), user=Depends(get_current_user),
): ):
with db() as conn: with db() as conn:
@ -103,28 +103,27 @@ async def breeder_apply(
if row["breeder_status"] == "pending": if row["breeder_status"] == "pending":
raise HTTPException(400, "Du hast bereits einen offenen Antrag.") raise HTTPException(400, "Du hast bereits einen offenen Antrag.")
# Dokument validieren und speichern # Dokument optional speichern
data = await dokument.read() filepath = None
if len(data) > 10 * 1024 * 1024: if dokument and dokument.filename:
raise HTTPException(400, "Dokument zu groß (max. 10 MB).") data = await dokument.read()
ext = os.path.splitext(dokument.filename or "")[1].lower() if len(data) > 10 * 1024 * 1024:
if ext not in (".pdf", ".jpg", ".jpeg", ".png", ".webp"): raise HTTPException(400, "Dokument zu groß (max. 10 MB).")
raise HTTPException(400, "Nur PDF, JPG, PNG oder WebP erlaubt.") ext = os.path.splitext(dokument.filename)[1].lower()
if ext not in (".pdf", ".jpg", ".jpeg", ".png", ".webp"):
user_doc_dir = os.path.join(BREEDER_DOCS_DIR, str(user["id"])) raise HTTPException(400, "Nur PDF, JPG, PNG oder WebP erlaubt.")
os.makedirs(user_doc_dir, exist_ok=True) user_doc_dir = os.path.join(BREEDER_DOCS_DIR, str(user["id"]))
os.makedirs(user_doc_dir, exist_ok=True)
filename = f"antrag_{datetime.now(_TZ).strftime('%Y%m%d_%H%M%S')}{ext}" filename = f"antrag_{datetime.now(_TZ).strftime('%Y%m%d_%H%M%S')}{ext}"
filepath = os.path.join(user_doc_dir, filename) filepath = os.path.join(user_doc_dir, filename)
with open(filepath, "wb") as f: with open(filepath, "wb") as f:
f.write(data) f.write(data)
with db() as conn: with db() as conn:
conn.execute( conn.execute(
"UPDATE users SET breeder_status='pending' WHERE id=?", "UPDATE users SET breeder_status='pending' WHERE id=?",
(user["id"],) (user["id"],)
) )
# Profil-Entwurf anlegen (oder überschreiben wenn rejected)
conn.execute( conn.execute(
"INSERT INTO breeder_profiles (user_id, zwingername, rasse_text, verein, vdh_mitglied, stadt, website, beschreibung) " "INSERT INTO breeder_profiles (user_id, zwingername, rasse_text, verein, vdh_mitglied, stadt, website, beschreibung) "
"VALUES (?,?,?,?,?,?,?,?) " "VALUES (?,?,?,?,?,?,?,?) "
@ -135,10 +134,11 @@ async def breeder_apply(
"verified_at=NULL", "verified_at=NULL",
(user["id"], zwingername, rasse_text, verein, vdh_mitglied, stadt, website, beschreibung) (user["id"], zwingername, rasse_text, verein, vdh_mitglied, stadt, website, beschreibung)
) )
conn.execute( if filepath:
"INSERT INTO breeder_documents (user_id, dokument_typ, file_path) VALUES (?,?,?)", conn.execute(
(user["id"], "antrag", filepath) "INSERT INTO breeder_documents (user_id, dokument_typ, file_path) VALUES (?,?,?)",
) (user["id"], "antrag", filepath)
)
# Admin benachrichtigen # Admin benachrichtigen
admin_body = f""" admin_body = f"""
@ -183,6 +183,28 @@ async def admin_pending_breeders(admin=Depends(require_admin)):
return [dict(r) for r in rows] return [dict(r) for r in rows]
# ------------------------------------------------------------------
# GET /api/admin/breeders — alle aktiven Züchter
# ------------------------------------------------------------------
@router.get("/admin/breeders")
async def admin_all_breeders(admin=Depends(require_admin)):
with db() as conn:
rows = conn.execute("""
SELECT u.id, u.name, u.email, u.created_at, u.subscription_tier,
u.breeder_status, u.last_login,
bp.zwingername, bp.rasse_text, bp.verein, bp.vdh_mitglied,
bp.stadt, bp.website, bp.verified_at,
(SELECT COUNT(*) FROM litters WHERE user_id=u.id) AS wuerfe_count,
(SELECT COUNT(*) FROM dogs WHERE user_id=u.id) AS hunde_count
FROM users u
LEFT JOIN breeder_profiles bp ON bp.user_id = u.id
WHERE u.rolle = 'breeder' OR u.breeder_status = 'approved'
ORDER BY CASE WHEN bp.verified_at IS NULL THEN 1 ELSE 0 END,
bp.verified_at DESC, u.created_at DESC
""").fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# GET /api/admin/breeder/{user_id}/documents — Dokumente eines Antrags # GET /api/admin/breeder/{user_id}/documents — Dokumente eines Antrags
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View file

@ -191,6 +191,7 @@ async def get_welcome_dashboard(dog_id: int, user=Depends(get_current_user)):
raise HTTPException(404, "Hund nicht gefunden.") raise HTTPException(404, "Hund nicht gefunden.")
# Hintergrundfoto: Querformat-Bilder bevorzugt, tagesweise rotierend # Hintergrundfoto: Querformat-Bilder bevorzugt, tagesweise rotierend
# Ownership bereits durch Dog-Check oben gesichert (dog gehört user)
photos = conn.execute( photos = conn.execute(
"""SELECT dm.url FROM diary_media dm """SELECT dm.url FROM diary_media dm
JOIN diary d ON d.id = dm.diary_id JOIN diary d ON d.id = dm.diary_id
@ -843,13 +844,14 @@ async def upload_photo(
file: UploadFile = File(...), file: UploadFile = File(...),
user=Depends(get_current_user) user=Depends(get_current_user)
): ):
# Hund gehört dem User? # Hund gehört dem User? Altes Foto merken für späteres Löschen.
with db() as conn: with db() as conn:
dog = conn.execute( dog = conn.execute(
"SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"]) "SELECT id, foto_url FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"])
).fetchone() ).fetchone()
if not dog: if not dog:
raise HTTPException(404, "Hund nicht gefunden.") raise HTTPException(404, "Hund nicht gefunden.")
old_foto_url = dog["foto_url"]
# Datei immer als JPEG speichern (HEIC/PNG/WebP → kompatibel für alle Browser) # Datei immer als JPEG speichern (HEIC/PNG/WebP → kompatibel für alle Browser)
import io import io
@ -883,6 +885,15 @@ async def upload_photo(
with db() as conn: with db() as conn:
conn.execute("UPDATE dogs SET foto_url=? WHERE id=?", (foto_url, dog_id)) conn.execute("UPDATE dogs SET foto_url=? WHERE id=?", (foto_url, dog_id))
# Altes Foto von Disk löschen
if old_foto_url:
try:
old_path = safe_media_path(MEDIA_DIR, old_foto_url)
if old_path and os.path.isfile(old_path):
os.remove(old_path)
except Exception:
pass
return {"foto_url": foto_url} return {"foto_url": foto_url}

View file

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

View file

@ -124,6 +124,9 @@ const API = (() => {
return get('/auth/me'); return get('/auth/me');
}, },
referral: () => get('/auth/referral'), referral: () => get('/auth/referral'),
upgradeRequest(tier, message) {
return post('/auth/upgrade-request', { tier, message });
},
}; };
// ---------------------------------------------------------- // ----------------------------------------------------------
@ -688,6 +691,7 @@ const API = (() => {
updateProfile(data) { return put('/breeder/profile', data); }, updateProfile(data) { return put('/breeder/profile', data); },
adminCreateProfile() { return post('/admin/breeder/create-profile', {}); }, adminCreateProfile() { return post('/admin/breeder/create-profile', {}); },
pendingList() { return get('/admin/breeders/pending'); }, pendingList() { return get('/admin/breeders/pending'); },
allList() { return get('/admin/breeders'); },
documents(userId) { return get(`/admin/breeder/${userId}/documents`); }, documents(userId) { return get(`/admin/breeder/${userId}/documents`); },
documentUrl(userId, docId) { return `/api/admin/breeder/${userId}/document/${docId}`; }, documentUrl(userId, docId) { return `/api/admin/breeder/${userId}/document/${docId}`; },
approve(userId) { return post(`/admin/breeder/${userId}/approve`, {}); }, approve(userId) { return post(`/admin/breeder/${userId}/approve`, {}); },
@ -796,9 +800,17 @@ const API = (() => {
get(`/osm/pois?type=${type}&south=${south}&west=${west}&north=${north}&east=${east}&fast=true`), get(`/osm/pois?type=${type}&south=${south}&west=${west}&north=${north}&east=${east}&fast=true`),
}; };
// SW-Cache-Einträge für eine URL löschen (z.B. nach Foto-Upload)
async function swCacheDelete(path) {
try {
const c = await caches.open('ban-yaro-api-v1');
await c.delete(new Request(path));
} catch {}
}
// Öffentliche API // Öffentliche API
return { return {
get, post, put, patch, del, upload, get, post, put, patch, del, upload, swCacheDelete,
auth, dogs, diary, health, tieraerzte, healthDocs, poison, auth, dogs, diary, health, tieraerzte, healthDocs, poison,
places, routes, walks, events, sitting, forum, lost, knigge, weather, push, places, routes, walks, events, sitting, forum, lost, knigge, weather, push,
friends, chat, webcal, importData, sharing, widget, notifications, services, ratings, sittingAccess, training, notes, friends, chat, webcal, importData, sharing, widget, notifications, services, ratings, sittingAccess, training, notes,

View file

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

View file

@ -26,6 +26,7 @@ window.Page_admin = (() => {
{ id: 'hilfe', label: 'Hilfe/FAQ', icon: 'question' }, { id: 'hilfe', label: 'Hilfe/FAQ', icon: 'question' },
{ id: 'uebungen_admin', label: 'Übungen', icon: 'barbell' }, { id: 'uebungen_admin', label: 'Übungen', icon: 'barbell' },
{ id: 'referrals', label: 'Referrals', icon: 'share-network' }, { id: 'referrals', label: 'Referrals', icon: 'share-network' },
{ id: 'upgrades', label: 'Upgrades', icon: 'crown-simple' },
]; ];
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@ -90,6 +91,7 @@ window.Page_admin = (() => {
try { d = await API.get('/admin/action-items'); } catch { return; } try { d = await API.get('/admin/action-items'); } catch { return; }
const items = [ const items = [
{ key: 'upgrades_pending', label: 'Upgrade-Anfragen', tab: 'upgrades', icon: 'crown-simple' },
{ key: 'jobs_pending', label: 'Bewerbungen', tab: 'bewerbungen', icon: 'user-plus' }, { key: 'jobs_pending', label: 'Bewerbungen', tab: 'bewerbungen', icon: 'user-plus' },
{ key: 'breeder_pending', label: 'Züchter-Anträge', tab: 'zuchter', icon: 'certificate' }, { key: 'breeder_pending', label: 'Züchter-Anträge', tab: 'zuchter', icon: 'certificate' },
{ key: 'reports_open', label: 'Meldungen', tab: 'moderation', icon: 'warning' }, { key: 'reports_open', label: 'Meldungen', tab: 'moderation', icon: 'warning' },
@ -163,6 +165,7 @@ window.Page_admin = (() => {
case 'hilfe': await _renderHilfe(el); break; case 'hilfe': await _renderHilfe(el); break;
case 'uebungen_admin': await _renderUebungenAdmin(el); break; case 'uebungen_admin': await _renderUebungenAdmin(el); break;
case 'referrals': await _renderReferrals(el); break; case 'referrals': await _renderReferrals(el); break;
case 'upgrades': await _renderUpgrades(el); break;
} }
} catch (e) { } catch (e) {
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.'); el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.');
@ -1890,12 +1893,17 @@ window.Page_admin = (() => {
${UI.icon('arrows-clockwise')} Aktualisieren ${UI.icon('arrows-clockwise')} Aktualisieren
</button> </button>
</div> </div>
<div id="adm-zuchter-list">Lade</div> <div id="adm-zuchter-antraege">Lade</div>
<div id="adm-zuchter-liste" style="margin-top:var(--space-4)">Lade</div>
`; `;
el.querySelector('#adm-zuchter-refresh').addEventListener('click', () => el.querySelector('#adm-zuchter-refresh').addEventListener('click', () => {
_loadZuechterAntraege(el.querySelector('#adm-zuchter-list')) _loadZuechterAntraege(el.querySelector('#adm-zuchter-antraege'));
); _loadZuechterListe(el.querySelector('#adm-zuchter-liste'));
await _loadZuechterAntraege(el.querySelector('#adm-zuchter-list')); });
await Promise.all([
_loadZuechterAntraege(el.querySelector('#adm-zuchter-antraege')),
_loadZuechterListe(el.querySelector('#adm-zuchter-liste')),
]);
} }
async function _loadZuechterAntraege(el) { async function _loadZuechterAntraege(el) {
@ -1909,12 +1917,20 @@ window.Page_admin = (() => {
} }
if (!antraege.length) { if (!antraege.length) {
el.innerHTML = _emptyState('certificate', 'Keine offenen Anträge', 'Aktuell liegen keine Züchter-Anträge zur Prüfung vor.'); el.innerHTML = `<div class="card" style="padding:var(--space-4)">
<div class="by-card-section-header">Offene Anträge</div>
<div style="padding:var(--space-3) var(--space-4);font-size:var(--text-sm);color:var(--c-text-muted)">
${UI.icon('check-circle')} Keine offenen Anträge
</div>
</div>`;
return; return;
} }
el.innerHTML = ` el.innerHTML = `
<div style="display:flex;flex-direction:column;gap:var(--space-3)"> <div class="card" style="margin-bottom:0">
<div class="by-card-section-header">Offene Anträge (${antraege.length})</div>
</div>
<div style="display:flex;flex-direction:column;gap:var(--space-3);margin-top:var(--space-3)">
${antraege.map(a => ` ${antraege.map(a => `
<div class="card" style="padding:var(--space-4)"> <div class="card" style="padding:var(--space-4)">
<div style="display:flex;align-items:flex-start;gap:var(--space-3);flex-wrap:wrap"> <div style="display:flex;align-items:flex-start;gap:var(--space-3);flex-wrap:wrap">
@ -2069,6 +2085,74 @@ window.Page_admin = (() => {
}); });
} }
async function _loadZuechterListe(el) {
el.innerHTML = `<div style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Lade…</div>`;
let breeders;
try {
breeders = await API.breeder.allList();
} catch (e) {
el.innerHTML = _emptyState('warning', 'Fehler', e.message);
return;
}
const tierBadge = t => {
if (t === 'breeder') return `<span style="display:inline-block;padding:1px 7px;border-radius:999px;font-size:10px;font-weight:700;background:#C4843A;color:#fff">Züchter-Abo</span>`;
if (t === 'breeder_test') return `<span style="display:inline-block;padding:1px 7px;border-radius:999px;font-size:10px;font-weight:700;background:#aaa;color:#fff">Test</span>`;
return `<span style="display:inline-block;padding:1px 7px;border-radius:999px;font-size:10px;font-weight:700;background:#eee;color:#666">Standard</span>`;
};
const rows = breeders.map(b => `
<tr>
<td style="padding:var(--space-2) var(--space-3)">
<div style="font-weight:600;font-size:var(--text-sm)">${_esc(b.name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">${_esc(b.email)}</div>
</td>
<td style="padding:var(--space-2) var(--space-3);font-size:var(--text-sm)">${_esc(b.zwingername || '—')}</td>
<td style="padding:var(--space-2) var(--space-3);font-size:var(--text-xs);color:var(--c-text-secondary)">${_esc(b.rasse_text || '—')}</td>
<td style="padding:var(--space-2) var(--space-3);font-size:var(--text-xs);color:var(--c-text-secondary)">${_esc(b.stadt || '—')}</td>
<td style="padding:var(--space-2) var(--space-3);text-align:center;font-size:var(--text-xs)">
${b.wuerfe_count || 0} Würfe<br>
<span style="color:var(--c-text-muted)">${b.hunde_count || 0} Hunde</span>
</td>
<td style="padding:var(--space-2) var(--space-3)">${tierBadge(b.subscription_tier)}</td>
<td style="padding:var(--space-2) var(--space-3);font-size:var(--text-xs);color:var(--c-text-muted)">
${b.verified_at ? new Date(b.verified_at).toLocaleDateString('de-DE') : '—'}
</td>
<td style="padding:var(--space-2) var(--space-3)">
<button class="btn btn-sm btn-ghost adm-breeder-tier-btn"
data-uid="${b.id}" data-name="${_esc(b.name)}" data-tier="${_esc(b.subscription_tier || 'standard')}"
style="font-size:var(--text-xs)">
Abo
</button>
</td>
</tr>`).join('');
el.innerHTML = `
<div class="card adm-table-card">
<div class="by-card-section-header">Alle Züchter (${breeders.length})</div>
<div class="adm-table-scroll">
<table class="adm-table" style="width:100%;border-collapse:collapse">
<thead><tr>
${['Nutzer','Zwingername','Rasse','Stadt','Aktivität','Abo','Seit',''].map(h =>
`<th style="padding:var(--space-2) var(--space-3);text-align:left;
font-size:var(--text-xs);color:var(--c-text-muted);font-weight:600;
border-bottom:1px solid var(--c-border);white-space:nowrap">${h}</th>`
).join('')}
</tr></thead>
<tbody>
${rows || `<tr><td colspan="8" style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Noch keine Züchter</td></tr>`}
</tbody>
</table>
</div>
</div>`;
el.querySelectorAll('.adm-breeder-tier-btn').forEach(btn => {
btn.addEventListener('click', () =>
_changeTier(btn.dataset.uid, btn.dataset.name, btn.dataset.tier)
);
});
}
// ------------------------------------------------------------------ // ------------------------------------------------------------------
async function _renderJobs(el) { async function _renderJobs(el) {
el.innerHTML = ` el.innerHTML = `
@ -3419,6 +3503,104 @@ window.Page_admin = (() => {
</div>`; </div>`;
} }
// ------------------------------------------------------------------
// TAB: UPGRADES
// ------------------------------------------------------------------
async function _renderUpgrades(el) {
const rows = await API.get('/admin/upgrade-requests');
const tierBadge = t => {
const cfg = { pro: ['Pro', '#16a34a'], breeder: ['Züchter', '#C4843A'] };
const [label, color] = cfg[t] || [t, '#888'];
return `<span style="display:inline-block;padding:1px 8px;border-radius:999px;
font-size:11px;font-weight:700;background:${color};color:#fff">${label}</span>`;
};
const pending = rows.filter(r => !r.fulfilled_at);
const done = rows.filter(r => r.fulfilled_at);
const _row = (r, showBtn) => `
<tr>
<td style="padding:var(--space-2) var(--space-3)">${_esc(r.name)}<br>
<span style="font-size:var(--text-xs);color:var(--c-text-muted)">${_esc(r.email)}</span></td>
<td style="padding:var(--space-2) var(--space-3)">${tierBadge(r.tier)}</td>
<td style="padding:var(--space-2) var(--space-3);font-size:var(--text-xs);color:var(--c-text-muted)">
${r.message ? _esc(r.message) : '—'}</td>
<td style="padding:var(--space-2) var(--space-3);font-size:var(--text-xs);color:var(--c-text-muted)">
${r.created_at?.slice(0,10) || ''}</td>
<td style="padding:var(--space-2) var(--space-3)">
${showBtn
? `<button class="btn btn-sm adm-fulfill-btn" data-id="${r.id}" data-name="${_esc(r.name)}" data-tier="${r.tier}"
style="background:#16a34a;color:#fff;border:none;padding:4px 12px;
border-radius:var(--radius-md);cursor:pointer;font-size:var(--text-xs);font-weight:600">
Freischalten
</button>`
: `<span style="font-size:var(--text-xs);color:var(--c-success)">✓ ${r.fulfilled_at?.slice(0,10)}</span>`}
</td>
</tr>`;
const thead = `<thead><tr>
${['Nutzer','Tarif','Nachricht','Datum','Aktion'].map(h =>
`<th style="padding:var(--space-2) var(--space-3);text-align:left;font-size:var(--text-xs);
color:var(--c-text-muted);font-weight:600;border-bottom:1px solid var(--c-border)">${h}</th>`
).join('')}</tr></thead>`;
el.innerHTML = `
<div class="card adm-table-card" style="margin-bottom:var(--space-4)">
<div class="by-card-section-header">Offene Anfragen (${pending.length})</div>
<div class="adm-table-scroll">
<table class="adm-table" style="width:100%;border-collapse:collapse">
${thead}
<tbody>
${pending.length
? pending.map(r => _row(r, true)).join('')
: `<tr><td colspan="5" style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">
Keine offenen Anfragen
</td></tr>`}
</tbody>
</table>
</div>
</div>
${done.length ? `
<div class="card adm-table-card">
<div class="by-card-section-header">Erledigt (${done.length})</div>
<div class="adm-table-scroll">
<table class="adm-table" style="width:100%;border-collapse:collapse">
${thead}
<tbody>${done.map(r => _row(r, false)).join('')}</tbody>
</table>
</div>
</div>` : ''}`;
el.querySelectorAll('.adm-fulfill-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const { id, name, tier } = btn.dataset;
const tierLabel = { pro: 'Pro', breeder: 'Züchter' }[tier] || tier;
const ok = await UI.modal.confirm({
title: `${name} auf ${tierLabel} freischalten?`,
body: `<p style="font-size:var(--text-sm)">
Der Account wird auf <strong>${tierLabel}</strong> gesetzt und
eine Bestätigungsmail gesendet.
</p>`,
confirmLabel: 'Freischalten',
danger: false,
});
if (!ok) return;
btn.disabled = true;
btn.textContent = '…';
try {
const res = await API.post(`/admin/upgrade-requests/${id}/fulfill`);
UI.toast.success(`${res.user} wurde auf ${tierLabel} freigeschaltet.`);
_renderTab();
} catch (e) {
UI.toast.error(e.message);
btn.disabled = false;
btn.textContent = 'Freischalten';
}
});
});
}
return { init, refresh, onDogChange }; return { init, refresh, onDogChange };
})(); })();

View file

@ -769,8 +769,16 @@ window.Page_dog_profile = (() => {
await API.dogs.updatePhotoPosition(dog.id, 1.0, 0.0, 0.0); await API.dogs.updatePhotoPosition(dog.id, 1.0, 0.0, 0.0);
_appState.activeDog = { ..._appState.activeDog, foto_url: result.foto_url, foto_zoom: 1, foto_offset_x: 0, foto_offset_y: 0 }; _appState.activeDog = { ..._appState.activeDog, foto_url: result.foto_url, foto_zoom: 1, foto_offset_x: 0, foto_offset_y: 0 };
_appState.dogs = _appState.dogs.map(d => d.id === dog.id ? _appState.activeDog : d); _appState.dogs = _appState.dogs.map(d => d.id === dog.id ? _appState.activeDog : d);
// localStorage + SW-Cache invalidieren
const userId2 = _appState.user?.id || 'anon';
localStorage.removeItem(`w3_bg3_${userId2}_` + new Date().toISOString().slice(0, 10));
localStorage.removeItem('w3_dogs');
API.swCacheDelete('/api/dogs');
API.swCacheDelete(`/api/dogs/${dog.id}`);
API.swCacheDelete(`/api/dogs/${dog.id}/welcome-dashboard`);
UI.modal.close(); UI.modal.close();
App.renderDogSwitcher(); App.renderDogSwitcher?.();
window.Worlds?.refresh(_appState);
UI.toast.success('Foto hochgeladen.'); UI.toast.success('Foto hochgeladen.');
_renderProfile(_appState.activeDog); _renderProfile(_appState.activeDog);
// Editor neu öffnen damit User positionieren kann // Editor neu öffnen damit User positionieren kann
@ -1354,6 +1362,9 @@ window.Page_dog_profile = (() => {
fell_typ: fd.fell_typ || null, fell_typ: fd.fell_typ || null,
}; };
// Datei-Referenz VOR Modal-Close sichern — DOM-Element wird beim Schließen entfernt
const fotoFile = document.getElementById('dp-form-foto')?.files[0];
let saved; let saved;
if (dog) { if (dog) {
saved = await API.dogs.update(dog.id, payload); saved = await API.dogs.update(dog.id, payload);
@ -1371,7 +1382,6 @@ window.Page_dog_profile = (() => {
} }
// Foto hochladen wenn gewählt // Foto hochladen wenn gewählt
const fotoFile = document.getElementById('dp-form-foto')?.files[0];
if (fotoFile) { if (fotoFile) {
try { try {
const fd = new FormData(); const fd = new FormData();
@ -1380,6 +1390,13 @@ window.Page_dog_profile = (() => {
saved.foto_url = result.foto_url; saved.foto_url = result.foto_url;
_appState.activeDog = { ...saved }; _appState.activeDog = { ...saved };
_appState.dogs = _appState.dogs.map(d => d.id === saved.id ? _appState.activeDog : d); _appState.dogs = _appState.dogs.map(d => d.id === saved.id ? _appState.activeDog : d);
// localStorage + SW-Cache invalidieren damit Welten das neue Foto zeigen
const userId = _appState.user?.id || 'anon';
localStorage.removeItem(`w3_bg3_${userId}_` + new Date().toISOString().slice(0, 10));
localStorage.removeItem('w3_dogs');
API.swCacheDelete('/api/dogs');
API.swCacheDelete(`/api/dogs/${saved.id}`);
API.swCacheDelete(`/api/dogs/${saved.id}/welcome-dashboard`);
} catch { } catch {
UI.toast.warning('Profil gespeichert, Foto konnte nicht hochgeladen werden.'); UI.toast.warning('Profil gespeichert, Foto konnte nicht hochgeladen werden.');
} }
@ -1388,6 +1405,9 @@ window.Page_dog_profile = (() => {
// Dog Switcher in Header + Sidebar aktualisieren // Dog Switcher in Header + Sidebar aktualisieren
App.renderDogSwitcher?.(); App.renderDogSwitcher?.();
// Welten neu laden damit HUND-Welt den neuen Hund zeigt
window.Worlds?.refresh(_appState);
await _render(); await _render();
}); });
}); });

View file

@ -205,8 +205,10 @@ window.Page_map = (() => {
<div class="map-fabs"> <div class="map-fabs">
<button class="map-fab map-fab--pin" id="map-pin-btn" title="Marker setzen"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg></button> <button class="map-fab map-fab--pin" id="map-pin-btn" title="Marker setzen"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg></button>
${App.hasPro(_appState?.user) ? `
<button class="map-fab" id="map-radar-btn" title="Regenradar ein-/ausblenden"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#cloud-rain"></use></svg></button> <button class="map-fab" id="map-radar-btn" title="Regenradar ein-/ausblenden"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#cloud-rain"></use></svg></button>
<button class="map-fab" id="map-temp-btn" title="Temperatur ein-/ausblenden"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#thermometer"></use></svg></button> <button class="map-fab" id="map-temp-btn" title="Temperatur ein-/ausblenden"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#thermometer"></use></svg></button>
` : ''}
<button class="map-fab" id="map-locate-btn" title="Meinen Standort"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg></button> <button class="map-fab" id="map-locate-btn" title="Meinen Standort"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg></button>
</div> </div>
@ -289,8 +291,8 @@ window.Page_map = (() => {
}); });
document.getElementById('map-pin-btn').addEventListener('click', _togglePlacementMode); document.getElementById('map-pin-btn').addEventListener('click', _togglePlacementMode);
document.getElementById('map-radar-btn').addEventListener('click', _toggleRadar); document.getElementById('map-radar-btn')?.addEventListener('click', _toggleRadar);
document.getElementById('map-temp-btn').addEventListener('click', _toggleTemp); document.getElementById('map-temp-btn')?.addEventListener('click', _toggleTemp);
} }
// ---------------------------------------------------------- // ----------------------------------------------------------

View file

@ -77,6 +77,269 @@ window.Page_settings = (() => {
} }
} }
// ----------------------------------------------------------
// ABO & TARIF
// ----------------------------------------------------------
function _tierCard(u) {
const tier = u.subscription_tier || 'standard';
const rolle = u.rolle || 'user';
const isAdmin = rolle === 'admin' || rolle === 'moderator';
const isPro = ['pro','pro_test'].includes(tier);
const isBreeder = ['breeder','breeder_test'].includes(tier) || rolle === 'breeder';
const isStandard = !isAdmin && !isPro && !isBreeder;
const _badge = (label, color) =>
`<span style="display:inline-block;padding:2px 10px;border-radius:20px;
font-size:var(--text-xs);font-weight:700;letter-spacing:.03em;
background:${color};color:#fff">${label}</span>`;
const _upgradeBtn = (id, label, price, color) =>
`<button id="${id}"
style="flex:1;min-width:130px;padding:var(--space-3) var(--space-2);
border-radius:var(--radius-md);border:none;cursor:pointer;
background:${color};color:#fff;
font-size:var(--text-sm);font-weight:600;
display:flex;flex-direction:column;align-items:center;gap:2px">
<span>${label}</span>
<span style="font-size:var(--text-xs);font-weight:400;opacity:.9">${price}</span>
</button>`;
let statusHtml = '';
let actionsHtml = '';
if (isAdmin) {
statusHtml = _badge('Admin', '#6366f1');
} else if (isBreeder) {
statusHtml = _badge('Züchter aktiv', '#C4843A');
} else if (isPro) {
statusHtml = _badge('Pro aktiv', '#16a34a');
actionsHtml = `
<div style="margin-top:var(--space-3);display:flex;gap:var(--space-2);flex-wrap:wrap">
${_upgradeBtn('settings-upgrade-breeder-btn','Züchter werden','49 €/Jahr','#C4843A')}
</div>`;
} else {
statusHtml = _badge('Kostenlos', '#888');
actionsHtml = `
<div style="margin-top:var(--space-3);display:flex;gap:var(--space-2);flex-wrap:wrap">
${_upgradeBtn('settings-upgrade-pro-btn','Ban Yaro Pro','29 €/Jahr','#16a34a')}
${_upgradeBtn('settings-upgrade-breeder-btn','Züchter','49 €/Jahr','#C4843A')}
</div>`;
}
return `
<div class="card" style="margin-bottom:var(--space-4)">
<div class="by-card-section-header">Abo &amp; Tarif</div>
<div style="padding:var(--space-4)">
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap">
<span style="font-size:var(--text-sm);color:var(--c-text-secondary)">Aktueller Tarif:</span>
${statusHtml}
</div>
${actionsHtml}
</div>
</div>`;
}
function _showUpgradeModal(tier) {
const isPro = tier === 'pro';
const label = isPro ? 'Ban Yaro Pro' : 'Züchter';
const price = isPro ? '29 €/Jahr' : '49 €/Jahr';
const color = isPro ? '#16a34a' : '#C4843A';
const _group = (title, items) => `
<div style="margin-bottom:var(--space-3)">
<div style="font-size:10px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;
color:var(--c-text-muted);margin-bottom:var(--space-2)">${title}</div>
${items.map(f => `
<div style="display:flex;align-items:flex-start;gap:var(--space-2);
padding:3px 0;font-size:var(--text-sm)">
<span style="color:${color};font-weight:700;flex-shrink:0;margin-top:1px"></span>
<span>${f}</span>
</div>`).join('')}
</div>`;
const featureList = isPro
? _group('Deine Hunde', [
'Bis zu 10 Hunde gleichzeitig verwalten',
'Getrennte Trainingsfortschritte, Gesundheits- und Ernährungsdaten je Hund',
])
+ _group('Community & Alltag', [
'Gassi-Treffen: Fotos und Rasse der Teilnehmer sichtbar, Fotos nach dem Treffen hochladen',
'Direktnachrichten & Chat mit anderen Hundebesitzern',
'Playdate: Spielkameraden in der Nähe finden und verabreden',
])
+ _group('Tools & Wissen', [
'Ernährung: Kalorienbedarf-Rechner, BARF-Guide, Giftliste, KI-Ernährungsberater',
'Reise-Checkliste & EU-Länder-Einreiseregeln',
'Notizblock mit KI-Muster-Analyse',
'Erweiterte Karten-Layer (Wandern, Radfahren, Satellit)',
'Alle künftigen Pro-Features inklusive',
])
: `<div style="padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
background:rgba(22,163,74,0.08);border:1px solid rgba(22,163,74,0.2);
font-size:var(--text-xs);color:var(--c-text-secondary);margin-bottom:var(--space-3)">
<strong style="color:#16a34a"> Alle Pro-Features inklusive</strong>
mehrere Hunde, Ernährung, Gassi-Community, Chat, Playdate, Reise, Karten-Layer
</div>`
+ _group('Zucht-Management', [
'Zuchtkartei: Stammdaten, Gesundheitstests (HD, ED, OCD, Augen, Herz, Patella, ZTP), Gentests (MDR1, PRA, DM, vWD)',
'Wurfverwaltung: Welpen, Gewichtsverlauf, Fotos, automatisch ausgefüllter Kaufvertrag',
'Warteliste: Interessenten mit Präferenzen (Geschlecht, Farbe, Verwendungszweck) pro Zuchthündin',
'Läufigkeit & Trächtigkeit: Zykluskalender, Progesterontests, Deckdaten, Meilensteinberechnung',
'Wurf-Buchstabe und Wurf-Name (z. B. A-Wurf, „Vatertags-Wurf")',
])
+ _group('KI & Analyse', [
'KI-Züchter-Assistent: Wurfankündigungen schreiben, Genetik-Erklärung für Käufer, Paarungsanalyse',
'Stammbaum bis 4 Generationen mit klickbaren Knoten',
'Inzucht-Koeffizient (Wright\'s Formel, Ampel-Bewertung, Probeverpaarung)',
'Tierschutz-Check automatisch bei jeder Verpaarung',
'KI-Jahresbericht mit Trends und Empfehlungen',
])
+ _group('Sichtbarkeit & Export', [
'Öffentliches Züchter-Profil unter banyaro.app/breeder/{zwingername}',
'Wurfbörse: Würfe öffentlich ankündigen, Käufer schreiben direkt an',
'Datenexport als HTML-Dossier und ODS-Tabelle (LibreOffice / Excel)',
'Privater Züchter-Bereich mit Zwingername und Logo',
]);
const inputStyle = `width:100%;box-sizing:border-box;padding:var(--space-2) var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
font-size:var(--text-sm);font-family:inherit;background:var(--c-surface);color:var(--c-text)`;
const breederForm = isPro ? '' : `
<div style="margin-top:var(--space-4);padding-top:var(--space-4);border-top:1px solid var(--c-border)">
<div style="font-size:var(--text-xs);font-weight:700;color:var(--c-text-muted);
text-transform:uppercase;letter-spacing:.05em;margin-bottom:var(--space-3)">
Dein Zwinger
</div>
<form id="breeder-upgrade-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:4px">
Zwingername <span style="color:var(--c-danger)">*</span>
</label>
<input name="zwingername" type="text" maxlength="100" required
placeholder="z. B. vom Sonnenfeld" style="${inputStyle}">
</div>
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:4px">
Rasse <span style="color:var(--c-danger)">*</span>
</label>
<input name="rasse_text" type="text" maxlength="100" required
placeholder="z. B. Labrador Retriever" style="${inputStyle}">
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-2)">
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:4px">Zuchtverein</label>
<input name="verein" type="text" maxlength="100"
placeholder="z. B. VDH, BCD" style="${inputStyle}">
</div>
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:4px">Stadt</label>
<input name="stadt" type="text" maxlength="80"
placeholder="z. B. München" style="${inputStyle}">
</div>
</div>
<div style="display:flex;align-items:center;gap:var(--space-2)">
<input name="vdh_mitglied" type="checkbox" id="upg-breeder-vdh"
style="width:16px;height:16px;cursor:pointer;flex-shrink:0">
<label for="upg-breeder-vdh" style="font-size:var(--text-sm);cursor:pointer">VDH-Mitglied</label>
</div>
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:4px">
Dokument hochladen <span style="font-weight:400;color:var(--c-text-muted)">(optional)</span>
</label>
<input name="dokument" type="file" accept=".pdf,.jpg,.jpeg,.png,.webp"
style="font-size:var(--text-sm);width:100%;box-sizing:border-box">
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:4px">
Zuchtbuch, Vereinsausweis o.ä. kann auch per E-Mail nachgereicht werden
</div>
</div>
</form>
</div>`;
UI.modal.open({
title: `${label} freischalten`,
body: `
<div style="padding:var(--space-2) 0">
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-4)">
<div style="font-size:2rem;font-weight:800;color:${color}">${price}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);line-height:1.5">
Einmaliger Jahresbeitrag<br>Kündigung jederzeit möglich
</div>
</div>
<div style="margin:0 0 var(--space-3)">
${featureList}
</div>
<div style="padding:var(--space-3);border-radius:var(--radius-md);
background:var(--c-surface-raised,rgba(0,0,0,.04));
font-size:var(--text-xs);color:var(--c-text-secondary);line-height:1.6">
Wir schalten deinen Account manuell frei innerhalb von 24 Stunden.
Wir melden uns mit den Zahlungsdetails per E-Mail.
</div>
${breederForm}
</div>`,
footer: `
<button data-modal-close
style="padding:var(--space-2) var(--space-4);border-radius:var(--radius-md);
border:1.5px solid var(--c-border);background:transparent;
color:var(--c-text);font-size:var(--text-sm);cursor:pointer">
Abbrechen
</button>
<button id="upgrade-request-send-btn"
style="padding:var(--space-2) var(--space-4);border-radius:var(--radius-md);
border:none;cursor:pointer;background:${color};color:#fff;
font-size:var(--text-sm);font-weight:600">
Anfrage senden
</button>`
});
document.getElementById('upgrade-request-send-btn')?.addEventListener('click', async () => {
const btn = document.getElementById('upgrade-request-send-btn');
if (!btn) return;
// Züchter: Formular validieren + als FormData senden
if (!isPro) {
const form = document.getElementById('breeder-upgrade-form');
if (form && !form.reportValidity()) return;
if (form) {
const fd = new FormData(form);
fd.set('vdh_mitglied', form.querySelector('[name="vdh_mitglied"]').checked ? '1' : '0');
// Pflichtfelder aus Form übernehmen falls leer → leere Strings senden
if (!fd.get('verein')) fd.set('verein', '');
if (!fd.get('stadt')) fd.set('stadt', '');
btn.disabled = true;
btn.textContent = 'Wird gesendet…';
try {
await API.breeder.apply(fd);
} catch (e) {
if (!e.message?.includes('bereits')) {
btn.disabled = false;
btn.textContent = 'Anfrage senden';
UI.toast.error(e.message || 'Fehler beim Einreichen.');
return;
}
}
}
} else {
btn.disabled = true;
btn.textContent = 'Wird gesendet…';
}
try {
const res = await API.auth.upgradeRequest(tier);
UI.modal.close();
if (res.already) {
UI.toast.info('Deine Anfrage liegt bereits vor — wir melden uns bald.');
} else {
UI.toast.success('Anfrage gesendet! Wir melden uns per E-Mail.');
}
if (!isPro) _loadBreederCard();
} catch (e) {
btn.disabled = false;
btn.textContent = 'Anfrage senden';
UI.toast.error(e.message || 'Fehler beim Senden.');
}
});
}
// ---------------------------------------------------------- // ----------------------------------------------------------
// RENDER // RENDER
// ---------------------------------------------------------- // ----------------------------------------------------------
@ -276,13 +539,6 @@ window.Page_settings = (() => {
<span>Feedback geben</span> <span>Feedback geben</span>
<span style="margin-left:auto;color:var(--c-text-secondary)"></span> <span style="margin-left:auto;color:var(--c-text-secondary)"></span>
</div> </div>
${!_appState.user?.subscription_tier || _appState.user.subscription_tier === 'standard' || _appState.user.subscription_tier === 'standard_test' ? `
<div style="margin:var(--space-3) 0;padding:var(--space-3) var(--space-4);
background:rgba(196,132,58,0.1);border-radius:var(--radius-md);
font-size:var(--text-xs);color:var(--c-text-secondary)">
<strong>Ban Yaro Pro</strong> kommt bald mehr Features, mehrere Hunde.
</div>
` : ''}
<div style="padding:var(--space-3) var(--space-4);border-top:1px solid var(--c-border)"> <div style="padding:var(--space-3) var(--space-4);border-top:1px solid var(--c-border)">
<button id="settings-logout-btn" <button id="settings-logout-btn"
style="width:100%;display:flex;align-items:center;justify-content:center; style="width:100%;display:flex;align-items:center;justify-content:center;
@ -316,6 +572,8 @@ window.Page_settings = (() => {
</div> </div>
</div> </div>
${_tierCard(u)}
<div class="card" style="margin-bottom:var(--space-4)"> <div class="card" style="margin-bottom:var(--space-4)">
<div class="by-card-section-header"> <div class="by-card-section-header">
App-Einstellungen App-Einstellungen
@ -355,7 +613,15 @@ window.Page_settings = (() => {
Vollbild: <em>Einstellungen Display Navigationsleiste Wischgesten</em> aktivieren. Vollbild: <em>Einstellungen Display Navigationsleiste Wischgesten</em> aktivieren.
</div>` : ''} </div>` : ''}
<!-- KI-Notiz-Assistent --> <!-- KI-Notiz-Assistent (nur Pro) -->
${(() => {
const tier = u.subscription_tier || 'standard';
const hasPro = ['pro','breeder'].includes(tier) ||
u.rolle === 'admin' || u.rolle === 'moderator' ||
u.is_moderator || u.is_social_media ||
tier.endsWith('_test');
if (!hasPro) return '';
return `
<div class="settings-toggle-row"> <div class="settings-toggle-row">
<svg class="ph-icon" aria-hidden="true" style="width:1.25rem;height:1.25rem"><use href="/icons/phosphor.svg#brain"></use></svg> <svg class="ph-icon" aria-hidden="true" style="width:1.25rem;height:1.25rem"><use href="/icons/phosphor.svg#brain"></use></svg>
<div class="settings-toggle-label"> <div class="settings-toggle-label">
@ -377,7 +643,8 @@ window.Page_settings = (() => {
background:#fff;transition:.2s; background:#fff;transition:.2s;
box-shadow:0 1px 3px rgba(0,0,0,.3)"></span> box-shadow:0 1px 3px rgba(0,0,0,.3)"></span>
</label> </label>
</div> </div>`;
})()}
<!-- Goldene Gassi-Stunde --> <!-- Goldene Gassi-Stunde -->
<div class="settings-toggle-row" style="border-bottom:none"> <div class="settings-toggle-row" style="border-bottom:none">
@ -829,6 +1096,13 @@ window.Page_settings = (() => {
} }
}); });
document.getElementById('settings-upgrade-pro-btn')?.addEventListener('click', () => {
_showUpgradeModal('pro');
});
document.getElementById('settings-upgrade-breeder-btn')?.addEventListener('click', () => {
_showUpgradeModal('breeder');
});
document.getElementById('settings-worlds-btn')?.addEventListener('click', () => { document.getElementById('settings-worlds-btn')?.addEventListener('click', () => {
if (window.Worlds?._openConfigModal) window.Worlds._openConfigModal(); if (window.Worlds?._openConfigModal) window.Worlds._openConfigModal();
else if (window.Worlds) window.Worlds.openConfig?.(); else if (window.Worlds) window.Worlds.openConfig?.();
@ -1167,17 +1441,17 @@ window.Page_settings = (() => {
</span>`; </span>`;
actionBlock = ` actionBlock = `
<div style="margin-top:var(--space-3)"> <div style="margin-top:var(--space-3)">
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0 0 var(--space-2)">
Du kannst einen neuen Antrag stellen.
</p>
<button class="btn btn-secondary btn-sm" id="breeder-reapply-btn"> <button class="btn btn-secondary btn-sm" id="breeder-reapply-btn">
${UI.icon('arrow-counter-clockwise')} Neu beantragen ${UI.icon('arrow-counter-clockwise')} Neu beantragen
</button> </button>
</div>`; </div>`;
} else { } else {
actionBlock = ` // Kein Antrag, kein Profil — Card ausblenden (Upgrade-Flow läuft über Abo & Tarif)
<div style="margin-top:var(--space-3)"> slot.innerHTML = '';
<button class="btn btn-primary btn-sm" id="breeder-apply-btn"> return;
${UI.icon('certificate')} Züchter werden
</button>
</div>`;
} }
slot.innerHTML = ` slot.innerHTML = `
@ -1190,11 +1464,7 @@ window.Page_settings = (() => {
</div>`; </div>`;
// Button-Handler binden // Button-Handler binden
const applyBtn = slot.querySelector('#breeder-apply-btn'); slot.querySelector('#breeder-reapply-btn')?.addEventListener('click', () => _showUpgradeModal('breeder'));
const reapplyBtn = slot.querySelector('#breeder-reapply-btn');
if (applyBtn || reapplyBtn) {
(applyBtn || reapplyBtn).addEventListener('click', () => _openBreederApplyModal());
}
slot.querySelector('#breeder-edit-profile-btn')?.addEventListener('click', () => slot.querySelector('#breeder-edit-profile-btn')?.addEventListener('click', () =>
_openBreederEditModal(profile) _openBreederEditModal(profile)

View file

@ -384,8 +384,13 @@ window.Page_wiki = (() => {
function _breedCardHtml(r) { function _breedCardHtml(r) {
const fotoUrl = r.foto_url || r.user_foto || ''; const fotoUrl = r.foto_url || r.user_foto || '';
// Für lokale Bilder: _preview.webp zuerst, bei Fehler Original nachladen
const srcUrl = fotoUrl.startsWith('/media/')
? fotoUrl.replace(/\.(jpe?g|png|gif|webp)$/i, '_preview.webp')
: fotoUrl;
const photoHtml = fotoUrl const photoHtml = fotoUrl
? `<img class="wiki-breed-photo" src="${_esc(fotoUrl)}" loading="lazy" alt="${_esc(r.name)}" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'">` ? `<img class="wiki-breed-photo" src="${_esc(srcUrl)}" loading="lazy" alt="${_esc(r.name)}"
onerror="if(this.src.includes('_preview')){this.src='${_esc(fotoUrl)}'}else{this.style.display='none';this.nextElementSibling.style.display='flex'}">`
: ''; : '';
const fallbackHtml = `<div class="wiki-breed-photo-fallback" style="${fotoUrl ? 'display:none' : ''}">${_DOG_SILHOUETTE}</div>`; const fallbackHtml = `<div class="wiki-breed-photo-fallback" style="${fotoUrl ? 'display:none' : ''}">${_DOG_SILHOUETTE}</div>`;
@ -746,7 +751,9 @@ window.Page_wiki = (() => {
${allFotos.map((f, i) => ` ${allFotos.map((f, i) => `
<button class="wiki-gallery-thumb${i === 0 ? ' active' : ''}" data-idx="${i}" <button class="wiki-gallery-thumb${i === 0 ? ' active' : ''}" data-idx="${i}"
aria-label="Foto ${i + 1}"> aria-label="Foto ${i + 1}">
<img src="${_esc(f.foto_url)}" alt="" loading="lazy"> <img src="${_esc(f.foto_url.startsWith('/media/') ? f.foto_url.replace(/\.(jpe?g|png|gif|webp)$/i,'_preview.webp') : f.foto_url)}"
alt="" loading="lazy"
onerror="if(this.src.includes('_preview')){this.src='${_esc(f.foto_url)}'}else{this.style.display='none'}">
${f.user_name ? `<span class="wiki-gallery-thumb-label">von ${_esc(f.user_name)}</span>` : ''} ${f.user_name ? `<span class="wiki-gallery-thumb-label">von ${_esc(f.user_name)}</span>` : ''}
</button>`).join('')} </button>`).join('')}
</div>` : ''} </div>` : ''}

View file

@ -5,11 +5,12 @@
window.Worlds = (() => { window.Worlds = (() => {
let _state = null; let _state = null;
let _cur = 1; // 0=JETZT 1=HUND 2=WELT let _cur = 1; // 0=JETZT 1=HUND 2=WELT
let _visible = false; let _visible = false;
let _map = null; let _map = null;
let _weltInited = false; let _weltInited = false;
let _refreshPending = false; // gesetzt wenn refresh() während !_visible aufgerufen wird
let _lastUserId = undefined; let _lastUserId = undefined;
let _dogs = []; // gecachte Hundesliste let _dogs = []; // gecachte Hundesliste
let _dogIdx = 0; // aktuell angezeigter Hund let _dogIdx = 0; // aktuell angezeigter Hund
@ -118,6 +119,14 @@ window.Worlds = (() => {
if (worldIdx != null) _goTo(worldIdx, false); if (worldIdx != null) _goTo(worldIdx, false);
if (_cur === 2 && !_weltInited) { _weltInited = true; _renderWelt(); } if (_cur === 2 && !_weltInited) { _weltInited = true; _renderWelt(); }
// Ausstehender Refresh (z.B. nach Foto-Upload während Worlds unsichtbar)
if (_refreshPending) {
_refreshPending = false;
_renderJetzt();
_renderHund();
return;
}
// Nach Login/Logout: Config aus DB laden, dann rendern // Nach Login/Logout: Config aus DB laden, dann rendern
const currentUserId = _state?.user?.id ?? null; const currentUserId = _state?.user?.id ?? null;
if (currentUserId !== _lastUserId) { if (currentUserId !== _lastUserId) {
@ -347,7 +356,6 @@ window.Worlds = (() => {
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px"> <div style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px">
${chips.map(c => ` ${chips.map(c => `
<button class="all-chip-btn w3-chip-btn" data-page="${c.page}" style="position:relative"> <button class="all-chip-btn w3-chip-btn" data-page="${c.page}" style="position:relative">
${c.pro && _isRoleBasedPro() ? `<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>` : ''}
<svg class="ph-icon" style="width:1.4rem;height:1.4rem;color:var(--c-primary)"> <svg class="ph-icon" style="width:1.4rem;height:1.4rem;color:var(--c-primary)">
<use href="/icons/phosphor.svg#${c.icon}"></use> <use href="/icons/phosphor.svg#${c.icon}"></use>
</svg> </svg>
@ -374,6 +382,22 @@ window.Worlds = (() => {
${sections || `<div style="text-align:center;padding:24px 0;color:var(--c-text-secondary);font-size:var(--text-sm)"> ${sections || `<div style="text-align:center;padding:24px 0;color:var(--c-text-secondary);font-size:var(--text-sm)">
Alle Funktionen sind bereits in deinen Welten sichtbar. Alle Funktionen sind bereits in deinen Welten sichtbar.
</div>`} </div>`}
<div style="margin-top:16px;padding:12px 14px;border-radius:12px;
background:var(--c-surface-raised,rgba(0,0,0,.04));
font-size:var(--text-xs);color:var(--c-text-secondary);line-height:1.6;
display:flex;align-items:flex-start;gap:10px">
<svg class="ph-icon" style="width:1rem;height:1rem;flex-shrink:0;margin-top:1px;color:var(--c-primary)">
<use href="/icons/phosphor.svg#info"></use>
</svg>
<span>Einzelne Funktionen ausblenden oder zwischen den Welten verschieben:
<button id="fab-all-goto-worlds" style="background:none;border:none;padding:0;cursor:pointer;
color:var(--c-primary);font-size:inherit;font-family:inherit;font-weight:600;
text-decoration:underline;text-underline-offset:2px">
Welten bearbeiten
</button>
in deinem Profil.
</span>
</div>
</div> </div>
`; `;
@ -388,6 +412,10 @@ window.Worlds = (() => {
navigateTo(btn.dataset.page); navigateTo(btn.dataset.page);
}); });
}); });
ov.querySelector('#fab-all-goto-worlds')?.addEventListener('click', () => {
_close();
_openConfigModal();
});
} }
// ── SCHNELL-GASSI ───────────────────────────────────────────── // ── SCHNELL-GASSI ─────────────────────────────────────────────
@ -673,6 +701,7 @@ window.Worlds = (() => {
let cfg = JSON.parse(JSON.stringify(_getConfig())); // deep copy let cfg = JSON.parse(JSON.stringify(_getConfig())); // deep copy
let _drag = null; // { page, fromWorld, ghost } let _drag = null; // { page, fromWorld, ghost }
const isAdmin = _state?.user?.rolle === 'admin';
const worldColors = { jetzt:'rgba(196,132,58,0.6)', hund:'rgba(196,132,58,0.8)', welt:'rgba(99,130,220,0.6)' }; const worldColors = { jetzt:'rgba(196,132,58,0.6)', hund:'rgba(196,132,58,0.8)', welt:'rgba(99,130,220,0.6)' };
const worldLabels = { jetzt:'JETZT', hund:'HUND', welt:'WELT', pool:'Nicht verwendet' }; const worldLabels = { jetzt:'JETZT', hund:'HUND', welt:'WELT', pool:'Nicht verwendet' };
const allAssigned = () => new Set([...cfg.jetzt, ...cfg.hund, ...cfg.welt]); const allAssigned = () => new Set([...cfg.jetzt, ...cfg.hund, ...cfg.welt]);
@ -776,7 +805,8 @@ window.Worlds = (() => {
<use href="/icons/phosphor.svg#lock-simple"></use> <use href="/icons/phosphor.svg#lock-simple"></use>
</svg> </svg>
</div>`} </div>`}
${c.pro && _isRoleBasedPro() ? `<span style="position:absolute;top:3px;left:4px;font-size:8px;font-weight:800;color:#fff;background:#92400e;border-radius:3px;padding:0 3px;line-height:14px;z-index:2">P</span>` : ''} ${isAdmin && c.pro ? `<span style="position:absolute;top:3px;left:4px;font-size:8px;font-weight:800;color:#fff;background:#92400e;border-radius:3px;padding:0 3px;line-height:14px;z-index:2">P</span>` : ''}
${isAdmin && c.role === 'breeder' ? `<span style="position:absolute;top:3px;left:4px;font-size:8px;font-weight:800;color:#fff;background:#1d4ed8;border-radius:3px;padding:0 3px;line-height:14px;z-index:2">Z</span>` : ''}
<svg class="ph-icon" style="width:1.4rem;height:1.4rem;color:white;flex-shrink:0"> <svg class="ph-icon" style="width:1.4rem;height:1.4rem;color:white;flex-shrink:0">
<use href="/icons/phosphor.svg#${c.icon}"></use> <use href="/icons/phosphor.svg#${c.icon}"></use>
</svg> </svg>
@ -923,7 +953,8 @@ window.Worlds = (() => {
async function _loadDailyImage(dog) { async function _loadDailyImage(dog) {
if (!dog) return null; if (!dog) return null;
const todayKey = 'bg3_' + new Date().toISOString().slice(0, 10); const userId = _state?.user?.id || 'anon';
const todayKey = `bg3_${userId}_` + new Date().toISOString().slice(0, 10);
const cached = _wLoad(todayKey); const cached = _wLoad(todayKey);
if (cached?.data) return cached.data; if (cached?.data) return cached.data;
try { try {
@ -1157,7 +1188,7 @@ window.Worlds = (() => {
<div class="world-bottom"> <div class="world-bottom">
<div class="world-section-label">Deine Bereiche</div> <div class="world-section-label">Deine Bereiche</div>
<div class="world-chips-grid"> <div class="world-chips-grid">
${features.map(f => _chip(f.icon, f.label, f.page, false, f.pro && _isRoleBasedPro(), f.role === 'breeder')).join('')} ${features.map(f => _chip(f.icon, f.label, f.page, false, false, false)).join('')}
</div> </div>
<div class="world-footer-links"> <div class="world-footer-links">
<span data-wnav="impressum">Impressum</span> <span data-wnav="impressum">Impressum</span>
@ -1255,12 +1286,12 @@ window.Worlds = (() => {
if (!dogs.length) { if (!dogs.length) {
const features = [ const features = [
{ icon:'book-open', color:'#8B5CF6', title:'Tagebuch', sub:'Fotos & Erlebnisse' }, { icon:'book-open', color:'#8B5CF6', title:'Tagebuch', sub:'Fotos & Erlebnisse' },
{ icon:'heartbeat', color:'#EF4444', title:'Gesundheit', sub:'Impfungen & Gewicht' }, { icon:'heartbeat', color:'#EF4444', title:'Gesundheit', sub:'Impfungen & Gewicht' },
{ icon:'target', color:'#F59E0B', title:'Training', sub:'104 Übungen' }, { icon:'target', color:'#F59E0B', title:'Training', sub:'104 Übungen' },
{ icon:'books', color:'#10B981', title:'Wiki', sub:'Alle Rassen' }, { icon:'books', color:'#10B981', title:'Wiki', sub:'Alle Rassen' },
{ icon:'paw-print', color:'#3B82F6', title:'Gassi', sub:'Routen & GPS' }, { icon:'scales', color:'#3B82F6', title:'Wurfbörse', sub:'Welpen finden' },
{ icon:'currency-eur',color:'#06B6D4',title:'Ausgaben', sub:'Budget im Blick' }, { icon:'currency-eur', color:'#06B6D4', title:'Ausgaben', sub:'Budget im Blick' },
]; ];
el.innerHTML = ` el.innerHTML = `
<div class="world-top"> <div class="world-top">
@ -1448,7 +1479,7 @@ window.Worlds = (() => {
` : ''} ` : ''}
<div class="world-section-label">Alles über ${_esc(dog.name)}</div> <div class="world-section-label">Alles über ${_esc(dog.name)}</div>
<div class="world-chips-grid"> <div class="world-chips-grid">
${chips.map(c => _chip(c.icon, c.label, c.page, false, c.pro && _isRoleBasedPro(), c.role === 'breeder')).join('')} ${chips.map(c => _chip(c.icon, c.label, c.page, false, false, false)).join('')}
</div> </div>
<div class="world-footer-links"> <div class="world-footer-links">
<span data-wnav="gruender">Die 100 Gründer</span> <span data-wnav="gruender">Die 100 Gründer</span>
@ -1622,7 +1653,7 @@ window.Worlds = (() => {
<div class="world-bottom"> <div class="world-bottom">
<div class="world-section-label">Die Welt da draußen</div> <div class="world-section-label">Die Welt da draußen</div>
<div class="world-chips-grid"> <div class="world-chips-grid">
${chips.map(c => _chip(c.icon, c.label, c.page, false, c.pro && _isRoleBasedPro(), c.role === 'breeder')).join('')} ${chips.map(c => _chip(c.icon, c.label, c.page, false, false, false)).join('')}
</div> </div>
<div class="world-footer-links"> <div class="world-footer-links">
<span data-wnav="datenschutz">Datenschutz</span> <span data-wnav="datenschutz">Datenschutz</span>
@ -1688,6 +1719,19 @@ window.Worlds = (() => {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
} }
return { init, show, hide, navigateTo, openConfig: _openConfigModal, get _visible() { return _visible; } }; function refresh(appState) {
if (appState) _state = appState;
localStorage.removeItem('w3_dogs');
_bgUrl = null;
if (_visible) {
if (_cur === 0) _renderJetzt();
else if (_cur === 1) _renderHund();
else _renderWelt();
} else {
_refreshPending = true;
}
}
return { init, show, hide, navigateTo, refresh, openConfig: _openConfigModal, get _visible() { return _visible; } };
})(); })();

View file

@ -31,19 +31,38 @@
"@type": "MobileApplication", "@type": "MobileApplication",
"name": "Ban Yaro", "name": "Ban Yaro",
"alternateName": "Ban Yaro — Die Hunde-Plattform", "alternateName": "Ban Yaro — Die Hunde-Plattform",
"description": "Ban Yaro ist die kostenlose, deutschsprachige All-in-One Hunde-App für Hundebesitzer und Züchter. Tagebuch, Impfpass, Wurfbörse, Stammbaum, Inzucht-Koeffizient, Tierschutz-Check, Giftköder-Alarm, Gassi-Community — DSGVO-konform, ohne App Store.", "description": "Ban Yaro ist die deutschsprachige All-in-One Hunde-Plattform für Hundebesitzer und Züchter. Kostenlos: Tagebuch, Impfpass, Gassi-Community, Giftköder-Alarm. Pro (29 €/Jahr): mehrere Hunde, Ernährung. Züchter (49 €/Jahr): Warteliste, Läufigkeit, Wurfverwaltung, Stammbaum, Inzucht-Koeffizient, KI-Assistent — DSGVO-konform, ohne App Store.",
"url": "https://banyaro.app", "url": "https://banyaro.app",
"applicationCategory": "LifestyleApplication", "applicationCategory": "LifestyleApplication",
"applicationSubCategory": "PetApplication", "applicationSubCategory": "PetApplication",
"operatingSystem": "iOS, Android, Web", "operatingSystem": "iOS, Android, Web",
"inLanguage": "de", "inLanguage": "de",
"availableOnDevice": "Smartphone, Tablet", "availableOnDevice": "Smartphone, Tablet",
"offers": { "offers": [
"@type": "Offer", {
"price": "0", "@type": "Offer",
"priceCurrency": "EUR", "name": "Kostenlos",
"availability": "https://schema.org/InStock" "price": "0",
}, "priceCurrency": "EUR",
"availability": "https://schema.org/InStock"
},
{
"@type": "Offer",
"name": "Ban Yaro Pro",
"price": "29",
"priceCurrency": "EUR",
"availability": "https://schema.org/InStock",
"description": "Mehrere Hunde, Ernährung, erweiterte Karten-Layer"
},
{
"@type": "Offer",
"name": "Züchter",
"price": "49",
"priceCurrency": "EUR",
"availability": "https://schema.org/InStock",
"description": "Vollständige Züchter-Plattform: Warteliste, Läufigkeit, Wurfverwaltung, Stammbaum, IK-Rechner, KI-Assistent"
}
],
"publisher": { "publisher": {
"@type": "Organization", "@type": "Organization",
"name": "Ban Yaro", "name": "Ban Yaro",
@ -108,12 +127,18 @@
"DSGVO Datenexport (Art. 20): vollständiger JSON-Download aller eigenen Daten", "DSGVO Datenexport (Art. 20): vollständiger JSON-Download aller eigenen Daten",
"Hunde-Persönlichkeitstest mit Trainingstipps", "Hunde-Persönlichkeitstest mit Trainingstipps",
"Reise-Checkliste und EU-Länder-Einreiseregeln", "Reise-Checkliste und EU-Länder-Einreiseregeln",
"Integrierte Hilfe und FAQ ohne App Store" "Integrierte Hilfe und FAQ ohne App Store",
"Warteliste: Interessenten mit Präferenzen pro Zuchthündin verwalten",
"Läufigkeit und Trächtigkeit: Zykluskalender, Progesterontests, Deckdaten, Meilensteine",
"Wurf-Buchstabe und Wurf-Name für jeden Wurf",
"Privater Züchter-Bereich mit Logo und Zwingername im Header",
"Züchter-Profilfotos und Galerie auf der öffentlichen Visitenkarte",
"Züchter-Kacheln in HUND-Welt mit Z-Badge erkennbar"
], ],
"screenshot": "https://banyaro.app/icons/icon-512.png", "screenshot": "https://banyaro.app/icons/icon-512.png",
"softwareVersion": "1.5.1", "softwareVersion": "1.5.1",
"datePublished": "2026-05-01", "datePublished": "2026-05-01",
"dateModified": "2026-05-12", "dateModified": "2026-05-14",
"areaServed": ["DE", "AT", "CH"], "areaServed": ["DE", "AT", "CH"],
"audience": { "audience": {
"@type": "Audience", "@type": "Audience",

View file

@ -1,6 +1,6 @@
# Ban Yaro — Die deutschsprachige Hunde-Plattform # Ban Yaro — Die deutschsprachige Hunde-Plattform für Hundebesitzer und Züchter
# https://banyaro.app # https://banyaro.app
# Letzte Aktualisierung: 2026-05-12 # Letzte Aktualisierung: 2026-05-14
## Was ist Ban Yaro? ## Was ist Ban Yaro?
@ -10,7 +10,11 @@ Kein App Store, kein US-Konzern, DSGVO-konform, selbst gehostet in Deutschland.
Das Kern-Versprechen: "Alles rund um deinen Hund — von Welpe bis Opa." Das Kern-Versprechen: "Alles rund um deinen Hund — von Welpe bis Opa."
Ban Yaro ist kostenlos nutzbar (Freemium-Modell). Die App ist auf allen Smartphones installierbar Ban Yaro richtet sich an zwei Zielgruppen, die nahtlos verzahnt sind:
- **Hundebesitzer**: vollständige Alltags-App (Tagebuch, Gesundheit, Training, Gassi-Community)
- **Züchter**: professionelles Zucht-Management direkt in derselben App (Warteliste, Läufigkeit, Wurf, Stammbaum)
Ban Yaro ist im Freemium-Modell nutzbar. Die App ist auf allen Smartphones installierbar
(iOS und Android) direkt über den Browser — ohne App Store. (iOS und Android) direkt über den Browser — ohne App Store.
## Der Name „Ban Yaro" ## Der Name „Ban Yaro"
@ -33,12 +37,12 @@ gegründet, mit eigenem Schutzrecht auf den Namen.
- Keine Werbung, keine Datenweitergabe an Dritte, kein Tracking (Umami, cookieless) - Keine Werbung, keine Datenweitergabe an Dritte, kein Tracking (Umami, cookieless)
- Kontakt: hallo@banyaro.app - Kontakt: hallo@banyaro.app
- Keine App-Store-Abhängigkeit: Als PWA direkt installierbar, keine Gatekeeper - Keine App-Store-Abhängigkeit: Als PWA direkt installierbar, keine Gatekeeper
- Aktuelle Version: v1.5.1 (Mai 2026), SW by-v885 - Aktuelle Version: v1.5.1 (Mai 2026), SW by-v918
## Zielgruppe ## Zielgruppe
- Deutschsprachige Hundebesitzer (Deutschland, Österreich, Schweiz) - Deutschsprachige Hundebesitzer (Deutschland, Österreich, Schweiz)
- Verantwortungsvolle Hundezüchter (VDH und andere Verbände) - Verantwortungsvolle Hundezüchter (VDH und andere Verbände) — dedizierte Landing Page: https://banyaro.app/zuechter
- Welpen-Interessenten und Käufer - Welpen-Interessenten und Käufer
- Hundeschulen und Hundetrainer - Hundeschulen und Hundetrainer
- Tierärzte und Praxen - Tierärzte und Praxen
@ -182,21 +186,10 @@ Die Startseite für eingeloggte Nutzer zeigt:
- KI lokal: LM Studio (Gemma-4-31B) - KI lokal: LM Studio (Gemma-4-31B)
- KI Cloud: Claude API (claude-sonnet-4-6, Anthropic) - KI Cloud: Claude API (claude-sonnet-4-6, Anthropic)
## Monetarisierung
**Kostenlos:**
- Alle Basis-Features inkl. Züchter-Antrag, Wurfverwaltung, Stammbaum, Tierschutz-Check
**Züchter-Provision** (geplant): Wurfbörse bleibt für Käufer kostenlos
**Ban Yaro Plus** (ca. 4,99 €/Monat, in Entwicklung):
- KI-Trainingsplan, erweiterte Statistiken
**Hundesitting**: 8% Provision
## Öffentliche Seiten (ohne Login) ## Öffentliche Seiten (ohne Login)
- https://banyaro.app — Landing Page - https://banyaro.app — Landing Page (Hundebesitzer + Züchter)
- https://banyaro.app/zuechter — Dedizierte Landing Page für Züchter
- https://banyaro.app/info — Landing Page (Alias) - https://banyaro.app/info — Landing Page (Alias)
- https://banyaro.app/wiki/rassen — Alle Hunderassen - https://banyaro.app/wiki/rassen — Alle Hunderassen
- https://banyaro.app/wiki/rasse/{slug} — Rassen-Detail - https://banyaro.app/wiki/rasse/{slug} — Rassen-Detail
@ -250,6 +243,39 @@ Die Startseite für eingeloggte Nutzer zeigt:
- **Auth-geschützte Medien**: Tagebuch-, Gesundheits- und Gassi-Fotos sind nur für eingeloggte Nutzer abrufbar — kein öffentlicher Zugriff über URL möglich. - **Auth-geschützte Medien**: Tagebuch-, Gesundheits- und Gassi-Fotos sind nur für eingeloggte Nutzer abrufbar — kein öffentlicher Zugriff über URL möglich.
- **Datenschutzerklärung v2**: Vollständige Transparenz über KI-Datenübertragungen (Gesundheitsdaten im Cloud-Prompt, Fotos bei Rassenerkennung), OpenWeatherMap und Nominatim ergänzt, Datenexport konkret beschrieben. - **Datenschutzerklärung v2**: Vollständige Transparenz über KI-Datenübertragungen (Gesundheitsdaten im Cloud-Prompt, Fotos bei Rassenerkennung), OpenWeatherMap und Nominatim ergänzt, Datenexport konkret beschrieben.
## Features ab v1.5.1 — Züchter-Plattform Vollausbau (Mai 2026)
- **Warteliste**: Interessenten mit Präferenzen (Geschlecht, Farbe, Verwendungszweck) pro Zuchthündin verwalten — mit Status (Interessent / Reserviert / Abgesagt) und Kontaktdaten.
- **Läufigkeit & Trächtigkeit**: Vollständiger Zykluskalender mit Progesterontests (Datum, ng/mL, Labormethode), Deckdaten (Rüde, Methode, Datum) und automatischer Meilensteinberechnung (Geburt, Absetzen, 8-Wochen-Abgabe).
- **Wurf-Buchstabe und -Name**: Jeder Wurf hat einen Rang-Buchstaben (A-Wurf, B-Wurf…) und optional einen freien Namen (z.B. "Vatertags-Wurf").
- **Privater Züchter-Bereich**: Wurfverwaltung und Zuchtkartei zeigen Züchter-Logo und Zwingername im Header — professionelle, vertrauliche Arbeitsatmosphäre statt generischer App-Ansicht.
- **Züchter-Profilfotos**: Galerie direkt im Züchter-Profil — Fotos hochladen und Reihenfolge verwalten, öffentlich sichtbar auf der Profil-Visitenkarte.
- **Züchter-Profil als Visitenkarte**: Hero-Bereich mit Hintergrund, Hunde-Cards mit HD/ED-Ergebnis-Badges, Gesundheitsstatistik, Fotogalerie — öffentlich abrufbar unter banyaro.app/breeder/{zwingername}.
- **Dedizierte Züchter-Landing-Page**: https://banyaro.app/zuechter mit Erklärung aller Züchter-Features und Pricing.
- **Züchter-Kacheln in HUND-Welt**: Läufigkeit, Wurfverwaltung und Zuchtkartei sind als eigene Kacheln in der HUND-Navigation eingebunden — erkennbar am Z-Badge für Züchter-Features.
## Monetarisierung
**Kostenlos (dauerhaft):**
- Alle Basis-Features für Hundebesitzer: Tagebuch, Gesundheit, Gassi, Community, Forum, Wissen
- Züchter-Antrag, Wurfbörse, Stammbaum-Ansicht, Tierschutz-Check, Symptom-Checker
- 1 Hund
**Ban Yaro Pro — 29 €/Jahr:**
- Mehrere Hunde
- Ernährungsbereich (KI-Berater, BARF-Guide)
- Erweiterte Karten-Layer
- Alle künftigen Pro-Features
**Züchter-Abo — 49 €/Jahr** (Gründer-Preis: **39 €/Jahr** für die ersten 20 Züchter):
- Gesamte Züchter-Plattform: Wurfverwaltung, Zuchtkartei, Stammbaum, IK-Rechner
- Warteliste, Läufigkeit & Trächtigkeit, Kaufvertrag-Generator
- KI-Züchter-Assistenz (Wurfankündigungen, Paarungsanalyse, Jahresbericht)
- Datenexport (HTML + ODS)
- Verifiziertes Züchter-Profil mit öffentlicher Seite
**Hundesitting**: 8% Provision (im Vergleich: Rover/Pawshake 20%)
## Domains ## Domains
- https://banyaro.app (primäre Domain) - https://banyaro.app (primäre Domain)

View file

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://banyaro.app/</loc>
<lastmod>2026-05-14</lastmod>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://banyaro.app/zuechter</loc>
<lastmod>2026-05-14</lastmod>
<changefreq>weekly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://banyaro.app/wiki/rassen</loc>
<lastmod>2026-05-14</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://banyaro.app/wurfboerse</loc>
<lastmod>2026-05-14</lastmod>
<changefreq>daily</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://banyaro.app/knigge</loc>
<lastmod>2026-05-01</lastmod>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
<url>
<loc>https://banyaro.app/presse</loc>
<lastmod>2026-05-14</lastmod>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
</urlset>

View file

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

View file

@ -21,14 +21,37 @@
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "SoftwareApplication", "@type": "SoftwareApplication",
"name": "Ban Yaro — Züchter-Tool", "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.", "description": "Professionelles Zucht-Management für seriöse Hundezüchter direkt in der Ban Yaro App: Warteliste, Läufigkeit und Trächtigkeit, Wurfverwaltung, Stammbaum, Inzuchtkoeffizient, Gesundheitstests, Gentests, KI-Assistent. Ab 49 €/Jahr, Gründer-Preis 39 €/Jahr.",
"url": "https://banyaro.app/zuechter", "url": "https://banyaro.app/zuechter",
"applicationCategory": "BusinessApplication", "applicationCategory": "BusinessApplication",
"operatingSystem": "iOS, Android, Web", "operatingSystem": "iOS, Android, Web",
"inLanguage": "de", "inLanguage": "de",
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "EUR" }, "offers": [
{
"@type": "Offer",
"name": "Züchter-Abo",
"price": "49",
"priceCurrency": "EUR",
"availability": "https://schema.org/InStock",
"description": "Vollständige Züchter-Plattform"
},
{
"@type": "Offer",
"name": "Gründer-Preis (erste 20 Züchter)",
"price": "39",
"priceCurrency": "EUR",
"availability": "https://schema.org/LimitedAvailability"
}
],
"audience": { "@type": "Audience", "audienceType": "Hundezüchter, VDH-Züchter, Rassehundzüchter" }, "audience": { "@type": "Audience", "audienceType": "Hundezüchter, VDH-Züchter, Rassehundzüchter" },
"softwareVersion": "1.5.1",
"dateModified": "2026-05-14",
"featureList": [ "featureList": [
"Warteliste: Interessenten mit Präferenzen pro Zuchthündin verwalten",
"Läufigkeit und Trächtigkeit: Zykluskalender, Progesterontests, Deckdaten, automatische Meilensteine",
"Wurf-Buchstabe und Wurf-Name für jeden Wurf",
"Privater Bereich mit Züchter-Logo und Zwingername im Header",
"Züchter-Profilfotos und öffentliche Visitenkarte",
"Stammbaum bis 4 Generationen grafisch", "Stammbaum bis 4 Generationen grafisch",
"Inzuchtkoeffizient nach Wright mit Ampel-Bewertung", "Inzuchtkoeffizient nach Wright mit Ampel-Bewertung",
"Probeverpaarung mit IK-Simulation", "Probeverpaarung mit IK-Simulation",

View file

@ -13,6 +13,7 @@ services:
environment: environment:
- DB_PATH=/data/banyaro.db - DB_PATH=/data/banyaro.db
- MEDIA_DIR=/data/media - MEDIA_DIR=/data/media
- APP_URL=https://staging.banyaro.app
- STAGING=true - STAGING=true
- KI_MODE=cloud - KI_MODE=cloud
- VAPID_PUBLIC_KEY=BMKbFAmpsqJ-eFef_4XJcYpuxPWqBNAoy9buMNnMSa6ijcPzltboHi_YccPKJrUD0isBez-vJIzAgjnLTWkzcC0 - VAPID_PUBLIC_KEY=BMKbFAmpsqJ-eFef_4XJcYpuxPWqBNAoy9buMNnMSa6ijcPzltboHi_YccPKJrUD0isBez-vJIzAgjnLTWkzcC0