Feature: Referral-System — User wirbt User

- DB: referral_code (8-stellig, eindeutig) + referred_by zu users
  Bestehende User erhalten automatisch einen Code
- GET /api/auth/referral: Code, Link und Anzahl geworbener User
- POST /api/auth/register: ref_code Parameter für Zuordnung
- Settings: 'App empfehlen'-Karte mit Link, Teilen-Button und Botschafter-Badges
  (Botschafter ab 1, Super ab 5, Top ab 10 Einladungen)
- app.js: ?ref=CODE aus URL in sessionStorage speichern
- APP_VER 222, SW by-v244
This commit is contained in:
rene 2026-04-19 11:09:24 +02:00
parent 82d9e26823
commit 6d757b86c2
6 changed files with 121 additions and 7 deletions

View file

@ -834,3 +834,17 @@ def _migrate(conn_factory):
CREATE INDEX IF NOT EXISTS idx_walk_inv_user ON walk_invitations(user_id, status); CREATE INDEX IF NOT EXISTS idx_walk_inv_user ON walk_invitations(user_id, status);
""") """)
logger.info("Migration: walk_invitations Tabelle bereit.") logger.info("Migration: walk_invitations Tabelle bereit.")
# Referral-Code für jeden User (einmalig generiert)
try:
conn.execute("ALTER TABLE users ADD COLUMN referral_code TEXT UNIQUE")
conn.execute("ALTER TABLE users ADD COLUMN referred_by INTEGER REFERENCES users(id)")
# Bestehende User bekommen einen Code
import secrets, string
rows = conn.execute("SELECT id FROM users WHERE referral_code IS NULL").fetchall()
for r in rows:
code = ''.join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(8))
conn.execute("UPDATE users SET referral_code=? WHERE id=?", (code, r['id']))
logger.info("Migration: referral_code + referred_by zu users hinzugefügt.")
except Exception:
pass

View file

@ -1,5 +1,10 @@
"""BAN YARO — Auth Routes""" """BAN YARO — Auth Routes"""
import os
import secrets
import string
from typing import Optional
from fastapi import APIRouter, HTTPException, Response, Depends from fastapi import APIRouter, HTTPException, Response, Depends
from pydantic import BaseModel, EmailStr from pydantic import BaseModel, EmailStr
from database import db from database import db
@ -20,6 +25,12 @@ class RegisterRequest(BaseModel):
email: EmailStr email: EmailStr
password: str password: str
name: str name: str
ref_code: Optional[str] = None
def _gen_referral_code() -> str:
alphabet = string.ascii_uppercase + string.digits
return ''.join(secrets.choice(alphabet) for _ in range(8))
def _set_cookie(response: Response, token: str): def _set_cookie(response: Response, token: str):
@ -45,10 +56,11 @@ async def register(data: RegisterRequest, response: Response):
"SELECT 1 FROM users WHERE name=? COLLATE NOCASE", (name,) "SELECT 1 FROM users WHERE name=? COLLATE NOCASE", (name,)
).fetchone(): ).fetchone():
raise HTTPException(400, "Dieser Name ist bereits vergeben. Bitte wähle einen anderen.") raise HTTPException(400, "Dieser Name ist bereits vergeben. Bitte wähle einen anderen.")
code = _gen_referral_code()
try: try:
conn.execute( conn.execute(
"INSERT INTO users (email, pw_hash, name) VALUES (?,?,?)", "INSERT INTO users (email, pw_hash, name, referral_code) VALUES (?,?,?,?)",
(data.email, hash_password(data.password), name) (data.email, hash_password(data.password), name, code)
) )
except Exception: except Exception:
# Fallback falls UNIQUE-Index greift (Race Condition) # Fallback falls UNIQUE-Index greift (Race Condition)
@ -56,6 +68,16 @@ async def register(data: RegisterRequest, response: Response):
user = conn.execute( user = conn.execute(
"SELECT id, rolle FROM users WHERE email=?", (data.email,) "SELECT id, rolle FROM users WHERE email=?", (data.email,)
).fetchone() ).fetchone()
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)
).fetchone()
if referrer:
conn.execute("UPDATE users SET referred_by=? WHERE id=?",
(referrer['id'], new_user_id))
token = create_token(user["id"], user["rolle"]) token = create_token(user["id"], user["rolle"])
_set_cookie(response, token) _set_cookie(response, token)
@ -90,6 +112,21 @@ async def logout(response: Response):
return {"ok": True} return {"ok": True}
@router.get("/referral")
async def get_referral_info(user=Depends(get_current_user)):
with db() as conn:
row = conn.execute(
"SELECT referral_code, (SELECT COUNT(*) FROM users WHERE referred_by=?) AS count FROM users WHERE id=?",
(user['id'], user['id'])
).fetchone()
base = os.getenv("APP_URL", "https://banyaro.app")
return {
"code": row["referral_code"],
"count": row["count"],
"link": f"{base}/?ref={row['referral_code']}",
}
@router.get("/me") @router.get("/me")
async def me(user=Depends(get_current_user)): async def me(user=Depends(get_current_user)):
with db() as conn: with db() as conn:

View file

