Release v1.2.0 — E-Mail-Verifikation, Forum public, Auth-Gates, Mailing-System, Partner/Gründer

This commit is contained in:
rene 2026-04-30 19:51:12 +02:00
commit 69cb79f973
19 changed files with 955 additions and 55 deletions

View file

@ -1 +0,0 @@
{"sessionId":"39ad9ffb-6cac-40b2-8c2d-b3974db3a4b8","pid":1946,"procStart":"Sat Apr 25 14:22:02 2026","acquiredAt":1777133270339}

View file

@ -488,7 +488,8 @@ def _migrate(conn_factory):
# WebCal: Kalender-Abo-Token
("users", "calendar_token", "TEXT"),
# User-Profil-Felder
("users", "email_verified", "INTEGER NOT NULL DEFAULT 0"),
("users", "email_verified", "INTEGER NOT NULL DEFAULT 0"),
("users", "verification_token", "TEXT"),
("users", "bio", "TEXT"),
("users", "wohnort", "TEXT"),
("users", "erfahrung", "TEXT"),
@ -560,9 +561,10 @@ def _migrate(conn_factory):
("users", "ki_zucht_beschreibung", "INTEGER NOT NULL DEFAULT 1"),
("users", "ki_zucht_jahresbericht", "INTEGER NOT NULL DEFAULT 1"),
# Partner-Code + Gründer-Lizenz
("users", "is_founder", "INTEGER NOT NULL DEFAULT 0"),
("users", "is_partner", "INTEGER NOT NULL DEFAULT 0"),
("users", "founder_number", "INTEGER"),
("users", "is_founder", "INTEGER NOT NULL DEFAULT 0"),
("users", "is_partner", "INTEGER NOT NULL DEFAULT 0"),
("users", "founder_number", "INTEGER"),
("users", "is_founder_pending", "INTEGER NOT NULL DEFAULT 0"),
]
with conn_factory() as conn:
for table, column, col_type in migrations:
@ -1508,6 +1510,54 @@ def _migrate(conn_factory):
except Exception as e:
logger.warning(f"Migration partner_codes: {e}")
# Outreach-Log (Admin-E-Mail-Versand)
try:
conn.executescript("""
CREATE TABLE IF NOT EXISTS outreach_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sent_by INTEGER REFERENCES users(id),
recipient TEXT NOT NULL,
subject TEXT NOT NULL,
body TEXT NOT NULL,
sent_at TEXT NOT NULL DEFAULT (datetime('now'))
);
""")
except Exception as e:
logger.warning(f"Migration outreach_log: {e}")
# E-Mail-Vorlagen (DB-gespeichert, CRUD über Admin)
try:
conn.execute("""
CREATE TABLE IF NOT EXISTS email_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT UNIQUE NOT NULL,
label TEXT NOT NULL,
subject TEXT NOT NULL,
body TEXT NOT NULL,
from_account TEXT NOT NULL DEFAULT 'partner',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT
)
""")
# Startwert-Vorlage einspielen wenn Tabelle noch leer
count = conn.execute("SELECT COUNT(*) FROM email_templates").fetchone()[0]
if count == 0:
conn.execute("""
INSERT INTO email_templates (key, label, subject, body, from_account) VALUES
('influencer_de',
'Influencer-Ansprache (DE)',
'Ban Yaro — 100 Gründer-Plätze, einer davon für deine Community',
'Hallo {name},\n\nich bin René und habe Ban Yaro gebaut — eine Hunde-App für Tagebuch, Gesundheit, Giftköder-Alarm und Community. Kostenlos, ohne App Store, direkt als PWA.\n\nIch kontaktiere gerade einige Influencer aus der deutschen Hunde-Community mit einem konkreten Angebot:\n\nWas deine Follower bekommen: Wer sich mit deinem persönlichen Code registriert, sichert sich einen der 100 Gründer-Plätze — eine nummerierte Badge ("Gründer #42") die dauerhaft im Profil sichtbar ist. Diese 100 Plätze gibt es genau einmal.\n\nWas du bekommst: Partner-Badge in der App, eigener Code, öffentliches Ranking wer die meisten Gründer bringt.\n\nKein Geld, kein verpflichtender Post — aber eine echte Exklusivität die du deiner Community geben kannst.\n\nAlle Infos: https://banyaro.app/partner\n\nWenn dich das interessiert, antworte einfach kurz — ich richte deinen Code binnen 24h ein.\n\nViele Grüße,\nRené\nbanyaro.app',
'partner')
""")
except Exception as e:
logger.warning(f"Migration email_templates: {e}")
# from_account-Spalte in outreach_log nachträglich hinzufügen
existing_ol = [row[1] for row in conn.execute("PRAGMA table_info(outreach_log)").fetchall()]
if 'from_account' not in existing_ol:
conn.execute("ALTER TABLE outreach_log ADD COLUMN from_account TEXT DEFAULT 'partner'")
# js_exercise_id zu training_exercises — verbindet training_exercises mit exercise_progress
existing_te = [row[1] for row in conn.execute("PRAGMA table_info(training_exercises)").fetchall()]
if 'js_exercise_id' not in existing_te:

View file

@ -163,6 +163,7 @@ from routes.zucht_hunde import router as zucht_hunde_router
from routes.breeder_export import router as breeder_export_router
from routes.zucht_ki import router as zucht_ki_router
from routes.partner import router as partner_router
from routes.outreach import router as outreach_router
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
@ -195,6 +196,7 @@ app.include_router(zucht_hunde_router, prefix="/api", tags=["Zuchtkart
app.include_router(breeder_export_router, prefix="/api", tags=["Export"])
app.include_router(zucht_ki_router, prefix="/api", tags=["Züchter-KI"])
app.include_router(partner_router, prefix="/api", tags=["Partner"])
app.include_router(outreach_router, prefix="/api/outreach", tags=["Outreach"])
app.include_router(webcal_router, prefix="/api/webcal", tags=["WebCal"])
app.include_router(profile_router, prefix="/api/profile", tags=["Profil"])
app.include_router(import_router, prefix="/api/import", tags=["Import"])
@ -1400,6 +1402,208 @@ async def knigge_page():
return HTMLResponse(content=html, headers={"Cache-Control": "max-age=7200"})
# ------------------------------------------------------------------
# /partner — Influencer-Landingpage
# ------------------------------------------------------------------
@app.get("/partner")
async def partner_landing():
from fastapi.responses import HTMLResponse
from database import db as _db
with _db() as conn:
total_founders = conn.execute("SELECT COUNT(*) FROM users WHERE is_founder=1").fetchone()[0]
partners = conn.execute(
"""SELECT label, uses FROM partner_codes WHERE grants_founder=1 ORDER BY uses DESC LIMIT 5"""
).fetchall()
open_slots = max(0, 100 - total_founders)
partner_rows = ''.join([
f'<div class="pl-partner-row"><span class="pl-partner-name">{p["label"]}</span>'
f'<span class="pl-partner-score">{p["uses"]} Gründer</span></div>'
for p in partners
]) or '<div style="color:#94a3b8;font-size:0.85rem">Noch keine Partner aktiv — sei der Erste.</div>'
html = f"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ban Yaro Partner Werde Teil der ersten 100</title>
<meta name="description" content="Werde Ban Yaro Partner. Gib deiner Community exklusive Gründer-Lizenzen — nur 100 Plätze weltweit, nie wieder erhältlich.">
<meta property="og:title" content="Ban Yaro Partner">
<meta property="og:description" content="Gib deiner Community etwas Besonderes. 100 Gründer-Plätze. Exklusiv. Für immer.">
<meta property="og:image" content="https://banyaro.app/icons/icon-512.png">
<style>
*,*::before,*::after{{box-sizing:border-box;margin:0;padding:0}}
body{{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#faf9f7;color:#1a1a1a;line-height:1.6}}
a{{color:#C4843A;text-decoration:none}}
.pl-wrap{{max-width:680px;margin:0 auto;padding:24px 20px 80px}}
/* Hero */
.pl-hero{{text-align:center;padding:60px 0 48px;border-bottom:1px solid #e8e4df}}
.pl-logo{{width:72px;height:72px;border-radius:18px;margin:0 auto 24px;display:block}}
.pl-eyebrow{{font-size:0.75rem;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:#C4843A;margin-bottom:12px}}
.pl-h1{{font-size:clamp(1.8rem,5vw,2.8rem);font-weight:800;line-height:1.15;color:#111;margin-bottom:16px}}
.pl-sub{{font-size:1.05rem;color:#555;max-width:480px;margin:0 auto 32px}}
.pl-cta{{display:inline-block;background:#C4843A;color:#fff;font-weight:700;font-size:1rem;padding:14px 32px;border-radius:999px;text-decoration:none;transition:opacity .15s}}
.pl-cta:hover{{opacity:.88}}
/* Slot-Counter */
.pl-counter{{background:#fff;border:1px solid #e8e4df;border-radius:16px;padding:28px 24px;margin:40px 0;text-align:center}}
.pl-counter-num{{font-size:3rem;font-weight:800;color:#C4843A;line-height:1}}
.pl-counter-label{{font-size:0.85rem;color:#888;margin-top:4px}}
.pl-bar-wrap{{background:#f0ece8;border-radius:999px;height:10px;margin:16px 0 8px;overflow:hidden}}
.pl-bar{{background:linear-gradient(90deg,#C4843A,#e0a870);height:100%;border-radius:999px;width:{min(100, round(total_founders/100*100))}%}}
.pl-bar-labels{{display:flex;justify-content:space-between;font-size:0.75rem;color:#aaa}}
/* Vorteile */
.pl-benefits{{margin:40px 0}}
.pl-section-title{{font-size:1.1rem;font-weight:700;margin-bottom:20px;color:#111}}
.pl-benefit{{display:flex;gap:16px;align-items:flex-start;padding:16px 0;border-bottom:1px solid #f0ece8}}
.pl-benefit:last-child{{border-bottom:none}}
.pl-benefit-icon{{font-size:1.6rem;flex-shrink:0;width:40px;text-align:center}}
.pl-benefit-title{{font-weight:700;font-size:0.95rem;margin-bottom:2px}}
.pl-benefit-text{{font-size:0.88rem;color:#555}}
/* Wie es funktioniert */
.pl-steps{{margin:40px 0}}
.pl-step{{display:flex;gap:16px;align-items:flex-start;margin-bottom:24px}}
.pl-step-num{{width:32px;height:32px;border-radius:50%;background:#C4843A;color:#fff;font-weight:800;font-size:0.9rem;display:flex;align-items:center;justify-content:center;flex-shrink:0}}
.pl-step-text{{padding-top:4px;font-size:0.92rem;color:#333}}
.pl-step-text strong{{display:block;font-weight:700;color:#111;margin-bottom:2px}}
/* Leaderboard */
.pl-leaderboard{{background:#fff;border:1px solid #e8e4df;border-radius:16px;padding:24px;margin:40px 0}}
.pl-partner-row{{display:flex;justify-content:space-between;align-items:center;padding:10px 0;border-bottom:1px solid #f5f3f0;font-size:0.9rem}}
.pl-partner-row:last-child{{border-bottom:none}}
.pl-partner-name{{font-weight:600}}
.pl-partner-score{{color:#C4843A;font-weight:700}}
/* Kontakt */
.pl-contact{{background:linear-gradient(135deg,#C4843A,#d4944a);border-radius:20px;padding:40px 32px;text-align:center;color:#fff;margin:40px 0}}
.pl-contact h2{{font-size:1.5rem;font-weight:800;margin-bottom:12px}}
.pl-contact p{{font-size:0.95rem;opacity:.9;margin-bottom:24px;max-width:400px;margin-left:auto;margin-right:auto}}
.pl-contact-btn{{display:inline-block;background:#fff;color:#C4843A;font-weight:700;padding:12px 28px;border-radius:999px;font-size:0.95rem}}
/* Footer */
.pl-footer{{text-align:center;font-size:0.78rem;color:#aaa;padding-top:32px;border-top:1px solid #f0ece8}}
</style>
</head>
<body>
<div class="pl-wrap">
<!-- Hero -->
<div class="pl-hero">
<img src="/icons/icon-192.png" alt="Ban Yaro" class="pl-logo">
<div class="pl-eyebrow">Ban Yaro · Influencer-Programm</div>
<h1 class="pl-h1">Gib deiner Community<br>etwas für immer.</h1>
<p class="pl-sub">100 Gründer-Plätze. Weltweit. Nie wieder erhältlich.<br>Als Partner bringst du deine Follower nach vorne und steigst im Ranking auf.</p>
<a href="mailto:partner@banyaro.app" class="pl-cta">Jetzt Partner werden</a>
</div>
<!-- Slot-Counter -->
<div class="pl-counter">
<div class="pl-counter-num">{open_slots}</div>
<div class="pl-counter-label">Gründer-Plätze noch frei (von 100)</div>
<div class="pl-bar-wrap"><div class="pl-bar"></div></div>
<div class="pl-bar-labels"><span>0</span><span>{total_founders} vergeben</span><span>100</span></div>
</div>
<!-- Vorteile -->
<div class="pl-benefits">
<div class="pl-section-title">Was du und deine Community bekommen</div>
<div class="pl-benefit">
<div class="pl-benefit-icon">🏆</div>
<div>
<div class="pl-benefit-title">Gründer-Lizenz für deine Follower</div>
<div class="pl-benefit-text">Jeder der sich mit deinem Code registriert bekommt einen der 100 Gründer-Plätze mit einer nummerierten Badge <strong>Gründer #N"</strong> die dauerhaft im Profil und im Forum sichtbar ist. Nie wieder erhältlich.</div>
</div>
</div>
<div class="pl-benefit">
<div class="pl-benefit-icon">🤝</div>
<div>
<div class="pl-benefit-title">Dein persönlicher Partner-Code</div>
<div class="pl-benefit-text">Du bekommst einen eigenen Code (z.B. <strong>HUNDEBLOG</strong>). Follower die sich damit registrieren werden automatisch Gründer du siehst in Echtzeit wie viele du gebracht hast.</div>
</div>
</div>
<div class="pl-benefit">
<div class="pl-benefit-icon">📊</div>
<div>
<div class="pl-benefit-title">Öffentliches Partner-Ranking</div>
<div class="pl-benefit-text">Auf der <a href="/app#gruender">Gründer-Seite</a> siehen alle wer die meisten Gründer gebracht hat. Das Ranking motiviert deine Follower mitzumachen und stärkt deine Position gegenüber anderen Influencern.</div>
</div>
</div>
<div class="pl-benefit">
<div class="pl-benefit-icon">💜</div>
<div>
<div class="pl-benefit-title">Partner-Badge für dich</div>
<div class="pl-benefit-text">Du selbst bekommst ein <strong>Partner"</strong>-Badge in deinem Profil — sichtbar für alle Nutzer der App.</div>
</div>
</div>
<div class="pl-benefit">
<div class="pl-benefit-icon">🎁</div>
<div>
<div class="pl-benefit-title">Lebenslang kostenlos für immer</div>
<div class="pl-benefit-text">Gründer zahlen nie für Premium-Features egal was wir in Zukunft einführen. Das ist ein echtes Dankeschön für die Pioniere.</div>
</div>
</div>
</div>
<!-- Wie es funktioniert -->
<div class="pl-steps">
<div class="pl-section-title">Wie es funktioniert</div>
<div class="pl-step">
<div class="pl-step-num">1</div>
<div class="pl-step-text"><strong>Kontakt aufnehmen</strong>Schreib uns kurz an partner@banyaro.app wir richten deinen persönlichen Code ein.</div>
</div>
<div class="pl-step">
<div class="pl-step-num">2</div>
<div class="pl-step-text"><strong>Code teilen</strong>Du postest deinen Code in Story, Reel oder Post deine Follower registrieren sich auf banyaro.app.</div>
</div>
<div class="pl-step">
<div class="pl-step-num">3</div>
<div class="pl-step-text"><strong>Gründer werden</strong>Jede Registrierung mit deinem Code sichert automatisch einen der 100 Gründer-Plätze. Du siehst deinen Fortschritt in Echtzeit.</div>
</div>
<div class="pl-step">
<div class="pl-step-num">4</div>
<div class="pl-step-text"><strong>Im Ranking aufsteigen</strong>Je mehr Gründer du bringst, desto höher dein Platz auf der öffentlichen Gründer-Seite.</div>
</div>
</div>
<!-- Leaderboard -->
{'<div class="pl-leaderboard"><div class="pl-section-title" style="margin-bottom:16px">🏅 Aktuelles Partner-Ranking</div>' + partner_rows + '</div>' if partners else ''}
<!-- Was ist Ban Yaro -->
<div style="background:#fff;border:1px solid #e8e4df;border-radius:16px;padding:28px 24px;margin:40px 0">
<div class="pl-section-title">Was ist Ban Yaro?</div>
<p style="font-size:0.9rem;color:#555;margin-bottom:12px">Ban Yaro ist die Hunde-App für alles was Halter brauchen Tagebuch, Gesundheit, Routen, Giftköder-Alarm, Community. Kostenlos, ohne App Store, direkt im Browser oder als PWA.</p>
<a href="https://banyaro.app" style="font-weight:700;color:#C4843A;font-size:0.9rem">banyaro.app entdecken </a>
</div>
<!-- CTA -->
<div class="pl-contact">
<h2>Bereit dabei zu sein?</h2>
<p>Schreib uns kurz wer du bist und auf welchem Kanal du aktiv bist wir richten deinen Code binnen 24h ein.</p>
<a href="mailto:partner@banyaro.app?subject=Ban Yaro Partner&body=Hallo,%0A%0Aich bin interessiert am Ban Yaro Partner-Programm.%0A%0AKanal / Reichweite:%0A%0AViele Grüße" class="pl-contact-btn">📧 partner@banyaro.app</a>
</div>
<!-- Footer -->
<div class="pl-footer">
<a href="https://banyaro.app">banyaro.app</a> ·
<a href="/impressum">Impressum</a> ·
<a href="/datenschutz">Datenschutz</a>
</div>
</div>
</body>
</html>"""
return HTMLResponse(content=html, headers={"Cache-Control": "no-cache"})
# SPA Fallback — ALLE nicht-API-Routen gehen zur index.html
@app.get("/{full_path:path}")
async def spa_fallback(full_path: str):

View file

@ -6,6 +6,7 @@ import string
from typing import Optional
from fastapi import APIRouter, HTTPException, Request, Response, Depends
from fastapi.responses import RedirectResponse
from pydantic import BaseModel, EmailStr
from database import db
from auth import (
@ -16,7 +17,28 @@ from username_blocklist import is_username_blocked
from ratelimit import check as rl_check
router = APIRouter()
COOKIE_NAME = "by_token"
COOKIE_NAME = "by_token"
_APP_URL = os.getenv("APP_URL", "https://banyaro.app")
_SMTP_READY = bool(os.getenv("SMTP_SUPPORT_USER") and os.getenv("SMTP_SUPPORT_PASS"))
def _send_verification_email(email: str, name: str, token: str):
if not _SMTP_READY:
return
from routes.outreach import _send_smtp
subject = "Ban Yaro — bitte bestätige deine E-Mail-Adresse"
body = (
f"Hallo {name},\n\n"
"willkommen bei Ban Yaro! Bitte bestätige deine E-Mail-Adresse mit diesem Link:\n\n"
f"{_APP_URL}/api/auth/verify-email/{token}\n\n"
"Der Link ist 7 Tage gültig.\n\n"
"Falls du dich nicht bei Ban Yaro registriert hast, kannst du diese Mail ignorieren.\n\n"
"Viele Grüße,\nDas Ban Yaro Team\nbanyaro.app"
)
try:
_send_smtp(email, subject, body, "support")
except Exception:
pass # Nicht blockieren wenn SMTP fehlschlägt
class LoginRequest(BaseModel):
@ -64,13 +86,13 @@ async def register(data: RegisterRequest, response: Response, request: Request):
).fetchone():
raise HTTPException(400, "Dieser Name ist bereits vergeben. Bitte wähle einen anderen.")
code = _gen_referral_code()
verify_token = secrets.token_urlsafe(32)
try:
conn.execute(
"INSERT INTO users (email, pw_hash, name, referral_code) VALUES (?,?,?,?)",
(data.email, hash_password(data.password), name, code)
"INSERT INTO users (email, pw_hash, name, referral_code, verification_token) VALUES (?,?,?,?,?)",
(data.email, hash_password(data.password), name, code, verify_token)
)
except Exception:
# Fallback falls UNIQUE-Index greift (Race Condition)
raise HTTPException(400, "Dieser Name ist bereits vergeben. Bitte wähle einen anderen.")
user = conn.execute(
"SELECT id, rolle FROM users WHERE email=?", (data.email,)
@ -97,9 +119,8 @@ async def register(data: RegisterRequest, response: Response, request: Request):
"SELECT COUNT(*) FROM users WHERE is_founder=1"
).fetchone()[0]
if total_founders < 100:
founder_num = total_founders + 1
updates["is_founder"] = 1
updates["founder_number"] = founder_num
# Pending — wird nach erstem Hunde-Profil mit Plausibilitätsprüfung aktiviert
updates["is_founder_pending"] = 1
set_clause = ", ".join(f"{k}=?" for k in updates)
conn.execute(
f"UPDATE users SET {set_clause} WHERE id=?",
@ -117,7 +138,8 @@ async def register(data: RegisterRequest, response: Response, request: Request):
token = create_token(user["id"], user["rolle"])
_set_cookie(response, token)
return {"token": token, "name": name}
_send_verification_email(data.email, name, verify_token)
return {"token": token, "name": name, "email_verified": 0}
@router.post("/login")
@ -198,7 +220,7 @@ async def me(user=Depends(get_current_user)):
"""SELECT id, name, real_name, email, rolle, is_premium, email_verified,
bio, wohnort, erfahrung, social_link,
profil_sichtbarkeit, avatar_url, created_at,
is_founder, is_partner, founder_number
is_founder, is_partner, founder_number, is_founder_pending
FROM users WHERE id=?""",
(user["id"],)
).fetchone()
@ -207,3 +229,37 @@ async def me(user=Depends(get_current_user)):
data = dict(row)
data["is_premium"] = bool(data["is_premium"])
return data
@router.get("/verify-email/{token}")
async def verify_email(token: str):
with db() as conn:
row = conn.execute(
"SELECT id, email_verified FROM users WHERE verification_token=?", (token,)
).fetchone()
if not row:
return RedirectResponse(f"{_APP_URL}/#settings?verified=error", status_code=302)
conn.execute(
"UPDATE users SET email_verified=1, verification_token=NULL WHERE id=?",
(row["id"],)
)
return RedirectResponse(f"{_APP_URL}/#settings?verified=1", status_code=302)
@router.post("/resend-verification")
async def resend_verification(user=Depends(get_current_user)):
with db() as conn:
row = conn.execute(
"SELECT email, name, email_verified FROM users WHERE id=?", (user["id"],)
).fetchone()
if not row:
raise HTTPException(404)
if row["email_verified"]:
return {"ok": True, "already_verified": True}
token = secrets.token_urlsafe(32)
with db() as conn:
conn.execute(
"UPDATE users SET verification_token=? WHERE id=?", (token, user["id"])
)
_send_verification_email(row["email"], row["name"], token)
return {"ok": True}

View file

@ -21,7 +21,7 @@ _TZ = ZoneInfo("Europe/Berlin")
BREEDER_DOCS_DIR = os.getenv("BREEDER_DOCS_DIR", "/data/breeder_docs")
os.makedirs(BREEDER_DOCS_DIR, exist_ok=True)
ADMIN_EMAIL = os.getenv("ADMIN_EMAIL", "mail@motocamp.de")
ADMIN_EMAIL = os.getenv("ADMIN_EMAIL", "admin@banyaro.app")
APP_URL = os.getenv("APP_URL", "https://banyaro.app")

View file

@ -78,6 +78,41 @@ async def list_dogs(user=Depends(get_current_user)):
return result
def _is_plausible_dog(name: str, rasse: str, geburtstag) -> tuple[bool, str]:
"""Einfache Plausibilitätsprüfung für Hunde-Profile."""
import re, datetime
name = (name or "").strip()
rasse = (rasse or "").strip()
if len(name) < 2:
return False, "Der Name muss mindestens 2 Zeichen haben."
if not re.search(r'[a-zA-ZäöüÄÖÜß]', name):
return False, "Der Name muss mindestens einen Buchstaben enthalten."
if len(set(name.lower())) < 2:
return False, "Bitte einen echten Namen eingeben."
if rasse and len(rasse) < 2:
return False, "Bitte eine gültige Rasse eingeben."
if rasse and not re.search(r'[a-zA-ZäöüÄÖÜß]', rasse):
return False, "Die Rasse muss Buchstaben enthalten."
if geburtstag:
try:
if isinstance(geburtstag, str):
year = int(geburtstag[:4])
else:
year = geburtstag.year
now = datetime.date.today().year
if year > now:
return False, "Das Geburtsdatum liegt in der Zukunft."
if year < now - 30:
return False, "Das Geburtsdatum ist unrealistisch."
except Exception:
pass
return True, ""
@router.post("")
async def create_dog(data: DogCreate, user=Depends(get_current_user)):
with db() as conn:
@ -93,6 +128,28 @@ async def create_dog(data: DogCreate, user=Depends(get_current_user)):
"SELECT * FROM dogs WHERE user_id=? ORDER BY id DESC LIMIT 1",
(user["id"],)
).fetchone()
# Gründer-Aktivierung: erstes Hunde-Profil + is_founder_pending
user_row = conn.execute(
"SELECT is_founder_pending, is_founder FROM users WHERE id=?",
(user["id"],)
).fetchone()
if user_row and user_row["is_founder_pending"] and not user_row["is_founder"]:
dog_count = conn.execute(
"SELECT COUNT(*) FROM dogs WHERE user_id=?", (user["id"],)
).fetchone()[0]
if dog_count == 1: # genau dieser erste Hund
plausible, reason = _is_plausible_dog(data.name, data.rasse, data.geburtstag)
if plausible:
total = conn.execute(
"SELECT COUNT(*) FROM users WHERE is_founder=1"
).fetchone()[0]
if total < 100:
conn.execute(
"UPDATE users SET is_founder=1, founder_number=?, is_founder_pending=0 WHERE id=?",
(total + 1, user["id"])
)
return dict(dog)

View file

@ -258,7 +258,7 @@ async def welfare_confirm(litter_id: int, user=Depends(_require_breeder)):
"FROM breeder_profiles bp JOIN users u ON u.id=bp.user_id "
"WHERE bp.user_id=?", (user["id"],)
).fetchone()
admin_email = os.getenv("ADMIN_EMAIL", "mail@motocamp.de")
admin_email = os.getenv("ADMIN_EMAIL", "admin@banyaro.app")
app_url = os.getenv("APP_URL", "https://banyaro.app")
zuechter = profile["name"] if profile else user.get("name", "Unbekannt")
zwinger = profile["zwingername"] if profile else ""

View file

@ -28,7 +28,7 @@ OVERPASS_URLS = [
_overpass_sem = asyncio.Semaphore(1)
_overpass_last_req = 0.0
_OVERPASS_MIN_DELAY = 2.0 # Sekunden zwischen Anfragen
_OVERPASS_UA = 'BanYaro/1.0 (https://banyaro.app; dog-walking PWA; contact: mail@motocamp.de)'
_OVERPASS_UA = 'BanYaro/1.0 (https://banyaro.app; dog-walking PWA; contact: admin@banyaro.app)'
_OVERPASS_HEADERS = {
'User-Agent': _OVERPASS_UA,
'Referer': 'https://banyaro.app/', # von overpass-api.de verlangt gegen 406

188
backend/routes/outreach.py Normal file
View file

@ -0,0 +1,188 @@
"""BAN YARO — Mailing (Admin)"""
import os
import smtplib
import ssl
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.utils import formataddr
from datetime import datetime
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from auth import require_admin
from database import db
router = APIRouter()
_SMTP_HOST = os.getenv("SMTP_HOST", "mail.your-server.de")
_SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
_ACCOUNTS = {
"partner": {
"user": os.getenv("SMTP_USER", ""),
"pass": os.getenv("SMTP_PASS", ""),
"from": "partner@banyaro.app",
"name": "Ban Yaro Partner",
},
"support": {
"user": os.getenv("SMTP_SUPPORT_USER", "support@banyaro.de"),
"pass": os.getenv("SMTP_SUPPORT_PASS", ""),
"from": "support@banyaro.app",
"name": "Ban Yaro Support",
},
}
def _send_smtp(to: str, subject: str, body: str, account: str = "partner"):
acc = _ACCOUNTS.get(account) or _ACCOUNTS["partner"]
if not acc["user"] or not acc["pass"]:
raise RuntimeError(f"SMTP-Account '{account}' nicht konfiguriert.")
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = formataddr((acc["name"], acc["from"]))
msg["To"] = to
msg["Reply-To"] = acc["from"]
msg.attach(MIMEText(body, "plain", "utf-8"))
ctx = ssl.create_default_context()
with smtplib.SMTP(_SMTP_HOST, _SMTP_PORT, timeout=15) as s:
s.ehlo()
s.starttls(context=ctx)
s.login(acc["user"], acc["pass"])
s.sendmail(acc["from"], [to], msg.as_bytes())
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class TemplateIn(BaseModel):
key: str
label: str
subject: str
body: str
from_account: str = "partner"
class TemplateUpdate(BaseModel):
label: str
subject: str
body: str
from_account: str = "partner"
class SendRequest(BaseModel):
to: List[str]
subject: str
body: str
from_account: str = "partner"
template_id: Optional[int] = None
# ------------------------------------------------------------------
# Templates CRUD
# ------------------------------------------------------------------
@router.get("/templates")
def list_templates(user=Depends(require_admin)):
with db() as conn:
rows = conn.execute(
"SELECT id, key, label, subject, body, from_account FROM email_templates ORDER BY id"
).fetchall()
return [dict(r) for r in rows]
@router.post("/templates", status_code=201)
def create_template(data: TemplateIn, user=Depends(require_admin)):
try:
with db() as conn:
row = conn.execute(
"""INSERT INTO email_templates (key, label, subject, body, from_account, created_at)
VALUES (?, ?, ?, ?, ?, ?) RETURNING id""",
(data.key, data.label, data.subject, data.body, data.from_account,
datetime.utcnow().isoformat())
).fetchone()
return {"id": row["id"]}
except Exception as e:
raise HTTPException(400, f"Vorlage konnte nicht angelegt werden: {e}")
@router.put("/templates/{tpl_id}")
def update_template(tpl_id: int, data: TemplateUpdate, user=Depends(require_admin)):
with db() as conn:
conn.execute(
"""UPDATE email_templates
SET label=?, subject=?, body=?, from_account=?, updated_at=?
WHERE id=?""",
(data.label, data.subject, data.body, data.from_account,
datetime.utcnow().isoformat(), tpl_id)
)
return {"ok": True}
@router.delete("/templates/{tpl_id}")
def delete_template(tpl_id: int, user=Depends(require_admin)):
with db() as conn:
conn.execute("DELETE FROM email_templates WHERE id=?", (tpl_id,))
return {"ok": True}
# ------------------------------------------------------------------
# Senden
# ------------------------------------------------------------------
@router.post("/send")
def send_mail(data: SendRequest, user=Depends(require_admin)):
if not data.to:
raise HTTPException(400, "Mindestens eine Empfänger-Adresse angeben.")
if not data.subject.strip() or not data.body.strip():
raise HTTPException(400, "Betreff und Text dürfen nicht leer sein.")
sent, failed = [], []
for addr in data.to:
addr = addr.strip()
if not addr:
continue
try:
_send_smtp(addr, data.subject, data.body, data.from_account)
sent.append(addr)
with db() as conn:
conn.execute(
"""INSERT INTO outreach_log
(sent_by, recipient, subject, body, from_account, sent_at)
VALUES (?, ?, ?, ?, ?, ?)""",
(user["id"], addr, data.subject, data.body, data.from_account,
datetime.utcnow().isoformat())
)
except Exception as e:
failed.append({"addr": addr, "error": str(e)})
return {"sent": sent, "failed": failed}
# ------------------------------------------------------------------
# Support-Versand (intern, ohne Admin-Auth — für Moderatoren-Trigger)
# ------------------------------------------------------------------
def send_support_mail(to: str, subject: str, body: str):
"""Intern aufrufbar ohne HTTP-Request — z.B. aus Moderations-Logik."""
_send_smtp(to, subject, body, "support")
# ------------------------------------------------------------------
# Log
# ------------------------------------------------------------------
@router.get("/log")
def outreach_log_endpoint(user=Depends(require_admin)):
with db() as conn:
rows = conn.execute(
"""SELECT ol.id, ol.recipient, ol.subject, ol.sent_at,
ol.from_account, u.name AS sent_by_name
FROM outreach_log ol
JOIN users u ON u.id = ol.sent_by
ORDER BY ol.sent_at DESC LIMIT 200"""
).fetchall()
return [dict(r) for r in rows]

View file

@ -189,4 +189,4 @@
<symbol id="certificate" viewBox="0 0 256 256">
<path d="M232,86.53V56a16,16,0,0,0-16-16H40A16,16,0,0,0,24,56V184a16,16,0,0,0,16,16H160v24A8,8,0,0,0,172,231l24-13.74L220,231A8,8,0,0,0,232,224V161.47a51.88,51.88,0,0,0,0-74.94ZM128,144H72a8,8,0,0,1,0-16h56a8,8,0,0,1,0,16Zm0-32H72a8,8,0,0,1,0-16h56a8,8,0,0,1,0,16Zm88,98.21-16-9.16a8,8,0,0,0-7.94,0l-16,9.16V172a51.88,51.88,0,0,0,40,0ZM196,160a36,36,0,1,1,36-36A36,36,0,0,1,196,160Z"/>
</symbol>
</svg>
<symbol id="envelope-simple" viewBox="0 0 256 256"><path d="M224,48H32a8,8,0,0,0-8,8V192a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A8,8,0,0,0,224,48Zm-8,144H40V74.19l82.59,75.71a8,8,0,0,0,10.82,0L216,74.19V192Z"/></symbol></svg>

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Before After
Before After

View file

@ -108,6 +108,26 @@
border-radius:999px;padding:1px 7px;font-size:11px;font-weight:700"></span>
</div>
<!-- E-Mail-Verifikations-Banner -->
<div id="verify-banner" aria-live="polite"
style="display:none;position:fixed;top:0;left:0;right:0;z-index:9998;
background:#d97706;color:#fff;font-size:0.8rem;font-weight:500;
padding:8px 16px;align-items:center;justify-content:center;gap:10px;
box-shadow:0 2px 8px rgba(0,0,0,.2)">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 256 256">
<path d="M224,48H32a8,8,0,0,0-8,8V192a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A8,8,0,0,0,224,48ZM98.71,128,40,181.81V74.19Zm11.84,10.85,12,11.05a8,8,0,0,0,10.82,0l12-11.05,58,53.15H52.57ZM157.29,128,216,74.19V181.81ZM40,61.62l88,80.15,88-80.15Z"/>
</svg>
<span>Bitte bestätige deine E-Mail-Adresse — wir haben dir eine Mail geschickt.</span>
<button id="verify-resend-btn"
style="background:rgba(255,255,255,.2);border:none;color:#fff;padding:3px 10px;
border-radius:999px;font-size:0.75rem;cursor:pointer;font-weight:600">
Erneut senden
</button>
<button id="verify-banner-close"
style="background:none;border:none;color:#fff;opacity:.7;cursor:pointer;
font-size:1rem;line-height:1;padding:0 4px" aria-label="Schließen">✕</button>
</div>
<!-- Backdrop + Sidebar direkt im body (kein Ancestor-Stacking-Context) -->
<div id="sidebar-backdrop" class="sidebar-backdrop"></div>
@ -230,7 +250,7 @@
border-top:1px solid var(--c-border,#e5e7eb);
font-size:var(--text-xs);color:var(--c-text-muted);
display:flex;flex-direction:column;gap:var(--space-2);padding-bottom:var(--space-2)">
<div style="display:flex;gap:var(--space-3)">
<div style="display:flex;gap:var(--space-3);justify-content:center">
<span data-page="impressum" style="cursor:pointer;text-decoration:underline">Impressum</span>
<span data-page="datenschutz" style="cursor:pointer;text-decoration:underline">Datenschutz</span>
</div>

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '539'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '552'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.1.4'; // ← semantische Version, wird bei make release gesetzt
const IS_STAGING = location.hostname === 'staging.banyaro.app';
@ -76,13 +76,15 @@ const App = (() => {
// AUTH GUARD — Login-Gate Texte pro Seite
// ----------------------------------------------------------
const AUTH_GATE = {
diary: { icon: 'book-open', text: 'Halte besondere Momente mit deinem Hund fest — Spaziergänge, Meilensteine, Fotos.' },
health: { icon: 'syringe', text: 'Impfungen, Tierarztbesuche, Medikamente und Allergien deines Hundes immer im Blick.' },
'dog-profile':{ icon: 'paw-print', text: 'Erstelle ein Profil für deinen Hund — mit Foto, Bio und NFC-Tag.' },
friends: { icon: 'users', text: 'Finde Hundebesitzer in deiner Nähe und baue ein Netzwerk auf.' },
chat: { icon: 'chat-circle-dots', text: 'Schreibe direkt mit anderen Hundebesitzern.' },
walks: { icon: 'paw-print', text: 'Organisiere Gassi-Treffen und triff andere Hunde in deiner Gegend.' },
sitting: { icon: 'house-line', text: 'Finde einen Dogsitter oder biete selbst Sitting an.' },
diary: { icon: 'book-open', text: 'Dein persönliches Hunde-Tagebuch — Fotos, Notizen, Stimmungen. Nur für dich, privat und sicher.', preview: '/img/screenshots/screen-1.jpg' },
health: { icon: 'syringe', text: 'Impfungen, Tierarztbesuche, Gewicht und Medikamente — alles an einem Ort, immer abrufbar.', preview: '/img/screenshots/screen-3.jpg' },
'dog-profile':{ icon: 'paw-print', text: 'Erstelle ein Profil für deinen Hund mit Foto, Bio, Chip-Nr. und NFC-Tag.', preview: null },
friends: { icon: 'users', text: 'Finde Hundebesitzer in deiner Nähe und tausche dich aus.', preview: null },
chat: { icon: 'chat-circle-dots', text: 'Schreibe direkt mit anderen Hundebesitzern.', preview: null },
walks: { icon: 'paw-print', text: 'Organisiere Gassi-Treffen und triff andere Hunde.', preview: '/img/screenshots/screen-5.jpg' },
sitting: { icon: 'house-line', text: 'Finde einen Dogsitter oder biete selbst Sitting an.', preview: null },
uebungen: { icon: 'target', text: 'Über 100 Übungen mit Anleitungen — tracke deinen Trainingsfortschritt.', preview: '/img/screenshots/screen-4.jpg' },
notes: { icon: 'note-pencil', text: 'Dein persönlicher Notizblock — für alles was du nicht vergessen willst.', preview: null },
};
// ----------------------------------------------------------
@ -122,10 +124,9 @@ const App = (() => {
async function _loadPage(pageId, params = {}) {
const page = pages[pageId];
// AUTH GUARD — geschützte Seiten für nicht-eingeloggte User
// AUTH GUARD — geschützte Seiten für nicht-eingeloggte User → Welcome
if (page.requiresAuth && !state.user) {
const container = document.querySelector(`#page-${pageId} .page-body`);
if (container) _renderLoginGate(container, pageId);
navigate('welcome', false);
return;
}
@ -188,16 +189,34 @@ const App = (() => {
container.innerHTML = `
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;
min-height:60vh;padding:var(--space-8) var(--space-5);text-align:center;gap:var(--space-5)">
min-height:60vh;padding:var(--space-6) var(--space-5);text-align:center;gap:var(--space-4)">
<!-- Icon -->
<div style="width:72px;height:72px;border-radius:50%;
<!-- Preview-Screenshot (wenn vorhanden) -->
${gate.preview ? `
<div style="position:relative;width:100%;max-width:280px;border-radius:var(--radius-lg);
overflow:hidden;box-shadow:0 8px 32px rgba(0,0,0,0.15)">
<img src="${gate.preview}" alt="${UI.escape(title)}"
style="width:100%;display:block;filter:blur(3px) brightness(0.7);transform:scale(1.05)">
<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center">
<div style="background:rgba(255,255,255,0.15);backdrop-filter:blur(4px);
border-radius:var(--radius-md);padding:var(--space-3) var(--space-4);
border:1px solid rgba(255,255,255,0.3)">
<svg style="width:28px;height:28px;color:#fff;display:block;margin:0 auto" aria-hidden="true">
<use href="/icons/phosphor.svg#lock-simple"></use>
</svg>
<span style="font-size:var(--text-xs);color:#fff;font-weight:700;display:block;margin-top:4px">
Nur für Mitglieder
</span>
</div>
</div>
</div>` : `
<div style="width:64px;height:64px;border-radius:50%;
background:var(--c-primary-subtle);
display:flex;align-items:center;justify-content:center">
<svg style="width:36px;height:36px;color:var(--c-primary)" aria-hidden="true">
<svg style="width:32px;height:32px;color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#${UI.escape(gate.icon)}"></use>
</svg>
</div>
</div>`}
<!-- Text -->
<div style="max-width:300px">
@ -213,14 +232,13 @@ const App = (() => {
<!-- CTAs -->
<div style="display:flex;flex-direction:column;gap:var(--space-3);width:100%;max-width:280px">
<button class="btn btn-primary" id="gate-login-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#sign-out"></use></svg>
Anmelden
</button>
<button class="btn btn-secondary" id="gate-register-btn">
<button class="btn btn-primary" id="gate-register-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#user-plus"></use></svg>
Kostenlos registrieren
</button>
<button class="btn btn-ghost" id="gate-login-btn" style="font-size:var(--text-sm)">
Schon dabei? Anmelden
</button>
</div>
<!-- Hinweis was sonst frei ist -->
@ -455,6 +473,7 @@ const App = (() => {
navigate('onboarding');
}
_showVerifyBanner();
_updateNotifBadge();
_updateChatBadge();
_checkNearbyAlerts();
@ -529,13 +548,30 @@ const App = (() => {
_updateHeaderUserBtn(false);
// Wenn aktuelle Seite geschützt ist → zu freier Seite wechseln
if (pages[state.page]?.requiresAuth) {
navigate('map', false);
} else {
// Bleib auf der Seite, zeige aber den Gate-Screen
_loadPage(state.page);
// Nicht eingeloggte User immer zur Welcome-Seite
navigate('welcome', false);
}
function _showVerifyBanner() {
const banner = document.getElementById('verify-banner');
if (!banner) return;
if (!state.user || state.user.email_verified) {
banner.style.display = 'none';
return;
}
const dismissed = sessionStorage.getItem('by_verify_dismissed');
if (dismissed) return;
banner.style.display = 'flex';
document.getElementById('verify-resend-btn')?.addEventListener('click', async () => {
await API.post('/auth/resend-verification', {});
UI.toast.success('Bestätigungs-Mail erneut gesendet.');
}, { once: true });
document.getElementById('verify-banner-close')?.addEventListener('click', () => {
banner.style.display = 'none';
sessionStorage.setItem('by_verify_dismissed', '1');
}, { once: true });
}
function _updateHeaderUserBtn(loggedIn) {
@ -787,8 +823,21 @@ const App = (() => {
hashParams[k] = isNaN(v) ? v : Number(v);
});
}
// E-Mail-Verifikation: Redirect von /api/auth/verify-email/{token}
if (hashParams.verified === '1' || hashParams.verified === 1) {
if (state.user) state.user.email_verified = 1;
document.getElementById('verify-banner')?.style?.setProperty('display', 'none');
UI.toast.success('E-Mail-Adresse erfolgreich bestätigt!');
history.replaceState(null, '', '/');
} else if (hashParams.verified === 'error') {
UI.toast.error('Ungültiger oder abgelaufener Bestätigungs-Link.');
history.replaceState(null, '', '/');
}
const startPage = (hashPage && pages[hashPage]) ? hashPage : 'welcome';
navigate(startPage, false, hashParams);
// Nicht eingeloggte User immer zur Welcome-Seite — auch bei direktem Link auf Forum, Map etc.
navigate(state.user ? startPage : 'welcome', false, hashParams);
}
async function _handleInvite(token) {

View file

@ -20,6 +20,7 @@ window.Page_admin = (() => {
{ id: 'system', label: 'System', icon: 'gear' },
{ id: 'jobs', label: 'Jobs', icon: 'clock' },
{ id: 'partner', label: 'Partner', icon: 'handshake' },
{ id: 'outreach', label: 'Outreach', icon: 'envelope-simple' },
{ id: 'audit', label: 'Audit-Log', icon: 'clipboard-text' },
];
@ -90,6 +91,7 @@ window.Page_admin = (() => {
case 'system': await _renderSystem(el); break;
case 'jobs': await _renderJobs(el); break;
case 'partner': await _renderPartner(el); break;
case 'outreach': await _renderOutreach(el); break;
case 'audit': await _renderAudit(el); break;
}
} catch (e) {
@ -2016,6 +2018,256 @@ window.Page_admin = (() => {
});
}
async function _renderOutreach(el) {
const [templates, log] = await Promise.all([
API.get('/outreach/templates').catch(() => []),
API.get('/outreach/log').catch(() => []),
]);
const accountBadge = a => a === 'support'
? `<span style="font-size:10px;background:var(--c-warning-bg,#FEF3C7);color:var(--c-warning,#D97706);padding:1px 6px;border-radius:999px">support@</span>`
: `<span style="font-size:10px;background:var(--c-primary-bg,#EFF6FF);color:var(--c-primary,#2563EB);padding:1px 6px;border-radius:999px">partner@</span>`;
el.innerHTML = `
<div style="display:flex;flex-direction:column;gap:var(--space-5)">
<!-- Vorlagen-Manager -->
<div class="by-card" style="padding:var(--space-4)">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-3)">
<h3 style="margin:0;font-size:var(--text-base)">Vorlagen</h3>
<button class="btn btn-sm btn-secondary" id="adm-tpl-new">
${UI.icon('plus')} Neue Vorlage
</button>
</div>
${templates.length === 0
? `<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Noch keine Vorlagen.</p>`
: `<div style="display:flex;flex-direction:column;gap:var(--space-2)">
${templates.map(t => `
<div style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-2) var(--space-3);
background:var(--c-bg-elevated);border-radius:var(--radius-md);border:1px solid var(--c-border)">
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:var(--space-2)">
<span style="font-size:var(--text-sm);font-weight:600">${_esc(t.label)}</span>
${accountBadge(t.from_account)}
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${_esc(t.subject)}
</div>
</div>
<div style="display:flex;gap:var(--space-2);flex-shrink:0">
<button class="btn btn-xs btn-secondary adm-tpl-load" data-id="${t.id}" title="In Compose laden">
${UI.icon('arrow-bend-up-left')}
</button>
<button class="btn btn-xs btn-secondary adm-tpl-edit" data-id="${t.id}" title="Bearbeiten">
${UI.icon('pencil-simple')}
</button>
<button class="btn btn-xs btn-danger adm-tpl-del" data-id="${t.id}" title="Löschen">
${UI.icon('trash')}
</button>
</div>
</div>`).join('')}
</div>`}
</div>
<!-- Compose -->
<div class="by-card" style="padding:var(--space-4)">
<h3 style="margin:0 0 var(--space-3);font-size:var(--text-base)">E-Mail senden</h3>
<form id="adm-outreach-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
<!-- Absender -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div>
<label class="form-label" style="font-size:var(--text-xs)">Absender</label>
<select id="adm-outreach-from" class="form-control">
<option value="partner">partner@banyaro.app (Influencer/Partner)</option>
<option value="support">support@banyaro.app (Support/Moderation)</option>
</select>
</div>
<div>
<label class="form-label" style="font-size:var(--text-xs)">
Empfänger <span style="color:var(--c-text-muted)">(Komma-getrennt)</span>
</label>
<input class="form-control" id="adm-outreach-to" type="text"
placeholder="name@example.com, andere@example.com">
</div>
</div>
<!-- Betreff -->
<div>
<label class="form-label" style="font-size:var(--text-xs)">Betreff</label>
<input class="form-control" id="adm-outreach-subject" type="text">
</div>
<!-- Text -->
<div>
<label class="form-label" style="font-size:var(--text-xs)">Text</label>
<textarea id="adm-outreach-body" class="form-control" rows="14"
style="font-family:monospace;font-size:var(--text-sm);resize:vertical"></textarea>
</div>
<div style="display:flex;gap:var(--space-3);align-items:center">
<button type="submit" class="btn btn-primary">
${UI.icon('paper-plane-tilt')} Senden
</button>
<span style="font-size:var(--text-xs);color:var(--c-text-muted)">
{name} wird nicht automatisch ersetzt bitte manuell anpassen.
</span>
</div>
</form>
</div>
<!-- Versand-Log -->
<div class="by-card" style="padding:var(--space-4)">
<h3 style="margin:0 0 var(--space-3);font-size:var(--text-base)">Versand-Log</h3>
${log.length === 0
? `<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Noch keine E-Mails gesendet.</p>`
: `<table style="width:100%;border-collapse:collapse;font-size:var(--text-xs)">
<thead>
<tr style="border-bottom:1px solid var(--c-border)">
<th style="text-align:left;padding:var(--space-2);color:var(--c-text-muted)">Von</th>
<th style="text-align:left;padding:var(--space-2);color:var(--c-text-muted)">Empfänger</th>
<th style="text-align:left;padding:var(--space-2);color:var(--c-text-muted)">Betreff</th>
<th style="text-align:left;padding:var(--space-2);color:var(--c-text-muted)">Wer</th>
<th style="text-align:left;padding:var(--space-2);color:var(--c-text-muted)">Wann</th>
</tr>
</thead>
<tbody>
${log.map(l => `
<tr style="border-bottom:1px solid var(--c-border)">
<td style="padding:var(--space-2)">${accountBadge(l.from_account)}</td>
<td style="padding:var(--space-2)">${_esc(l.recipient)}</td>
<td style="padding:var(--space-2);color:var(--c-text-secondary)">${_esc(l.subject)}</td>
<td style="padding:var(--space-2);color:var(--c-text-muted)">${_esc(l.sent_by_name || '')}</td>
<td style="padding:var(--space-2);color:var(--c-text-muted)">${(l.sent_at||'').slice(0,16).replace('T',' ')}</td>
</tr>`).join('')}
</tbody>
</table>`}
</div>
</div>
`;
// Vorlage in Compose laden
function _loadTplIntoCompose(id) {
const tpl = templates.find(t => t.id === id);
if (!tpl) return;
el.querySelector('#adm-outreach-from').value = tpl.from_account || 'partner';
el.querySelector('#adm-outreach-subject').value = tpl.subject;
el.querySelector('#adm-outreach-body').value = tpl.body;
}
el.querySelectorAll('.adm-tpl-load').forEach(btn => {
btn.addEventListener('click', () => _loadTplIntoCompose(Number(btn.dataset.id)));
});
// Vorlage löschen
el.querySelectorAll('.adm-tpl-del').forEach(btn => {
btn.addEventListener('click', async () => {
if (!window.confirm('Vorlage löschen?')) return;
await API.del(`/outreach/templates/${btn.dataset.id}`);
await _renderOutreach(el);
});
});
// Vorlage bearbeiten
el.querySelectorAll('.adm-tpl-edit').forEach(btn => {
btn.addEventListener('click', () => {
const tpl = templates.find(t => t.id === Number(btn.dataset.id));
if (tpl) _openTplModal(el, tpl);
});
});
// Neue Vorlage
el.querySelector('#adm-tpl-new')?.addEventListener('click', () => _openTplModal(el, null));
// Senden
el.querySelector('#adm-outreach-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = e.target.querySelector('[type="submit"]');
const from_account = el.querySelector('#adm-outreach-from').value;
const to = (el.querySelector('#adm-outreach-to').value || '')
.split(',').map(s => s.trim()).filter(Boolean);
const subject = el.querySelector('#adm-outreach-subject').value.trim();
const body = el.querySelector('#adm-outreach-body').value.trim();
if (!to.length) { UI.toast.warning('Bitte mindestens einen Empfänger eingeben.'); return; }
if (!subject) { UI.toast.warning('Betreff fehlt.'); return; }
if (!body) { UI.toast.warning('Text fehlt.'); return; }
await UI.asyncButton(btn, async () => {
const res = await API.post('/outreach/send', { to, subject, body, from_account });
if (res.sent?.length) UI.toast.success(`${res.sent.length} E-Mail(s) gesendet.`);
if (res.failed?.length) UI.toast.error(`${res.failed.length} Fehler: ${res.failed.map(f => f.error).join(', ')}`);
await _renderOutreach(el);
});
});
}
function _openTplModal(el, tpl) {
const isNew = !tpl;
const id = `adm-tpl-modal-${Date.now()}`;
UI.modal.open({
title: isNew ? 'Neue Vorlage' : 'Vorlage bearbeiten',
body: `
<form id="${id}" style="display:flex;flex-direction:column;gap:var(--space-3)">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div>
<label class="form-label" style="font-size:var(--text-xs)">Name (intern)</label>
<input class="form-control" id="${id}-key" type="text" placeholder="z.B. willkommen_neu"
value="${_esc(tpl?.key || '')}" ${isNew ? '' : 'readonly'}>
</div>
<div>
<label class="form-label" style="font-size:var(--text-xs)">Absender</label>
<select id="${id}-from" class="form-control">
<option value="partner" ${(tpl?.from_account||'partner')==='partner'?'selected':''}>partner@banyaro.app</option>
<option value="support" ${tpl?.from_account==='support'?'selected':''}>support@banyaro.app</option>
</select>
</div>
</div>
<div>
<label class="form-label" style="font-size:var(--text-xs)">Bezeichnung (sichtbar)</label>
<input class="form-control" id="${id}-label" type="text" placeholder="z.B. Willkommensnachricht"
value="${_esc(tpl?.label || '')}">
</div>
<div>
<label class="form-label" style="font-size:var(--text-xs)">Betreff</label>
<input class="form-control" id="${id}-subject" type="text"
value="${_esc(tpl?.subject || '')}">
</div>
<div>
<label class="form-label" style="font-size:var(--text-xs)">Text</label>
<textarea id="${id}-body" class="form-control" rows="12"
style="font-family:monospace;font-size:var(--text-sm);resize:vertical">${_esc(tpl?.body || '')}</textarea>
</div>
</form>`,
footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" form="${id}" type="submit">Speichern</button>`,
});
document.getElementById(id)?.addEventListener('submit', async e => {
e.preventDefault();
const payload = {
label: document.getElementById(`${id}-label`).value.trim(),
subject: document.getElementById(`${id}-subject`).value.trim(),
body: document.getElementById(`${id}-body`).value.trim(),
from_account: document.getElementById(`${id}-from`).value,
};
if (!payload.label || !payload.subject || !payload.body) {
UI.toast.warning('Alle Felder ausfüllen.'); return;
}
if (isNew) {
const key = document.getElementById(`${id}-key`).value.trim();
if (!key) { UI.toast.warning('Interner Name fehlt.'); return; }
await API.post('/outreach/templates', { ...payload, key });
} else {
await API.put(`/outreach/templates/${tpl.id}`, payload);
}
UI.modal.close();
await _renderOutreach(el);
});
}
async function _renderAudit(el) {
el.innerHTML = `
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">

View file

@ -29,7 +29,7 @@ window.Page_datenschutz = (() => {
${sec('Verantwortlicher', `
<p style="${S.p}">
René Degelmann, Ringstr. 26, 85560 Ebersberg<br>
E-Mail: <a href="mailto:mail@motocamp.de" style="${S.a}">mail@motocamp.de</a>
E-Mail: <a href="mailto:hallo@banyaro.app" style="${S.a}">hallo@banyaro.app</a>
</p>`)}
${sec('Deine Daten gehören dir', `
@ -169,7 +169,7 @@ window.Page_datenschutz = (() => {
(Art. 18) sowie <strong>Datenportabilität</strong> (Art. 20). Erteilte Einwilligungen
kannst du jederzeit mit Wirkung für die Zukunft widerrufen (Art. 7 Abs. 3 DSGVO).
Zur Ausübung deiner Rechte wende dich per E-Mail an
<a href="mailto:mail@motocamp.de" style="${S.a}">mail@motocamp.de</a>.<br><br>
<a href="mailto:hallo@banyaro.app" style="${S.a}">hallo@banyaro.app</a>.<br><br>
Du hast außerdem das Recht, bei der zuständigen Datenschutz-Aufsichtsbehörde
Beschwerde einzulegen:<br>
<strong>Bayerisches Landesamt für Datenschutzaufsicht (BayLDA)</strong><br>

View file

@ -25,9 +25,9 @@ window.Page_impressum = (() => {
<h2 style="font-size:var(--text-base);font-weight:var(--weight-semibold);
color:var(--c-text);margin:0 0 var(--space-2)">Kontakt</h2>
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.7;margin:0">
E-Mail: <a href="mailto:mail@motocamp.de"
style="color:var(--c-primary)">mail@motocamp.de</a><br>
Kontaktformular: <a href="mailto:mail@motocamp.de"
E-Mail: <a href="mailto:hallo@banyaro.app"
style="color:var(--c-primary)">hallo@banyaro.app</a><br>
Kontaktformular: <a href="mailto:hallo@banyaro.app"
style="color:var(--c-primary)">Nachricht senden</a>
</p>
</section>

View file

@ -807,6 +807,7 @@ window.Page_map = (() => {
// Marker setzen (Placement-Mode)
// ----------------------------------------------------------
function _togglePlacementMode() {
if (!_appState?.user) { App.navigate('welcome'); return; }
_placingMarker = !_placingMarker;
const btn = document.getElementById('map-pin-btn');
if (_placingMarker) {

View file

@ -138,7 +138,15 @@ window.Page_settings = (() => {
style="display:none">
<div>
<div style="font-weight:700;font-size:var(--text-lg)">${_esc(u.name)}</div>
<div style="color:var(--c-text-secondary);font-size:var(--text-sm)">${_esc(u.email)}</div>
<div style="display:flex;align-items:center;gap:var(--space-2);color:var(--c-text-secondary);font-size:var(--text-sm)">
${_esc(u.email)}
${u.email_verified
? `<svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px;color:#22c55e" title="Bestätigt"><use href="/icons/phosphor.svg#check-circle"></use></svg>`
: `<span id="settings-verify-chip"
style="font-size:10px;background:#fef3c7;color:#d97706;padding:1px 7px;
border-radius:999px;cursor:pointer;white-space:nowrap"
title="E-Mail noch nicht bestätigt">Nicht bestätigt</span>`}
</div>
<div style="display:flex;flex-wrap:wrap;gap:var(--space-1);margin-top:var(--space-1)">
${u.is_premium
? `<span class="badge badge-primary">
@ -149,6 +157,12 @@ window.Page_settings = (() => {
? `<span class="badge" style="background:#7c3aed;color:#fff;cursor:pointer" data-page="gruender">
<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#key"></use></svg>
${u.founder_number ? `Gründer #${u.founder_number}` : 'Gründer'}
</span>`
: u.is_founder_pending
? `<span class="badge" style="background:#f59e0b;color:#fff;cursor:pointer" data-page="dog-profile"
title="Hunde-Profil anlegen um Gründer-Platz zu sichern">
<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#hourglass"></use></svg>
Gründer-Platz reserviert
</span>` : ''}
${u.is_partner
? `<span class="badge" style="background:#0ea5e9;color:#fff">
@ -474,6 +488,12 @@ window.Page_settings = (() => {
});
// Avatar-Hover-Overlay
// E-Mail-Verifikation: Chip → erneut senden
document.getElementById('settings-verify-chip')?.addEventListener('click', async () => {
await API.post('/auth/resend-verification', {});
UI.toast.success('Bestätigungs-Mail gesendet — bitte prüfe dein Postfach.');
});
const avatarBtn = document.getElementById('settings-avatar-btn');
const avatarOverlay = avatarBtn?.querySelector('.avatar-overlay');
if (avatarBtn && avatarOverlay) {
@ -1525,7 +1545,9 @@ window.Page_settings = (() => {
_appState.activeDog = null;
document.getElementById('header-login-btn')?.remove();
const greeting = _appState.user.is_founder
const greeting = _appState.user.is_founder_pending
? `Willkommen, ${_appState.user.name}! 🎉 Dein Gründer-Platz ist reserviert — leg jetzt dein Hunde-Profil an um ihn zu sichern!`
: _appState.user.is_founder
? `Willkommen, Gründer ${_appState.user.name}! 🎉`
: `Willkommen bei Ban Yaro, ${_appState.user.name}!`;
UI.toast.success(greeting);

View file

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

View file

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