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:
parent
82d9e26823
commit
6d757b86c2
6 changed files with 121 additions and 7 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue