diff --git a/backend/auth.py b/backend/auth.py index b3365aa..d923c70 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -87,7 +87,7 @@ def get_current_user( user_id = int(payload["sub"]) with db() as conn: row = conn.execute( - "SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason, is_social_media, notes_ki_enabled, breeder_status FROM users WHERE id=?", + "SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason, is_social_media, notes_ki_enabled, breeder_status, is_founder, is_partner FROM users WHERE id=?", (user_id,) ).fetchone() diff --git a/backend/database.py b/backend/database.py index 940c76f..e428a56 100644 --- a/backend/database.py +++ b/backend/database.py @@ -559,6 +559,10 @@ def _migrate(conn_factory): ("users", "ki_zucht_paarung", "INTEGER NOT NULL DEFAULT 1"), ("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"), ] with conn_factory() as conn: for table, column, col_type in migrations: @@ -1485,6 +1489,25 @@ def _migrate(conn_factory): CREATE INDEX IF NOT EXISTS idx_bj_user ON breeder_jahresberichte(user_id, jahr DESC); """) + # Partner-Codes (Influencer-Kooperationen + Gründer-Lizenz) + try: + conn.executescript(""" + CREATE TABLE IF NOT EXISTS partner_codes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL UNIQUE COLLATE NOCASE, + label TEXT NOT NULL, + grants_founder INTEGER NOT NULL DEFAULT 1, + max_uses INTEGER DEFAULT NULL, + uses INTEGER NOT NULL DEFAULT 0, + created_by INTEGER REFERENCES users(id), + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_partner_codes_code ON partner_codes(code); + """) + logger.info("Migration: partner_codes Tabelle bereit.") + except Exception as e: + logger.warning(f"Migration partner_codes: {e}") + # 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: diff --git a/backend/main.py b/backend/main.py index 4490cbb..db883dc 100644 --- a/backend/main.py +++ b/backend/main.py @@ -162,6 +162,7 @@ from routes.breeder_photos import router as breeder_photos_router 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 app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) @@ -193,6 +194,7 @@ app.include_router(breeder_photos_router, prefix="/api", tags=["Züchter- app.include_router(zucht_hunde_router, prefix="/api", tags=["Zuchtkartei"]) 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(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"]) @@ -1401,6 +1403,16 @@ async def knigge_page(): # SPA Fallback — ALLE nicht-API-Routen gehen zur index.html @app.get("/{full_path:path}") async def spa_fallback(full_path: str): + IS_STAGING = os.getenv("STAGING", "false").lower() == "true" + if IS_STAGING: + from fastapi.responses import HTMLResponse + with open(f"{STATIC_DIR}/index.html", encoding="utf-8") as f: + html = f.read() + html = html.replace( + '', + '', + ) + return HTMLResponse(content=html, headers={"Cache-Control": "no-cache"}) return FileResponse( f"{STATIC_DIR}/index.html", headers={"Cache-Control": "no-cache"} diff --git a/backend/routes/auth.py b/backend/routes/auth.py index 73349b4..32d5c6c 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -78,13 +78,42 @@ async def register(data: RegisterRequest, response: Response, request: Request): new_user_id = user["id"] if data.ref_code: - referrer = conn.execute( - "SELECT id FROM users WHERE referral_code=? AND id != ?", - (data.ref_code.strip().upper(), new_user_id) + code_upper = data.ref_code.strip().upper() + # Zuerst prüfen ob es ein Partner-Code ist + partner = conn.execute( + "SELECT id, grants_founder, max_uses, uses FROM partner_codes WHERE code=?", + (code_upper,) ).fetchone() - if referrer: - conn.execute("UPDATE users SET referred_by=? WHERE id=?", - (referrer['id'], new_user_id)) + if partner: + # Nur einlösen wenn max_uses nicht erreicht + if partner["max_uses"] is None or partner["uses"] < partner["max_uses"]: + conn.execute( + "UPDATE partner_codes SET uses=uses+1 WHERE id=?", + (partner["id"],) + ) + updates = {"referred_by": -partner["id"]} + if partner["grants_founder"]: + total_founders = conn.execute( + "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 + set_clause = ", ".join(f"{k}=?" for k in updates) + conn.execute( + f"UPDATE users SET {set_clause} WHERE id=?", + (*updates.values(), new_user_id) + ) + else: + # Fallback: Referral-Code eines anderen Users + referrer = conn.execute( + "SELECT id FROM users WHERE referral_code=? AND id != ?", + (code_upper, new_user_id) + ).fetchone() + if referrer: + conn.execute("UPDATE users SET referred_by=? WHERE id=?", + (referrer['id'], new_user_id)) token = create_token(user["id"], user["rolle"]) _set_cookie(response, token) @@ -120,6 +149,24 @@ async def logout(response: Response): return {"ok": True} +_REFERRAL_TIERS = [ + (50, 50), + (20, 30), + (10, 20), +] + +def _referral_tier(count: int): + for threshold, discount in _REFERRAL_TIERS: + if count >= threshold: + return discount + return 0 + +def _referral_next(count: int): + for threshold, discount in reversed(_REFERRAL_TIERS): + if count < threshold: + return {"count": threshold, "discount": discount} + return None # Maximalstufe erreicht + @router.get("/referral") async def get_referral_info(user=Depends(get_current_user)): with db() as conn: @@ -133,11 +180,14 @@ async def get_referral_info(user=Depends(get_current_user)): if not code: code = _gen_referral_code() conn.execute("UPDATE users SET referral_code=? WHERE id=?", (code, user['id'])) - base = os.getenv("APP_URL", "https://banyaro.app") + count = row["count"] if row else 0 + base = os.getenv("APP_URL", "https://banyaro.app") return { - "code": code, - "count": row["count"] if row else 0, - "link": f"{base}/?ref={code}", + "code": code, + "count": count, + "link": f"{base}/?ref={code}", + "discount_pct": _referral_tier(count), + "next_tier": _referral_next(count), } diff --git a/backend/routes/forum.py b/backend/routes/forum.py index d22a460..b6d204f 100644 --- a/backend/routes/forum.py +++ b/backend/routes/forum.py @@ -131,7 +131,7 @@ async def list_threads( t.antworten, t.likes, t.views, t.is_pinned, t.is_locked, t.foto_urls, t.created_at, t.user_id, - u.name AS autor_name + u.name AS autor_name, u.founder_number AS autor_founder_number FROM forum_threads t LEFT JOIN users u ON u.id = t.user_id WHERE t.is_deleted = 0 @@ -183,7 +183,7 @@ async def create_thread(data: ThreadCreate, user=Depends(get_current_user)): data.thread_lat, data.thread_lon, data.thread_ort, ct) ) row = conn.execute( - """SELECT t.*, u.name AS autor_name + """SELECT t.*, u.name AS autor_name, u.founder_number AS autor_founder_number FROM forum_threads t LEFT JOIN users u ON u.id = t.user_id WHERE t.id = ?""", @@ -203,7 +203,7 @@ async def get_thread(thread_id: int, user=Depends(get_current_user_optional)): uid = user['id'] if user else None with db() as conn: thread = conn.execute( - """SELECT t.*, u.name AS autor_name + """SELECT t.*, u.name AS autor_name, u.founder_number AS autor_founder_number FROM forum_threads t LEFT JOIN users u ON u.id = t.user_id WHERE t.id = ? AND t.is_deleted = 0""", @@ -218,7 +218,7 @@ async def get_thread(thread_id: int, user=Depends(get_current_user_optional)): ) posts = conn.execute( - """SELECT p.*, u.name AS autor_name + """SELECT p.*, u.name AS autor_name, u.founder_number AS autor_founder_number FROM forum_posts p LEFT JOIN users u ON u.id = p.user_id WHERE p.thread_id = ? @@ -288,7 +288,7 @@ async def patch_thread(thread_id: int, data: ThreadPatch, user=Depends(get_curre [*updates.values(), thread_id] ) row = conn.execute( - """SELECT t.*, u.name AS autor_name + """SELECT t.*, u.name AS autor_name, u.founder_number AS autor_founder_number FROM forum_threads t LEFT JOIN users u ON u.id = t.user_id WHERE t.id = ?""", @@ -328,7 +328,7 @@ async def create_post(thread_id: int, data: PostCreate, user=Depends(get_current (thread_id,) ) row = conn.execute( - """SELECT p.*, u.name AS autor_name + """SELECT p.*, u.name AS autor_name, u.founder_number AS autor_founder_number FROM forum_posts p LEFT JOIN users u ON u.id = p.user_id WHERE p.id = ?""", diff --git a/backend/routes/partner.py b/backend/routes/partner.py new file mode 100644 index 0000000..745fde7 --- /dev/null +++ b/backend/routes/partner.py @@ -0,0 +1,192 @@ +"""BAN YARO — Partner-Codes + Gründer-Lizenz""" + +from typing import Optional +from fastapi import APIRouter, HTTPException, Depends +from pydantic import BaseModel +from database import db +from auth import require_admin, get_current_user + +router = APIRouter() + + +class PartnerCodeCreate(BaseModel): + code: str + label: str + grants_founder: int = 1 + max_uses: Optional[int] = None + + +class GrantRequest(BaseModel): + is_founder: Optional[int] = None + is_partner: Optional[int] = None + + +# ------------------------------------------------------------------ +# Admin: Partner-Codes verwalten +# ------------------------------------------------------------------ + +@router.get("/admin/partner/codes") +def list_partner_codes(user=Depends(require_admin)): + """Alle Partner-Codes mit Stats (admin only).""" + with db() as conn: + rows = conn.execute( + """SELECT pc.id, pc.code, pc.label, pc.grants_founder, + pc.max_uses, pc.uses, pc.created_at, + u.name AS created_by_name + FROM partner_codes pc + LEFT JOIN users u ON u.id = pc.created_by + ORDER BY pc.created_at DESC""" + ).fetchall() + return [dict(r) for r in rows] + + +@router.post("/admin/partner/codes", status_code=201) +def create_partner_code(data: PartnerCodeCreate, user=Depends(require_admin)): + """Neuen Partner-Code erstellen (admin only).""" + code = data.code.strip().upper() + if not code: + raise HTTPException(400, "Code darf nicht leer sein.") + with db() as conn: + existing = conn.execute( + "SELECT id FROM partner_codes WHERE code=?", (code,) + ).fetchone() + if existing: + raise HTTPException(400, "Dieser Code existiert bereits.") + conn.execute( + """INSERT INTO partner_codes (code, label, grants_founder, max_uses, created_by) + VALUES (?, ?, ?, ?, ?)""", + (code, data.label.strip(), data.grants_founder, data.max_uses, user["id"]) + ) + row = conn.execute( + "SELECT * FROM partner_codes WHERE code=?", (code,) + ).fetchone() + return dict(row) + + +@router.delete("/admin/partner/codes/{code_id}", status_code=204) +def delete_partner_code(code_id: int, user=Depends(require_admin)): + """Partner-Code löschen (admin only).""" + with db() as conn: + existing = conn.execute( + "SELECT id FROM partner_codes WHERE id=?", (code_id,) + ).fetchone() + if not existing: + raise HTTPException(404, "Partner-Code nicht gefunden.") + conn.execute("DELETE FROM partner_codes WHERE id=?", (code_id,)) + return None + + +@router.post("/admin/partner/users/{user_id}/grant") +def grant_user_status(user_id: int, data: GrantRequest, user=Depends(require_admin)): + """Founder- und/oder Partner-Status für einen User setzen (admin only).""" + updates = {} + if data.is_founder is not None: + updates["is_founder"] = data.is_founder + if data.is_partner is not None: + updates["is_partner"] = data.is_partner + if not updates: + raise HTTPException(400, "Mindestens is_founder oder is_partner muss angegeben werden.") + with db() as conn: + target = conn.execute( + "SELECT id, is_founder, founder_number FROM users WHERE id=?", (user_id,) + ).fetchone() + if not target: + raise HTTPException(404, "User nicht gefunden.") + # Beim manuellen Vergeben von is_founder: founder_number zuweisen wenn noch keine + if updates.get("is_founder") == 1 and not target["founder_number"]: + total = conn.execute( + "SELECT COUNT(*) FROM users WHERE is_founder=1" + ).fetchone()[0] + if total >= FOUNDER_MAX: + raise HTTPException(400, f"Alle {FOUNDER_MAX} Gründer-Plätze sind vergeben.") + updates["founder_number"] = total + 1 + set_clause = ", ".join(f"{k}=?" for k in updates) + conn.execute( + f"UPDATE users SET {set_clause} WHERE id=?", + (*updates.values(), user_id) + ) + row = conn.execute( + "SELECT id, name, email, is_founder, is_partner, founder_number FROM users WHERE id=?", + (user_id,) + ).fetchone() + return dict(row) + + +@router.get("/admin/users/search") +def search_users(q: str, user=Depends(require_admin)): + """User-Suche für Admin (Name-Präfix, max. 10 Ergebnisse).""" + with db() as conn: + rows = conn.execute( + """SELECT id, name, email, is_founder, is_partner, rolle + FROM users WHERE name LIKE ? COLLATE NOCASE + ORDER BY name LIMIT 10""", + (f"{q}%",) + ).fetchall() + return [dict(r) for r in rows] + + +# ------------------------------------------------------------------ +# Öffentlich: Gründer-Leaderboard + Code-Info +# ------------------------------------------------------------------ + +FOUNDER_MAX = 100 + +@router.get("/partner/founders/stats") +def founders_stats(): + """Öffentliches Gründer-Leaderboard: Slots, Partner-Ranking, Gründer-Liste.""" + with db() as conn: + total = conn.execute( + "SELECT COUNT(*) FROM users WHERE is_founder=1" + ).fetchone()[0] + # Partner-Ranking: nach uses absteigend + partners = conn.execute( + """SELECT pc.id, pc.label, pc.uses, pc.code, + u.name AS partner_username, u.is_partner + FROM partner_codes pc + LEFT JOIN users u ON u.referred_by = -pc.id AND u.is_partner=1 + WHERE pc.grants_founder=1 + ORDER BY pc.uses DESC""" + ).fetchall() + # Erste 100 Gründer (für Galerie) + founders = conn.execute( + """SELECT u.name, u.founder_number, + pc.label AS via_partner + FROM users u + LEFT JOIN partner_codes pc ON u.referred_by = -pc.id + WHERE u.is_founder=1 AND u.founder_number IS NOT NULL + ORDER BY u.founder_number ASC + LIMIT 100""" + ).fetchall() + return { + "total": total, + "max": FOUNDER_MAX, + "open": max(0, FOUNDER_MAX - total), + "closed": total >= FOUNDER_MAX, + "partners": [dict(p) for p in partners], + "founders": [dict(f) for f in founders], + } + + +@router.get("/partner/codes/{code}/info") +def partner_code_info(code: str): + """Gibt zurück ob ein Partner-Code existiert und dessen Label (öffentlich).""" + with db() as conn: + row = conn.execute( + """SELECT code, label, grants_founder, max_uses, uses + FROM partner_codes WHERE code=?""", + (code.strip().upper(),) + ).fetchone() + if not row: + raise HTTPException(404, "Partner-Code nicht gefunden.") + r = dict(row) + if r["grants_founder"]: + with db() as conn2: + total = conn2.execute( + "SELECT COUNT(*) FROM users WHERE is_founder=1" + ).fetchone()[0] + r["founder_slots_open"] = max(0, FOUNDER_MAX - total) + r["redeemable"] = (r["max_uses"] is None or r["uses"] < r["max_uses"]) and total < FOUNDER_MAX + else: + r["founder_slots_open"] = None + r["redeemable"] = r["max_uses"] is None or r["uses"] < r["max_uses"] + return r diff --git a/backend/static/icons/icon-180-staging.png b/backend/static/icons/icon-180-staging.png new file mode 100644 index 0000000..fc2be5c Binary files /dev/null and b/backend/static/icons/icon-180-staging.png differ diff --git a/backend/static/index.html b/backend/static/index.html index 3f4acbb..25a3ee5 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -230,6 +230,7 @@ border-top:1px solid var(--c-border,#e5e7eb); font-size:var(--text-xs);color:var(--c-text-muted); display:flex;gap:var(--space-3);padding-bottom:var(--space-2)"> + 🏆 100 Gründer Impressum Datenschutz @@ -419,6 +420,10 @@
+
+
+
+ diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 697e7d6..27a2a8a 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '490'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '495'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.0.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; @@ -69,6 +69,7 @@ const App = (() => { wurfboerse: { title: 'Wurfbörse', module: null }, zuchthunde: { title: 'Zuchtkartei', module: null, requiresAuth: true }, 'zucht-profil': { title: 'Hunde-Profil', module: null }, + gruender: { title: '100 Gründer', module: null }, }; // ---------------------------------------------------------- diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index 9452095..4706cee 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -19,6 +19,7 @@ window.Page_admin = (() => { { id: 'analytics', label: 'Analytics', icon: 'target' }, { id: 'system', label: 'System', icon: 'gear' }, { id: 'jobs', label: 'Jobs', icon: 'clock' }, + { id: 'partner', label: 'Partner & Codes', icon: 'handshake' }, { id: 'audit', label: 'Audit-Log', icon: 'clipboard-text' }, ]; @@ -88,6 +89,7 @@ window.Page_admin = (() => { case 'analytics': await _renderAnalytics(el); break; case 'system': await _renderSystem(el); break; case 'jobs': await _renderJobs(el); break; + case 'partner': await _renderPartner(el); break; case 'audit': await _renderAudit(el); break; } } catch (e) { @@ -1789,6 +1791,210 @@ window.Page_admin = (() => { // ------------------------------------------------------------------ // TAB: AUDIT-LOG // ------------------------------------------------------------------ + async function _renderPartner(el) { + const [codes] = await Promise.all([ + API.get('/api/admin/partner/codes'), + ]); + + el.innerHTML = ` +
+ + +
+

Neuen Partner-Code erstellen

+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+

Aktive Codes

+
+ ${codes.length === 0 + ? `

Noch keine Partner-Codes angelegt.

` + : ` + + + + + + + + + + + ${codes.map(c => ` + + + + + + + + `).join('')} + +
CodeBezeichnungNutzungenGründer
+ ${c.code} + ${c.label} + ${c.uses}${c.max_uses ? `/${c.max_uses}` : ''} + + ${c.grants_founder ? '✓' : '—'} + + +
` + } +
+
+ + +
+

Nutzer-Status manuell vergeben

+
+
+ + +
+
+
+ + +
+ +
+
+ +
+ `; + + // Code erstellen + el.querySelector('#adm-partner-create')?.addEventListener('submit', async e => { + e.preventDefault(); + const btn = e.target.querySelector('[type="submit"]'); + const fd = UI.formData(e.target); + const code = (fd.code || '').trim().toUpperCase(); + if (!code) return; + await UI.asyncButton(btn, async () => { + await API.post('/api/admin/partner/codes', { + code, + label: fd.label || code, + grants_founder: e.target.querySelector('[name="grants_founder"]').checked ? 1 : 0, + max_uses: fd.max_uses ? parseInt(fd.max_uses) : null, + }); + UI.toast.success(`Code "${code}" erstellt.`); + await _renderPartner(el); + }); + }); + + // Code löschen + el.querySelectorAll('.adm-del-code').forEach(btn => { + btn.addEventListener('click', async () => { + if (!window.confirm(`Code wirklich löschen?`)) return; + const id = btn.dataset.id; + await UI.asyncButton(btn, async () => { + await API.del(`/api/admin/partner/codes/${id}`); + UI.toast.success('Code gelöscht.'); + await _renderPartner(el); + }); + }); + }); + + // User suchen für Status-Vergabe + let _grantUserId = null; + const searchInput = el.querySelector('#adm-grant-search'); + const grantResult = el.querySelector('#adm-grant-result'); + let _searchTimeout = null; + searchInput?.addEventListener('input', () => { + clearTimeout(_searchTimeout); + _grantUserId = null; + const q = searchInput.value.trim(); + if (q.length < 2) { grantResult.innerHTML = ''; return; } + _searchTimeout = setTimeout(async () => { + try { + const users = await API.get(`/api/admin/users/search?q=${encodeURIComponent(q)}`); + if (!users.length) { + grantResult.innerHTML = `

Kein User gefunden.

`; + return; + } + grantResult.innerHTML = users.map(u => ` +
+ ${u.name} + + ${u.is_founder ? '⭐ Gründer ' : ''}${u.is_partner ? '🤝 Partner' : ''} + +
+ `).join(''); + grantResult.querySelectorAll('.adm-grant-user').forEach(div => { + div.addEventListener('click', () => { + _grantUserId = parseInt(div.dataset.id); + searchInput.value = div.dataset.name; + grantResult.innerHTML = `

✓ ${div.dataset.name} ausgewählt

`; + }); + }); + } catch { grantResult.innerHTML = ''; } + }, 400); + }); + + el.querySelector('#adm-partner-grant')?.addEventListener('submit', async e => { + e.preventDefault(); + if (!_grantUserId) { UI.toast.warning('Bitte erst einen User auswählen.'); return; } + const btn = e.target.querySelector('[type="submit"]'); + const isFounder = e.target.querySelector('[name="is_founder"]').checked ? 1 : 0; + const isPartner = e.target.querySelector('[name="is_partner"]').checked ? 1 : 0; + await UI.asyncButton(btn, async () => { + const result = await API.post(`/api/admin/partner/users/${_grantUserId}/grant`, { + is_founder: isFounder, + is_partner: isPartner, + }); + UI.toast.success(`Status für ${result.name} gesetzt.`); + grantResult.innerHTML = `

✓ Gründer: ${result.is_founder ? 'Ja' : 'Nein'} | Partner: ${result.is_partner ? 'Ja' : 'Nein'}

`; + }); + }); + } + async function _renderAudit(el) { el.innerHTML = `
diff --git a/backend/static/js/pages/forum.js b/backend/static/js/pages/forum.js index c46fe6a..f29ff18 100644 --- a/backend/static/js/pages/forum.js +++ b/backend/static/js/pages/forum.js @@ -392,6 +392,7 @@ window.Page_forum = (() => {
${_esc(_initial(thread.autor_name))}
${_esc(thread.autor_name || 'Unbekannt')} + ${thread.autor_founder_number ? `Gründer #${thread.autor_founder_number}` : ''}
@@ -299,9 +307,9 @@ window.Page_settings = (() => {
-
${UI.icon('arrow-square-out')} App empfehlen
+
${UI.icon('arrow-square-out')} Freunde werben — dauerhafter Rabatt
- Lade Freunde ein — jede erfolgreiche Einladung wird in deinem Profil angezeigt. + 10 Freunde → 20% · 20 Freunde → 30% · 50 Freunde → 50% — lebenslang, sobald Bezahlfunktionen aktiv sind.
Lade…
@@ -1092,50 +1100,88 @@ window.Page_settings = (() => { if (!el) return; try { const r = await API.auth.referral(); + + const TIERS = [{t:10,d:20},{t:20,d:30},{t:50,d:50}]; + const currentTier = r.discount_pct; + const next = r.next_tier; + + // Fortschrittsbalken-Berechnung + let barPct = 0, barLabel = ''; + if (!next) { + barPct = 100; + barLabel = 'Maximaler Rabatt erreicht!'; + } else { + const prevT = TIERS.find(t => t.d === currentTier)?.t || 0; + barPct = Math.round(((r.count - prevT) / (next.count - prevT)) * 100); + barLabel = `Noch ${next.count - r.count} ${next.count - r.count === 1 ? 'Person' : 'Personen'} bis ${next.discount}% Rabatt`; + } + el.innerHTML = ` + +
+ ${TIERS.map(({t, d}) => { + const reached = r.count >= t; + return `
+
+ ${d}% +
+
ab ${t} Freunden
+ ${reached ? `
✓ Erreicht
` : ''} +
`; + }).join('')} +
+ + +
+
+ + ${UI.icon('users')} ${r.count} ${r.count === 1 ? 'Freund geworben' : 'Freunde geworben'} + + ${currentTier > 0 ? `${currentTier}% Rabatt aktiv` : ''} +
+
+
+
+
${barLabel}
+
+ +
${r.link}
- - -
-
-
+
+
+
+ width:32px;height:32px;border-radius:7px;border:2px solid #fff">
- -
- ${UI.icon('users')} ${r.count} ${r.count === 1 ? 'Person' : 'Personen'} über deinen Link registriert -
- ${r.count > 0 ? ` -
- ${r.count >= 1 ? `${UI.icon('star')} Botschafter` : ''} - ${r.count >= 5 ? `${UI.icon('star')} Super-Botschafter` : ''} - ${r.count >= 10 ? `${UI.icon('star')} Top-Botschafter` : ''} -
` : ''} +

+ Der Rabatt gilt für dich — sobald Bezahlfunktionen aktiv sind, dauerhaft und automatisch. +

`; + document.getElementById('ref-share-btn')?.addEventListener('click', async () => { + const msg = `Ich bin bei Ban Yaro — der coolsten Hunde-App! Registrier dich mit meinem Link und wir wachsen zusammen 🐾`; if (navigator.share) { - navigator.share({ title: 'Ban Yaro — Die Hunde-App', text: 'Schau dir Ban Yaro an!', url: r.link }).catch(() => {}); + navigator.share({ title: 'Ban Yaro', text: msg, url: r.link }).catch(() => {}); } else { await navigator.clipboard.writeText(r.link); UI.toast.success('Link kopiert!'); } }); - // QR-Code rendern (Bibliothek lazy laden) + await App.loadScript('/js/qrcode.min.js'); new QRCode(document.getElementById('ref-qr'), { - text: r.link, - width: 160, - height: 160, - colorDark: '#000000', - colorLight: '#ffffff', + text: r.link, width: 140, height: 140, + colorDark: '#000000', colorLight: '#ffffff', correctLevel: QRCode.CorrectLevel.H, }); } catch { el.innerHTML = ''; } @@ -1283,6 +1329,16 @@ window.Page_settings = (() => {
+
+ + + +
@@ -1375,6 +1431,44 @@ window.Page_settings = (() => { }); } + // Partner-Code live validieren + const partnerInput = document.getElementById('reg-partner-code'); + const partnerHint = document.getElementById('reg-partner-hint'); + let _partnerValid = false; + if (partnerInput) { + // Vorausfüllen falls via sessionStorage gesetzt + const stored = sessionStorage.getItem('by_ref_code') || ''; + if (stored) partnerInput.value = stored; + + let _debounce = null; + partnerInput.addEventListener('input', () => { + const code = partnerInput.value.trim().toUpperCase(); + partnerInput.value = code; + clearTimeout(_debounce); + partnerHint.style.display = 'none'; + _partnerValid = false; + if (code.length < 3) return; + _debounce = setTimeout(async () => { + try { + const info = await API.get(`/api/partner/codes/${encodeURIComponent(code)}/info`); + if (info.redeemable) { + partnerHint.textContent = info.grants_founder + ? `✓ Gültiger Code von "${info.label}" — du erhältst eine lebenslange Gründer-Lizenz!` + : `✓ Gültiger Einladungscode von "${info.label}"`; + partnerHint.style.cssText = 'display:block;background:var(--c-success-bg,#f0fdf4);color:var(--c-success,#16a34a);padding:var(--space-2) var(--space-3);border-radius:var(--radius-sm);font-size:var(--text-xs)'; + _partnerValid = true; + } else { + partnerHint.textContent = 'Dieser Code ist bereits vollständig eingelöst.'; + partnerHint.style.cssText = 'display:block;background:#fef2f2;color:#dc2626;padding:var(--space-2) var(--space-3);border-radius:var(--radius-sm);font-size:var(--text-xs)'; + } + } catch { + partnerHint.textContent = 'Code nicht gefunden.'; + partnerHint.style.cssText = 'display:block;color:var(--c-text-muted);font-size:var(--text-xs)'; + } + }, 500); + }); + } + document.getElementById('auth-form')?.addEventListener('submit', async e => { e.preventDefault(); const btn = e.target.querySelector('[type="submit"]'); @@ -1390,8 +1484,10 @@ window.Page_settings = (() => { } await UI.asyncButton(btn, async () => { - const refCode = sessionStorage.getItem('by_ref_code') || ''; - const result = await API.auth.register(fd.email, fd.password, fd.name.trim(), refCode || undefined); + const partnerCode = (fd.partner_code || '').trim().toUpperCase() || undefined; + const refCode = sessionStorage.getItem('by_ref_code') || ''; + const finalCode = partnerCode || refCode || undefined; + const result = await API.auth.register(fd.email, fd.password, fd.name.trim(), finalCode); localStorage.setItem('by_token', result.token); if (refCode) sessionStorage.removeItem('by_ref_code'); @@ -1401,7 +1497,10 @@ window.Page_settings = (() => { _appState.activeDog = null; document.getElementById('header-login-btn')?.remove(); - UI.toast.success(`Willkommen bei Ban Yaro, ${_appState.user.name}!`); + const greeting = _appState.user.is_founder + ? `Willkommen, Gründer ${_appState.user.name}! 🎉` + : `Willkommen bei Ban Yaro, ${_appState.user.name}!`; + UI.toast.success(greeting); App.showOnboarding(); }); }); diff --git a/backend/static/sw.js b/backend/static/sw.js index c28125f..ad12985 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v513'; +const CACHE_VERSION = 'by-v518'; 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