Release v1.2.0 — E-Mail-Verifikation, Forum public, Auth-Gates, Mailing-System, Partner/Gründer
This commit is contained in:
commit
69cb79f973
19 changed files with 955 additions and 55 deletions
|
|
@ -1 +0,0 @@
|
|||
{"sessionId":"39ad9ffb-6cac-40b2-8c2d-b3974db3a4b8","pid":1946,"procStart":"Sat Apr 25 14:22:02 2026","acquiredAt":1777133270339}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
204
backend/main.py
204
backend/main.py
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 "—"
|
||||
|
|
|
|||
|
|
@ -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
188
backend/routes/outreach.py
Normal 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]
|
||||
|
|
@ -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 |
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue