Feature: Referral-Rabattstufen — 10→20%, 20→30%, 50→50% lebenslang

- auth.py: _referral_tier() + _referral_next() Tier-Logik, GET /api/auth/referral gibt discount_pct + next_tier zurück
- settings.js: Referral-UI komplett neu — Tier-Kacheln, Fortschrittsbalken, Zähler, Rabatt-Hinweis
- SW by-v517, APP_VER 494
This commit is contained in:
rene 2026-04-29 21:34:23 +02:00
parent ab41af470d
commit e7e4adaa70
4 changed files with 88 additions and 29 deletions

View file

@ -149,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:
@ -162,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),
}

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '493'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '494'; // ← 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';

View file

@ -1100,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 = `
<!-- Tier-Übersicht -->
<div style="display:flex;gap:var(--space-2);margin-bottom:var(--space-4)">
${TIERS.map(({t, d}) => {
const reached = r.count >= t;
return `<div style="flex:1;padding:var(--space-2) var(--space-1);border-radius:var(--radius-md);
text-align:center;border:2px solid ${reached ? '#7c3aed' : 'var(--c-border)'};
background:${reached ? 'rgba(124,58,237,.08)' : 'var(--c-surface-2)'}">
<div style="font-size:var(--text-lg);font-weight:800;color:${reached ? '#7c3aed' : 'var(--c-text-muted)'}">
${d}%
</div>
<div style="font-size:10px;color:var(--c-text-muted)">ab ${t} Freunden</div>
${reached ? `<div style="font-size:10px;font-weight:700;color:#7c3aed">✓ Erreicht</div>` : ''}
</div>`;
}).join('')}
</div>
<!-- Zähler + Fortschritt -->
<div style="margin-bottom:var(--space-4)">
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:var(--space-1)">
<span style="font-size:var(--text-sm);font-weight:600">
${UI.icon('users')} <strong>${r.count}</strong> ${r.count === 1 ? 'Freund geworben' : 'Freunde geworben'}
</span>
${currentTier > 0 ? `<span style="font-size:var(--text-xs);font-weight:700;color:#7c3aed">${currentTier}% Rabatt aktiv</span>` : ''}
</div>
<div style="background:var(--c-surface-2);border-radius:var(--radius-full);height:10px;overflow:hidden">
<div style="background:linear-gradient(90deg,#7c3aed,#a855f7);width:${barPct}%;height:100%;
border-radius:var(--radius-full);transition:width .5s ease"></div>
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-1)">${barLabel}</div>
</div>
<!-- Link + QR -->
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-3)">
<div style="flex:1;min-width:0;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>
<!-- QR-Code -->
<div style="display:flex;justify-content:center;margin-bottom:var(--space-4)">
<div style="position:relative;width:160px;height:160px">
<div id="ref-qr" style="width:160px;height:160px;border-radius:var(--radius-md);overflow:hidden"></div>
<div style="display:flex;justify-content:center;margin-bottom:var(--space-1)">
<div style="position:relative;width:140px;height:140px">
<div id="ref-qr" style="width:140px;height:140px;border-radius:var(--radius-md);overflow:hidden"></div>
<img src="/icons/icon-180.png" alt=""
style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);
width:36px;height:36px;border-radius:8px;border:2px solid #fff">
width:32px;height:32px;border-radius:7px;border:2px solid #fff">
</div>
</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>` : ''}
<p style="font-size:var(--text-xs);color:var(--c-text-muted);text-align:center;margin:0">
Der Rabatt gilt für dich sobald Bezahlfunktionen aktiv sind, dauerhaft und automatisch.
</p>
`;
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 = ''; }

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache
============================================================ */
const CACHE_VERSION = 'by-v516';
const CACHE_VERSION = 'by-v517';
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