Compare commits

...

8 commits

Author SHA1 Message Date
52160e4dc0 Fix: Admin Züchter-Tab — Alle Züchter Liste + Antraege-Section (SW by-v921)
- GET /api/admin/breeders: neuer Endpunkt listet alle aktiven Züchter
  mit Zwingername, Rasse, Stadt, Würfe/Zuchthunde-Zähler, subscription_tier
- _renderZuechter: zwei Sektionen parallel geladen
  - "Offene Anträge" (wie vorher, aber mit Section-Header auch wenn leer)
  - "Alle Züchter": Tabelle analog Nutzer-Tab mit Abo-Button → _changeTier
- api.js: API.breeder.allList() hinzugefügt
- SW by-v921, APP_VER 921
2026-05-14 10:06:48 +02:00
f6b37717b4 Feature: Upgrade-Anfragen-System — User-Flow + Admin-Panel (SW by-v920)
- DB: upgrade_requests-Tabelle (user_id, tier, message, fulfilled_at)
- POST /api/upgrade-request: Anfrage speichern + Admin-Benachrichtigungsmail
- GET/POST /api/admin/upgrade-requests[/{id}/fulfill]: Admin-Endpunkte
  — fulfill setzt subscription_tier + sendet Bestätigungsmail an User
- action-items: upgrades_pending zählt offene Anfragen → Badge im Admin
- Admin-Tab "Upgrades": Tabelle offener/erledigter Anfragen, Freischalten-Button
  mit Confirm-Modal, automatischer Tier-Setzung und Bestätigungsmail
- Settings: Upgrade-Modal sendet echte API-Anfrage statt nur mailto
  — doppelte Anfrage wird erkannt (already:true → Toast statt Fehler)
- api.js: API.auth.upgradeRequest(tier, message) hinzugefügt
- SW by-v920, APP_VER 920
2026-05-14 09:59:11 +02:00
d61fd155c5 Feature: Abo & Tarif in Einstellungen — Upgrade-UI für Pro + Züchter (SW by-v919)
- /api/me gibt subscription_tier jetzt zurück (fehlte im SELECT)
- settings.js: "Pro kommt bald" durch echte Abo-Karte ersetzt
  - Zeigt aktuellen Tarif mit farbigem Badge (Kostenlos/Pro/Züchter/Admin)
  - Standard-Nutzer: zwei Upgrade-Buttons (Pro 29€/Jahr, Züchter 49€/Jahr)
  - Pro-Nutzer: Pro-Badge + optionaler Züchter-Upgrade
  - Züchter/Admin: Status-Badge, keine Upgrade-Buttons
- Upgrade-Modal: Features-Liste + ehrlicher Hinweis auf manuelle Freischaltung
  + mailto-Button mit vorausgefülltem Betreff und Account-E-Mail
- SW by-v919, APP_VER 919
2026-05-14 09:48:01 +02:00
eaa2e02e88 SEO: llms.txt v1.5.1 + sitemap /zuechter + JSON-LD Pricing (Pro 29€/Züchter 49€)
- llms.txt: Dual-Audience-Positionierung, echte Preise, neue Züchter-Features
  (Warteliste, Läufigkeit, Wurf-Buchstabe/-Name, Privater Header, Profilfotos),
  neue URL /zuechter, SW by-v918, Datum 2026-05-14
- landing.html JSON-LD: 3 Offers (kostenlos/Pro 29€/Züchter 49€), 7 neue featureList-Einträge,
  dateModified 2026-05-14, Beschreibung mit Preisen
- zuechter.html JSON-LD: 2 Offers (49€/39€ Gründer), 5 neue Features, dateModified + softwareVersion
- sitemap.xml: neue statische Datei (Backup-Referenz, dynamic route in main.py)
- main.py sitemap: /zuechter mit priority 0.9 hinzugefügt
2026-05-14 09:43:21 +02:00
c5d4e730d9 Feature: Preise live — Pro 29€/Jahr, Züchter 49€/Jahr (Gründer 39€) auf Landing + Züchter-Seite 2026-05-14 09:31:49 +02:00
509d4eda2b Fix: UMAMI_URL in docker-compose.yml ergänzt — Analytics-Fehler auf Admin-Seite 2026-05-14 09:24:01 +02:00
3da4a1b6d7 Fix: Züchter Icon — volles Orange #C4843A + weißes Icon für maximalen Kontrast 2026-05-14 09:19:05 +02:00
5f5f3e9271 UX: Züchter-Karte Icon heller — rgba .35 + #f5c07a für besseren Kontrast 2026-05-14 09:09:59 +02:00
16 changed files with 695 additions and 76 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 = "921" # 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

