diff --git a/backend/database.py b/backend/database.py index 5c5516a..2c37d10 100644 --- a/backend/database.py +++ b/backend/database.py @@ -2341,6 +2341,24 @@ def _migrate(conn_factory): except Exception: 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 try: existing = conn.execute("SELECT COUNT(*) FROM route_dogs").fetchone()[0] diff --git a/backend/main.py b/backend/main.py index 097edd7..bed3d6f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -406,7 +406,7 @@ async def serve_media(path: str, request: _Request): raise _HE(404, "Nicht gefunden.") 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") async def assetlinks(): @@ -465,10 +465,11 @@ async def sitemap(): today = date.today().isoformat() urls = [ ("https://banyaro.app/", "weekly", "1.0"), - ("https://banyaro.app/info", "monthly", "0.9"), - ("https://banyaro.app/presse", "monthly", "0.8"), + ("https://banyaro.app/zuechter", "weekly", "0.9"), + ("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/knigge", "monthly", "0.8"), + ("https://banyaro.app/knigge", "monthly", "0.7"), ("https://banyaro.app/wurfboerse", "daily", "0.8"), ] diff --git a/backend/media_utils.py b/backend/media_utils.py index 4cb2e28..4fa6a82 100644 --- a/backend/media_utils.py +++ b/backend/media_utils.py @@ -56,7 +56,12 @@ def safe_media_path(media_dir: str, url: str) -> str | None: Konstruiert einen sicheren Dateipfad aus einer gespeicherten URL. 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)) real_base = os.path.realpath(media_dir) if not candidate.startswith(real_base + os.sep) and candidate != real_base: diff --git a/backend/routes/admin.py b/backend/routes/admin.py index 92a199d..b8cfb40 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -124,13 +124,20 @@ async def action_items(user=Depends(require_mod)): users_today = conn.execute( "SELECT COUNT(*) FROM users WHERE DATE(created_at)=DATE('now')" ).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 { - "jobs_pending": jobs, - "breeder_pending": breeders, - "reports_open": reports, - "fotos_pending": fotos, - "poi_edits_pending": poi_edits, - "users_today": users_today, + "jobs_pending": jobs, + "breeder_pending": breeders, + "reports_open": reports, + "fotos_pending": fotos, + "poi_edits_pending": poi_edits, + "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") async def generate_media_previews(user=Depends(require_admin)): - """Generiert fehlende _preview.jpg für alle Bilder in /data/media.""" - import io as _io + """Generiert fehlende _preview.webp für alle Bilder in /data/media.""" + import logging as _log from media_utils import generate_preview, _PREVIEW_EXTS + _logger = _log.getLogger(__name__) MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") generated = 0 skipped = 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) if not os.path.isdir(folder): + dirs_info[subdir] = "not found" continue - for fname in os.listdir(folder): - # Nur Original-Bilder (keine _preview, _thumb, Videos, PDFs) + files = os.listdir(folder) + dirs_info[subdir] = f"{len(files)} files" + for fname in files: low = fname.lower() if "_preview" in low or "_thumb" in low: continue @@ -1087,7 +1098,73 @@ async def generate_media_previews(user=Depends(require_admin)): generated += 1 else: skipped += 1 + _logger.warning(f"Preview None für {subdir}/{fname}") except Exception as exc: 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""" +

Hallo {req['name']},

+

dein Account wurde soeben auf {tier_label} freigeschaltet.

+

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.

+

Vielen Dank für dein Vertrauen!

+

Viele Grüße
René & das Ban Yaro Team