@ -78,8 +78,10 @@ const API = (() => {
login(email, password) { login(email, password) {
return post('/auth/login', { email, password }); return post('/auth/login', { email, password });
}, },
register(email, password, name) { register(email, password, name, ref_code) {
return post('/auth/register', { email, password, name }); const body = { email, password, name };
if (ref_code) body.ref_code = ref_code;
return post('/auth/register', body);
}, },
logout() { logout() {
localStorage.removeItem('by_token'); localStorage.removeItem('by_token');
@ -88,6 +90,7 @@ const API = (() => {
me() { me() {
return get('/auth/me'); return get('/auth/me');
}, },
referral: () => get('/auth/referral'),
}; };
// ---------------------------------------------------------- // ----------------------------------------------------------

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung. Router, State-Management, Navigation, Initialisierung.
============================================================ */ ============================================================ */
const APP_VER = '221'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VER = '222'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const App = (() => { const App = (() => {
@ -687,6 +687,15 @@ const App = (() => {
// INITIALISIERUNG // INITIALISIERUNG
// ---------------------------------------------------------- // ----------------------------------------------------------
async function init() { async function init() {
// Referral-Code aus URL ?ref=CODE speichern
const urlParams = new URLSearchParams(window.location.search);
const refCode = urlParams.get('ref');
if (refCode) {
sessionStorage.setItem('by_ref_code', refCode.toUpperCase());
// URL bereinigen ohne Reload
history.replaceState({}, '', window.location.pathname + window.location.hash);
}
_bindNavigation(); _bindNavigation();
await _checkAuth(); await _checkAuth();

View file

@ -227,6 +227,17 @@ window.Page_settings = (() => {
</div> </div>
</div> </div>
<!-- App empfehlen -->
<div class="card" style="margin-bottom:var(--space-5)" id="referral-card">
<div style="padding:var(--space-4);border-bottom:1px solid var(--c-border)">
<div style="font-weight:600;margin-bottom:2px">${UI.icon('arrow-square-out')} App empfehlen</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
Lade Freunde ein jede erfolgreiche Einladung wird in deinem Profil angezeigt.
</div>
</div>
<div id="referral-body" style="padding:var(--space-4)">Lade</div>
</div>
<!-- App installieren --> <!-- App installieren -->
<div class="card" style="margin-bottom:var(--space-4)"> <div class="card" style="margin-bottom:var(--space-4)">
<div style="padding:var(--space-3) var(--space-4); <div style="padding:var(--space-3) var(--space-4);
@ -470,6 +481,44 @@ window.Page_settings = (() => {
? 'Pocket-Modus aktiviert — Bildschirm bleibt bei Aufzeichnung an.' ? 'Pocket-Modus aktiviert — Bildschirm bleibt bei Aufzeichnung an.'
: 'Pocket-Modus deaktiviert.'); : 'Pocket-Modus deaktiviert.');
}); });
_loadReferral();
}
// ----------------------------------------------------------
// REFERRAL — Einladungslink laden und rendern
// ----------------------------------------------------------
async function _loadReferral() {
const el = document.getElementById('referral-body');
if (!el) return;
try {
const r = await API.auth.referral();
el.innerHTML = `
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-3)">
<div style="flex:1;background:var(--c-surface-2);border-radius:var(--radius-md);
padding:var(--space-2) var(--space-3);font-family:monospace;font-size:var(--text-sm);
overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${r.link}</div>
<button class="btn btn-primary btn-sm" id="ref-share-btn">${UI.icon('arrow-square-out')} Teilen</button>
</div>
<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
${UI.icon('users')} <strong>${r.count}</strong> ${r.count === 1 ? 'Person' : 'Personen'} über deinen Link registriert
</div>
${r.count > 0 ? `
<div style="margin-top:var(--space-2);display:flex;gap:var(--space-2);flex-wrap:wrap">
${r.count >= 1 ? `<span class="badge badge-primary">${UI.icon('star')} Botschafter</span>` : ''}
${r.count >= 5 ? `<span class="badge badge-primary">${UI.icon('star')} Super-Botschafter</span>` : ''}
${r.count >= 10 ? `<span class="badge badge-primary">${UI.icon('star')} Top-Botschafter</span>` : ''}
</div>` : ''}
`;
document.getElementById('ref-share-btn')?.addEventListener('click', async () => {
if (navigator.share) {
navigator.share({ title: 'Ban Yaro — Die Hunde-App', text: 'Schau dir Ban Yaro an!', url: r.link }).catch(() => {});
} else {
await navigator.clipboard.writeText(r.link);
UI.toast.success('Link kopiert!');
}
});
} catch { el.innerHTML = '<p style="color:var(--c-text-muted)">Nicht verfügbar.</p>'; }
} }
// ---------------------------------------------------------- // ----------------------------------------------------------
@ -662,8 +711,10 @@ window.Page_settings = (() => {
} }
await UI.asyncButton(btn, async () => { await UI.asyncButton(btn, async () => {
const result = await API.auth.register(fd.email, fd.password, fd.name.trim()); const refCode = sessionStorage.getItem('by_ref_code') || '';
const result = await API.auth.register(fd.email, fd.password, fd.name.trim(), refCode || undefined);
localStorage.setItem('by_token', result.token); localStorage.setItem('by_token', result.token);
if (refCode) sessionStorage.removeItem('by_ref_code');
_appState.user = await API.auth.me(); _appState.user = await API.auth.me();
document.getElementById('sidebar-username').textContent = _appState.user.name; document.getElementById('sidebar-username').textContent = _appState.user.name;

View file

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