@ -124,13 +124,17 @@ 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]
upgrades_pending = conn.execute(
"SELECT COUNT(*) FROM upgrade_requests WHERE fulfilled_at IS NULL"
).fetchone()[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,
} }
@ -1091,3 +1095,66 @@ async def generate_media_previews(user=Depends(require_admin)):
errors += 1 errors += 1
return {"generated": generated, "skipped": skipped, "errors": errors} return {"generated": generated, "skipped": skipped, "errors": errors}
# ------------------------------------------------------------------
# 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

@ -183,6 +183,27 @@ 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 AND is_zucht_hund=1) AS zuchthunde_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 bp.verified_at DESC NULLS LAST, 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

@ -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`, {}); },

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 = '921'; // ← 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.zuchthunde_count || 0} Zuchthunde</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

@ -77,6 +77,137 @@ 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 features = isPro
? ['Mehrere Hunde verwalten', 'Ernährungsbereich mit KI-Berater', 'Erweiterte Karten-Layer', 'Alle künftigen Pro-Features']
: ['Vollständige Züchter-Plattform', 'Warteliste, Läufigkeit & Trächtigkeit', 'Wurfverwaltung, Stammbaum, IK-Rechner', 'KI-Züchter-Assistent & Datenexport'];
const featureList = features.map(f =>
`<li style="padding:var(--space-1) 0;font-size:var(--text-sm)">✓ ${f}</li>`
).join('');
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>
<ul style="list-style:none;padding:0;margin:0 0 var(--space-4)">
${featureList}
</ul>
<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>
</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;
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.');
}
} catch (e) {
btn.disabled = false;
btn.textContent = 'Anfrage senden';
UI.toast.error(e.message || 'Fehler beim Senden.');
}
});
}
// ---------------------------------------------------------- // ----------------------------------------------------------
// RENDER // RENDER
// ---------------------------------------------------------- // ----------------------------------------------------------
@ -276,13 +407,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 +440,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
@ -829,6 +955,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?.();

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",
@ -555,8 +580,8 @@
<!-- Züchter --> <!-- Züchter -->
<a href="/zuechter" style="text-decoration:none" class="audience-card"> <a href="/zuechter" style="text-decoration:none" class="audience-card">
<div style="background:linear-gradient(135deg,#1a1208,#2d1f0e);border:2px solid rgba(196,132,58,.4);border-radius:14px;padding:2rem;height:100%;display:flex;flex-direction:column;gap:1rem"> <div style="background:linear-gradient(135deg,#1a1208,#2d1f0e);border:2px solid rgba(196,132,58,.4);border-radius:14px;padding:2rem;height:100%;display:flex;flex-direction:column;gap:1rem">
<div style="width:52px;height:52px;background:rgba(196,132,58,.15);border-radius:14px;display:flex;align-items:center;justify-content:center"> <div style="width:52px;height:52px;background:#C4843A;border-radius:14px;display:flex;align-items:center;justify-content:center">
<svg style="width:28px;height:28px;color:#C4843A" viewBox="0 0 256 256"><use href="/icons/phosphor.svg#certificate"></use></svg> <svg style="width:28px;height:28px;color:#ffffff" viewBox="0 0 256 256"><use href="/icons/phosphor.svg#certificate"></use></svg>
</div> </div>
<div> <div>
<h3 style="margin:0 0 .5rem;font-size:1.15rem;color:white">Für Züchter</h3> <h3 style="margin:0 0 .5rem;font-size:1.15rem;color:white">Für Züchter</h3>
@ -951,8 +976,10 @@
<section id="preise"> <section id="preise">
<div class="container"> <div class="container">
<h2>Preise</h2> <h2>Preise</h2>
<p class="section-intro">Ban Yaro startet kostenlos — mit allem was du täglich brauchst. Mehr Features kommen, wenn die Community gewachsen ist.</p> <p class="section-intro">Ban Yaro startet kostenlos — mit allem was du täglich brauchst.</p>
<div class="pricing-grid" style="max-width:860px;margin:0 auto;grid-template-columns:repeat(auto-fit,minmax(260px,1fr))"> <div class="pricing-grid" style="max-width:900px;margin:0 auto;grid-template-columns:repeat(auto-fit,minmax(260px,1fr))">
<!-- Kostenlos -->
<div class="pricing-card featured"> <div class="pricing-card featured">
<h3>Kostenlos</h3> <h3>Kostenlos</h3>
<div class="pricing-price">0 € <span>/ für immer</span></div> <div class="pricing-price">0 € <span>/ für immer</span></div>
@ -962,35 +989,58 @@
<li>Karte, Giftköder-Alarm, Gassiwetter</li> <li>Karte, Giftköder-Alarm, Gassiwetter</li>
<li>Forum, Wiki, Erste Hilfe</li> <li>Forum, Wiki, Erste Hilfe</li>
<li>Routen, Events, Rückrufe, Knigge</li> <li>Routen, Events, Rückrufe, Knigge</li>
<li>Sitting — 0% Provision, ihr handelt selbst aus</li> <li>Sitting — 0 % Provision</li>
<li>Persönlichkeitstest, Adoption, Ausgaben</li> <li>Persönlichkeitstest, Adoption, Ausgaben</li>
</ul> </ul>
<a href="/" class="section-cta-btn" style="display:block;text-align:center;margin-top:1.5rem"
onclick="sessionStorage.setItem('by_stay_in_app','1')">Kostenlos starten</a>
</div> </div>
<!-- Pro -->
<div class="pricing-card"> <div class="pricing-card">
<h3>Ban Yaro Pro</h3> <h3>Ban Yaro Pro</h3>
<div class="pricing-price" style="font-size:1.4rem">Kommt bald</div> <div class="pricing-price">29 € <span>/ Jahr</span></div>
<p style="font-size:0.85rem;color:var(--text-secondary);margin-bottom:1rem">Mehrere Hunde · erweiterte Community</p> <p style="font-size:0.85rem;color:var(--text-secondary);margin-bottom:1rem">Mehrere Hunde · erweiterte Community</p>
<ul> <ul>
<li>Mehrere Hunde verwalten</li> <li>Mehrere Hunde verwalten</li>
<li>KI-Trainer für personalisiertes Training</li> <li>KI-Trainer für personalisiertes Training</li>
<li>Direktnachrichten, Freunde, Playdate</li> <li>Direktnachrichten, Freunde, Playdate</li>
<li>Gassi-Treffen, Ernährung, Reise</li> <li>Gassi-Treffen, Ernährung, Reise</li>
<li>Notizblock</li> <li>Notizblock mit KI-Analyse</li>
<li>Alles aus Kostenlos inklusive</li>
</ul> </ul>
<p style="margin-top:1rem;font-size:0.82rem;color:var(--text-muted)">Preis wird mit der Community entwickelt. Wer jetzt dabei ist, profitiert.</p> <a href="/" class="section-cta-btn" style="display:block;text-align:center;margin-top:1.5rem"
onclick="sessionStorage.setItem('by_stay_in_app','1')">Pro starten</a>
</div> </div>
<div class="pricing-card">
<h3>Züchter</h3> <!-- Züchter -->
<div class="pricing-price" style="font-size:1.4rem">Kommt bald</div> <div class="pricing-card" style="border-color:var(--primary);position:relative;overflow:hidden">
<div style="position:absolute;top:0;left:0;right:0;height:4px;background:linear-gradient(90deg,#C4843A,#f5c07a)"></div>
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:.25rem">
<h3 style="margin:0">Züchter</h3>
<span style="background:#fef3c7;color:#92400e;font-size:0.7rem;font-weight:700;
padding:2px 8px;border-radius:999px">
🎉 Gründer: 39 €
</span>
</div>
<div class="pricing-price">49 € <span>/ Jahr</span></div>
<p style="font-size:0.85rem;color:var(--text-secondary);margin-bottom:1rem">Professionelle Zucht · alles aus Pro</p> <p style="font-size:0.85rem;color:var(--text-secondary);margin-bottom:1rem">Professionelle Zucht · alles aus Pro</p>
<ul> <ul>
<li>Stammbaum bis 4 Generationen</li> <li>Stammbaum bis 4 Generationen</li>
<li>Inzucht-Koeffizient nach Wright</li> <li>Inzucht-Koeffizient nach Wright</li>
<li>Wurfverwaltung + Kaufvertrag</li> <li>Wurfverwaltung + Warteliste</li>
<li>Tierschutz-Check (automatisch)</li> <li>Kaufvertrag automatisch generiert</li>
<li>Wurfbörse + verifiziertes Profil</li> <li>Läufigkeit &amp; Trächtigkeits-Meilensteine</li>
<li>KI-Assistent für Züchter</li>
<li>Tierschutz-Check automatisch</li>
<li>Verifiziertes Züchterprofil</li>
</ul> </ul>
<p style="margin-top:0.75rem;font-size:0.8rem;color:var(--primary-dark);font-weight:600">
Jetzt: 39 €/Jahr für die ersten 20 Züchter
</p>
<a href="/zuechter" class="section-cta-btn" style="display:block;text-align:center;margin-top:1rem">Als Züchter registrieren</a>
</div> </div>
</div> </div>
</div> </div>
</section> </section>

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-v921';
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",
@ -548,8 +571,15 @@
<!-- CTA --> <!-- CTA -->
<section class="cta-section"> <section class="cta-section">
<div class="container"> <div class="container">
<h2>Jetzt kostenlos starten.</h2> <div style="display:inline-block;background:rgba(255,255,255,.1);border:1px solid rgba(255,255,255,.25);
<p>Züchter-Antrag stellen, Zuchthunde anlegen, ersten Wurf veröffentlichen — alles kostenlos, kein App Store, keine Kreditkarte.</p> border-radius:999px;padding:.35rem 1.25rem;margin-bottom:1.25rem;font-size:.85rem;font-weight:600">
🎉 Gründer-Preis: <strong>39 €/Jahr</strong> für die ersten 20 Züchter
</div>
<h2>Jetzt starten.</h2>
<p style="margin-bottom:.75rem">Züchter-Antrag stellen, Zuchthunde anlegen, ersten Wurf veröffentlichen.</p>
<p style="font-size:.9rem;opacity:.7;margin-bottom:1.75rem">
39 €/Jahr für die ersten 20 Gründer-Züchter · danach 49 €/Jahr · kein App Store · keine Kreditkarte zum Start
</p>
<a href="/#register?rolle=breeder" class="cta-btn" onclick="sessionStorage.setItem('by_stay_in_app','1')">Als Züchter registrieren</a> <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> <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> </div>

View file

@ -15,6 +15,7 @@ services:
- VAPID_PUBLIC_KEY=BMKbFAmpsqJ-eFef_4XJcYpuxPWqBNAoy9buMNnMSa6ijcPzltboHi_YccPKJrUD0isBez-vJIzAgjnLTWkzcC0 - VAPID_PUBLIC_KEY=BMKbFAmpsqJ-eFef_4XJcYpuxPWqBNAoy9buMNnMSa6ijcPzltboHi_YccPKJrUD0isBez-vJIzAgjnLTWkzcC0
- VAPID_PRIVATE_KEY=8PWa9vvwMqtqsJEJGcwmiLhR0_Yl7duVX3wmWiKS878 - VAPID_PRIVATE_KEY=8PWa9vvwMqtqsJEJGcwmiLhR0_Yl7duVX3wmWiKS878
- VAPID_CONTACT=mailto:admin@banyaro.app - VAPID_CONTACT=mailto:admin@banyaro.app
- UMAMI_URL=https://umami.motocamp.de
healthcheck: healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/')"] test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/')"]
interval: 30s interval: 30s