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);
""")
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"""
import os
import secrets
import string
from typing import Optional
from fastapi import APIRouter, HTTPException, Response, Depends
from pydantic import BaseModel, EmailStr
from database import db
@ -20,6 +25,12 @@ class RegisterRequest(BaseModel):
email: EmailStr
password: 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):
@ -45,10 +56,11 @@ async def register(data: RegisterRequest, response: Response):
"SELECT 1 FROM users WHERE name=? COLLATE NOCASE", (name,)
).fetchone():
raise HTTPException(400, "Dieser Name ist bereits vergeben. Bitte wähle einen anderen.")
code = _gen_referral_code()
try:
conn.execute(
"INSERT INTO users (email, pw_hash, name) VALUES (?,?,?)",
(data.email, hash_password(data.password), name)
"INSERT INTO users (email, pw_hash, name, referral_code) VALUES (?,?,?,?)",
(data.email, hash_password(data.password), name, code)
)
except Exception:
# Fallback falls UNIQUE-Index greift (Race Condition)
@ -56,6 +68,16 @@ async def register(data: RegisterRequest, response: Response):
user = conn.execute(
"SELECT id, rolle FROM users WHERE email=?", (data.email,)
).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"])
_set_cookie(response, token)
@ -90,6 +112,21 @@ async def logout(response: Response):
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")
async def me(user=Depends(get_current_user)):
with db() as conn:

View file

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

View file

@ -3,7 +3,7 @@
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 = (() => {
@ -687,6 +687,15 @@ const App = (() => {
// INITIALISIERUNG
// ----------------------------------------------------------
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();
await _checkAuth();

View file

@ -227,6 +227,17 @@ window.Page_settings = (() => {
</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 -->
<div class="card" style="margin-bottom: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 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 () => {
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);
if (refCode) sessionStorage.removeItem('by_ref_code');
_appState.user = await API.auth.me();
document.getElementById('sidebar-username').textContent = _appState.user.name;

View file

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