""" + 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"]} diff --git a/backend/routes/auth.py b/backend/routes/auth.py index a0174e6..863be66 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -240,7 +240,7 @@ async def me(user=Depends(get_current_user)): profil_sichtbarkeit, avatar_url, created_at, is_founder, is_partner, founder_number, is_founder_pending, notes_ki_enabled, gassi_stunde_push, - preferred_theme + preferred_theme, subscription_tier FROM users WHERE id=?""", (user["id"],) ).fetchone() @@ -335,6 +335,46 @@ async def forgot_password(data: ForgotPasswordRequest, request: Request): 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") async def reset_password(data: ResetPasswordRequest, request: Request): rl_check(request, max_requests=5, window_seconds=3600, key="reset_pw") diff --git a/backend/routes/breeder.py b/backend/routes/breeder.py index 1afe535..fe4028b 100644 --- a/backend/routes/breeder.py +++ b/backend/routes/breeder.py @@ -87,7 +87,7 @@ async def breeder_apply( stadt: str = Form(...), website: str = Form(""), beschreibung: str = Form(""), - dokument: UploadFile = File(...), + dokument: UploadFile = File(None), user=Depends(get_current_user), ): with db() as conn: @@ -103,28 +103,27 @@ async def breeder_apply( if row["breeder_status"] == "pending": raise HTTPException(400, "Du hast bereits einen offenen Antrag.") - # Dokument validieren und speichern - data = await dokument.read() - if len(data) > 10 * 1024 * 1024: - raise HTTPException(400, "Dokument zu groß (max. 10 MB).") - ext = os.path.splitext(dokument.filename or "")[1].lower() - if ext not in (".pdf", ".jpg", ".jpeg", ".png", ".webp"): - raise HTTPException(400, "Nur PDF, JPG, PNG oder WebP erlaubt.") - - 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}" - filepath = os.path.join(user_doc_dir, filename) - with open(filepath, "wb") as f: - f.write(data) + # Dokument optional speichern + filepath = None + if dokument and dokument.filename: + data = await dokument.read() + if len(data) > 10 * 1024 * 1024: + raise HTTPException(400, "Dokument zu groß (max. 10 MB).") + ext = os.path.splitext(dokument.filename)[1].lower() + if ext not in (".pdf", ".jpg", ".jpeg", ".png", ".webp"): + raise HTTPException(400, "Nur PDF, JPG, PNG oder WebP erlaubt.") + 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}" + filepath = os.path.join(user_doc_dir, filename) + with open(filepath, "wb") as f: + f.write(data) with db() as conn: conn.execute( "UPDATE users SET breeder_status='pending' WHERE id=?", (user["id"],) ) - # Profil-Entwurf anlegen (oder überschreiben wenn rejected) conn.execute( "INSERT INTO breeder_profiles (user_id, zwingername, rasse_text, verein, vdh_mitglied, stadt, website, beschreibung) " "VALUES (?,?,?,?,?,?,?,?) " @@ -135,10 +134,11 @@ async def breeder_apply( "verified_at=NULL", (user["id"], zwingername, rasse_text, verein, vdh_mitglied, stadt, website, beschreibung) ) - conn.execute( - "INSERT INTO breeder_documents (user_id, dokument_typ, file_path) VALUES (?,?,?)", - (user["id"], "antrag", filepath) - ) + if filepath: + conn.execute( + "INSERT INTO breeder_documents (user_id, dokument_typ, file_path) VALUES (?,?,?)", + (user["id"], "antrag", filepath) + ) # Admin benachrichtigen admin_body = f""" @@ -183,6 +183,28 @@ async def admin_pending_breeders(admin=Depends(require_admin)): 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 # ------------------------------------------------------------------ diff --git a/backend/routes/dogs.py b/backend/routes/dogs.py index cc86caf..832e777 100644 --- a/backend/routes/dogs.py +++ b/backend/routes/dogs.py @@ -191,6 +191,7 @@ async def get_welcome_dashboard(dog_id: int, user=Depends(get_current_user)): raise HTTPException(404, "Hund nicht gefunden.") # Hintergrundfoto: Querformat-Bilder bevorzugt, tagesweise rotierend + # Ownership bereits durch Dog-Check oben gesichert (dog gehört user) photos = conn.execute( """SELECT dm.url FROM diary_media dm JOIN diary d ON d.id = dm.diary_id @@ -843,13 +844,14 @@ async def upload_photo( file: UploadFile = File(...), 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: 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() if not dog: 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) import io @@ -883,6 +885,15 @@ async def upload_photo( with db() as conn: 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} diff --git a/backend/static/index.html b/backend/static/index.html index 0e13659..d079f75 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -599,10 +599,10 @@ - - - - + + + + diff --git a/backend/static/js/api.js b/backend/static/js/api.js index 9781362..8621935 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -124,6 +124,9 @@ const API = (() => { return get('/auth/me'); }, 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); }, adminCreateProfile() { return post('/admin/breeder/create-profile', {}); }, pendingList() { return get('/admin/breeders/pending'); }, + allList() { return get('/admin/breeders'); }, documents(userId) { return get(`/admin/breeder/${userId}/documents`); }, documentUrl(userId, docId) { return `/api/admin/breeder/${userId}/document/${docId}`; }, 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`), }; + // 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 return { - get, post, put, patch, del, upload, + get, post, put, patch, del, upload, swCacheDelete, auth, dogs, diary, health, tieraerzte, healthDocs, poison, places, routes, walks, events, sitting, forum, lost, knigge, weather, push, friends, chat, webcal, importData, sharing, widget, notifications, services, ratings, sittingAccess, training, notes, diff --git a/backend/static/js/app.js b/backend/static/js/app.js index c6d2189..cd79577 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ 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 IS_STAGING = location.hostname === 'staging.banyaro.app'; // Cache-Bust-Parameter nach Update-Reload sofort entfernen diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index 2747256..00a19e8 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -26,6 +26,7 @@ window.Page_admin = (() => { { id: 'hilfe', label: 'Hilfe/FAQ', icon: 'question' }, { id: 'uebungen_admin', label: 'Übungen', icon: 'barbell' }, { 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; } const items = [ + { key: 'upgrades_pending', label: 'Upgrade-Anfragen', tab: 'upgrades', icon: 'crown-simple' }, { key: 'jobs_pending', label: 'Bewerbungen', tab: 'bewerbungen', icon: 'user-plus' }, { key: 'breeder_pending', label: 'Züchter-Anträge', tab: 'zuchter', icon: 'certificate' }, { key: 'reports_open', label: 'Meldungen', tab: 'moderation', icon: 'warning' }, @@ -163,6 +165,7 @@ window.Page_admin = (() => { case 'hilfe': await _renderHilfe(el); break; case 'uebungen_admin': await _renderUebungenAdmin(el); break; case 'referrals': await _renderReferrals(el); break; + case 'upgrades': await _renderUpgrades(el); break; } } catch (e) { el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.'); @@ -1890,12 +1893,17 @@ window.Page_admin = (() => { ${UI.icon('arrows-clockwise')} Aktualisieren -
Lade…
+
Lade…
+
Lade…
`; - el.querySelector('#adm-zuchter-refresh').addEventListener('click', () => - _loadZuechterAntraege(el.querySelector('#adm-zuchter-list')) - ); - await _loadZuechterAntraege(el.querySelector('#adm-zuchter-list')); + el.querySelector('#adm-zuchter-refresh').addEventListener('click', () => { + _loadZuechterAntraege(el.querySelector('#adm-zuchter-antraege')); + _loadZuechterListe(el.querySelector('#adm-zuchter-liste')); + }); + await Promise.all([ + _loadZuechterAntraege(el.querySelector('#adm-zuchter-antraege')), + _loadZuechterListe(el.querySelector('#adm-zuchter-liste')), + ]); } async function _loadZuechterAntraege(el) { @@ -1909,12 +1917,20 @@ window.Page_admin = (() => { } if (!antraege.length) { - el.innerHTML = _emptyState('certificate', 'Keine offenen Anträge', 'Aktuell liegen keine Züchter-Anträge zur Prüfung vor.'); + el.innerHTML = `
+
Offene Anträge
+
+ ${UI.icon('check-circle')} Keine offenen Anträge +
+
`; return; } el.innerHTML = ` -
+
+
Offene Anträge (${antraege.length})
+
+
${antraege.map(a => `
@@ -2069,6 +2085,74 @@ window.Page_admin = (() => { }); } + async function _loadZuechterListe(el) { + el.innerHTML = `
Lade…
`; + 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 `Züchter-Abo`; + if (t === 'breeder_test') return `Test`; + return `Standard`; + }; + + const rows = breeders.map(b => ` + + +
${_esc(b.name)}
+
${_esc(b.email)}
+ + ${_esc(b.zwingername || '—')} + ${_esc(b.rasse_text || '—')} + ${_esc(b.stadt || '—')} + + ${b.wuerfe_count || 0} Würfe
+ ${b.hunde_count || 0} Hunde + + ${tierBadge(b.subscription_tier)} + + ${b.verified_at ? new Date(b.verified_at).toLocaleDateString('de-DE') : '—'} + + + + + `).join(''); + + el.innerHTML = ` +
+
Alle Züchter (${breeders.length})
+
+ + + ${['Nutzer','Zwingername','Rasse','Stadt','Aktivität','Abo','Seit',''].map(h => + `` + ).join('')} + + + ${rows || ``} + +
${h}
Noch keine Züchter
+
+
`; + + 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) { el.innerHTML = ` @@ -3419,6 +3503,104 @@ window.Page_admin = (() => {
`; } + // ------------------------------------------------------------------ + // 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 `${label}`; + }; + + const pending = rows.filter(r => !r.fulfilled_at); + const done = rows.filter(r => r.fulfilled_at); + + const _row = (r, showBtn) => ` + + ${_esc(r.name)}
+ ${_esc(r.email)} + ${tierBadge(r.tier)} + + ${r.message ? _esc(r.message) : '—'} + + ${r.created_at?.slice(0,10) || ''} + + ${showBtn + ? `` + : `✓ ${r.fulfilled_at?.slice(0,10)}`} + + `; + + const thead = ` + ${['Nutzer','Tarif','Nachricht','Datum','Aktion'].map(h => + `${h}` + ).join('')}`; + + el.innerHTML = ` +
+
Offene Anfragen (${pending.length})
+
+ + ${thead} + + ${pending.length + ? pending.map(r => _row(r, true)).join('') + : ``} + +
+ Keine offenen Anfragen +
+
+
+ ${done.length ? ` +
+
Erledigt (${done.length})
+
+ + ${thead} + ${done.map(r => _row(r, false)).join('')} +
+
+
` : ''}`; + + 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: `

+ Der Account wird auf ${tierLabel} gesetzt und + eine Bestätigungsmail gesendet. +

`, + 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 }; })(); diff --git a/backend/static/js/pages/dog-profile.js b/backend/static/js/pages/dog-profile.js index 9399085..95aa123 100644 --- a/backend/static/js/pages/dog-profile.js +++ b/backend/static/js/pages/dog-profile.js @@ -769,8 +769,16 @@ window.Page_dog_profile = (() => { 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.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(); - App.renderDogSwitcher(); + App.renderDogSwitcher?.(); + window.Worlds?.refresh(_appState); UI.toast.success('Foto hochgeladen.'); _renderProfile(_appState.activeDog); // Editor neu öffnen damit User positionieren kann @@ -1354,6 +1362,9 @@ window.Page_dog_profile = (() => { 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; if (dog) { saved = await API.dogs.update(dog.id, payload); @@ -1371,7 +1382,6 @@ window.Page_dog_profile = (() => { } // Foto hochladen wenn gewählt - const fotoFile = document.getElementById('dp-form-foto')?.files[0]; if (fotoFile) { try { const fd = new FormData(); @@ -1380,6 +1390,13 @@ window.Page_dog_profile = (() => { saved.foto_url = result.foto_url; _appState.activeDog = { ...saved }; _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 { UI.toast.warning('Profil gespeichert, Foto konnte nicht hochgeladen werden.'); } @@ -1388,6 +1405,9 @@ window.Page_dog_profile = (() => { // Dog Switcher in Header + Sidebar aktualisieren App.renderDogSwitcher?.(); + // Welten neu laden damit HUND-Welt den neuen Hund zeigt + window.Worlds?.refresh(_appState); + await _render(); }); }); diff --git a/backend/static/js/pages/map.js b/backend/static/js/pages/map.js index 4cce79c..bc65d98 100644 --- a/backend/static/js/pages/map.js +++ b/backend/static/js/pages/map.js @@ -205,8 +205,10 @@ window.Page_map = (() => {
+ ${App.hasPro(_appState?.user) ? ` + ` : ''}
@@ -289,8 +291,8 @@ window.Page_map = (() => { }); document.getElementById('map-pin-btn').addEventListener('click', _togglePlacementMode); - document.getElementById('map-radar-btn').addEventListener('click', _toggleRadar); - document.getElementById('map-temp-btn').addEventListener('click', _toggleTemp); + document.getElementById('map-radar-btn')?.addEventListener('click', _toggleRadar); + document.getElementById('map-temp-btn')?.addEventListener('click', _toggleTemp); } // ---------------------------------------------------------- diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index 8767cc6..1bacd62 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -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) => + `${label}`; + + const _upgradeBtn = (id, label, price, color) => + ``; + + 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 = ` +
+ ${_upgradeBtn('settings-upgrade-breeder-btn','Züchter werden','49 €/Jahr','#C4843A')} +
`; + } else { + statusHtml = _badge('Kostenlos', '#888'); + actionsHtml = ` +
+ ${_upgradeBtn('settings-upgrade-pro-btn','Ban Yaro Pro','29 €/Jahr','#16a34a')} + ${_upgradeBtn('settings-upgrade-breeder-btn','Züchter','49 €/Jahr','#C4843A')} +
`; + } + + return ` +
+
Abo & Tarif
+
+
+ Aktueller Tarif: + ${statusHtml} +
+ ${actionsHtml} +
+
`; + } + + 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) => ` +
+
${title}
+ ${items.map(f => ` +
+ + ${f} +
`).join('')} +
`; + + 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', + ]) + : `
+ ✓ Alle Pro-Features inklusive — + mehrere Hunde, Ernährung, Gassi-Community, Chat, Playdate, Reise, Karten-Layer +
` + + _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 ? '' : ` +
+
+ Dein Zwinger +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+ Zuchtbuch, Vereinsausweis o.ä. — kann auch per E-Mail nachgereicht werden +
+
+
+
`; + + UI.modal.open({ + title: `${label} freischalten`, + body: ` +
+
+
${price}
+
+ Einmaliger Jahresbeitrag
Kündigung jederzeit möglich +
+
+
+ ${featureList} +
+
+ Wir schalten deinen Account manuell frei — innerhalb von 24 Stunden. + Wir melden uns mit den Zahlungsdetails per E-Mail. +
+ ${breederForm} +
`, + footer: ` + + ` + }); + + 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 // ---------------------------------------------------------- @@ -276,13 +539,6 @@ window.Page_settings = (() => { Feedback geben
- ${!_appState.user?.subscription_tier || _appState.user.subscription_tier === 'standard' || _appState.user.subscription_tier === 'standard_test' ? ` -
- ⭐ Ban Yaro Pro kommt bald — mehr Features, mehrere Hunde. -
- ` : ''}
`; } else { - actionBlock = ` -
- -
`; + // Kein Antrag, kein Profil — Card ausblenden (Upgrade-Flow läuft über Abo & Tarif) + slot.innerHTML = ''; + return; } slot.innerHTML = ` @@ -1190,11 +1464,7 @@ window.Page_settings = (() => {
`; // Button-Handler binden - const applyBtn = slot.querySelector('#breeder-apply-btn'); - const reapplyBtn = slot.querySelector('#breeder-reapply-btn'); - if (applyBtn || reapplyBtn) { - (applyBtn || reapplyBtn).addEventListener('click', () => _openBreederApplyModal()); - } + slot.querySelector('#breeder-reapply-btn')?.addEventListener('click', () => _showUpgradeModal('breeder')); slot.querySelector('#breeder-edit-profile-btn')?.addEventListener('click', () => _openBreederEditModal(profile) diff --git a/backend/static/js/pages/wiki.js b/backend/static/js/pages/wiki.js index b0c101d..86bb8d9 100644 --- a/backend/static/js/pages/wiki.js +++ b/backend/static/js/pages/wiki.js @@ -384,8 +384,13 @@ window.Page_wiki = (() => { function _breedCardHtml(r) { 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 - ? `${_esc(r.name)}` + ? `${_esc(r.name)}` : ''; const fallbackHtml = `
${_DOG_SILHOUETTE}
`; @@ -746,7 +751,9 @@ window.Page_wiki = (() => { ${allFotos.map((f, i) => ` `).join('')}
` : ''} diff --git a/backend/static/js/worlds.js b/backend/static/js/worlds.js index 0c3c15d..b250a29 100644 --- a/backend/static/js/worlds.js +++ b/backend/static/js/worlds.js @@ -5,11 +5,12 @@ window.Worlds = (() => { - let _state = null; - let _cur = 1; // 0=JETZT 1=HUND 2=WELT - let _visible = false; - let _map = null; - let _weltInited = false; + let _state = null; + let _cur = 1; // 0=JETZT 1=HUND 2=WELT + let _visible = false; + let _map = null; + let _weltInited = false; + let _refreshPending = false; // gesetzt wenn refresh() während !_visible aufgerufen wird let _lastUserId = undefined; let _dogs = []; // gecachte Hundesliste let _dogIdx = 0; // aktuell angezeigter Hund @@ -118,6 +119,14 @@ window.Worlds = (() => { if (worldIdx != null) _goTo(worldIdx, false); 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 const currentUserId = _state?.user?.id ?? null; if (currentUserId !== _lastUserId) { @@ -347,7 +356,6 @@ window.Worlds = (() => {
${chips.map(c => ` + in deinem Profil. + +
`; @@ -388,6 +412,10 @@ window.Worlds = (() => { navigateTo(btn.dataset.page); }); }); + ov.querySelector('#fab-all-goto-worlds')?.addEventListener('click', () => { + _close(); + _openConfigModal(); + }); } // ── SCHNELL-GASSI ───────────────────────────────────────────── @@ -673,6 +701,7 @@ window.Worlds = (() => { let cfg = JSON.parse(JSON.stringify(_getConfig())); // deep copy 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 worldLabels = { jetzt:'JETZT', hund:'HUND', welt:'WELT', pool:'Nicht verwendet' }; const allAssigned = () => new Set([...cfg.jetzt, ...cfg.hund, ...cfg.welt]); @@ -776,7 +805,8 @@ window.Worlds = (() => { `} - ${c.pro && _isRoleBasedPro() ? `P` : ''} + ${isAdmin && c.pro ? `P` : ''} + ${isAdmin && c.role === 'breeder' ? `Z` : ''} @@ -923,7 +953,8 @@ window.Worlds = (() => { async function _loadDailyImage(dog) { 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); if (cached?.data) return cached.data; try { @@ -1157,7 +1188,7 @@ window.Worlds = (() => {
Deine Bereiche
- ${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('')}