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:
parent
ab41af470d
commit
e7e4adaa70
4 changed files with 88 additions and 29 deletions
|
|
@ -149,6 +149,24 @@ async def logout(response: Response):
|
||||||
return {"ok": True}
|
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")
|
@router.get("/referral")
|
||||||
async def get_referral_info(user=Depends(get_current_user)):
|
async def get_referral_info(user=Depends(get_current_user)):
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
|
|
@ -162,11 +180,14 @@ async def get_referral_info(user=Depends(get_current_user)):
|
||||||
if not code:
|
if not code:
|
||||||
code = _gen_referral_code()
|
code = _gen_referral_code()
|
||||||
conn.execute("UPDATE users SET referral_code=? WHERE id=?", (code, user['id']))
|
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 {
|
return {
|
||||||
"code": code,
|
"code": code,
|
||||||
"count": row["count"] if row else 0,
|
"count": count,
|
||||||
"link": f"{base}/?ref={code}",
|
"link": f"{base}/?ref={code}",
|
||||||
|
"discount_pct": _referral_tier(count),
|
||||||
|
"next_tier": _referral_next(count),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Router, State-Management, Navigation, Initialisierung.
|
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 APP_VERSION = '1.0.0'; // ← semantische Version, wird bei make release gesetzt
|
||||||
const IS_STAGING = location.hostname === 'staging.banyaro.app';
|
const IS_STAGING = location.hostname === 'staging.banyaro.app';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1100,50 +1100,88 @@ window.Page_settings = (() => {
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
try {
|
try {
|
||||||
const r = await API.auth.referral();
|
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 = `
|
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="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);
|
<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);
|
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>
|
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>
|
<button class="btn btn-primary btn-sm" id="ref-share-btn">${UI.icon('arrow-square-out')} Teilen</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="display:flex;justify-content:center;margin-bottom:var(--space-1)">
|
||||||
<!-- QR-Code -->
|
<div style="position:relative;width:140px;height:140px">
|
||||||
<div style="display:flex;justify-content:center;margin-bottom:var(--space-4)">
|
<div id="ref-qr" style="width:140px;height:140px;border-radius:var(--radius-md);overflow:hidden"></div>
|
||||||
<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>
|
|
||||||
<img src="/icons/icon-180.png" alt=""
|
<img src="/icons/icon-180.png" alt=""
|
||||||
style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);
|
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>
|
</div>
|
||||||
|
<p style="font-size:var(--text-xs);color:var(--c-text-muted);text-align:center;margin:0">
|
||||||
<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
|
Der Rabatt gilt für dich — sobald Bezahlfunktionen aktiv sind, dauerhaft und automatisch.
|
||||||
${UI.icon('users')} <strong>${r.count}</strong> ${r.count === 1 ? 'Person' : 'Personen'} über deinen Link registriert
|
</p>
|
||||||
</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 () => {
|
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) {
|
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 {
|
} else {
|
||||||
await navigator.clipboard.writeText(r.link);
|
await navigator.clipboard.writeText(r.link);
|
||||||
UI.toast.success('Link kopiert!');
|
UI.toast.success('Link kopiert!');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// QR-Code rendern (Bibliothek lazy laden)
|
|
||||||
await App.loadScript('/js/qrcode.min.js');
|
await App.loadScript('/js/qrcode.min.js');
|
||||||
new QRCode(document.getElementById('ref-qr'), {
|
new QRCode(document.getElementById('ref-qr'), {
|
||||||
text: r.link,
|
text: r.link, width: 140, height: 140,
|
||||||
width: 160,
|
colorDark: '#000000', colorLight: '#ffffff',
|
||||||
height: 160,
|
|
||||||
colorDark: '#000000',
|
|
||||||
colorLight: '#ffffff',
|
|
||||||
correctLevel: QRCode.CorrectLevel.H,
|
correctLevel: QRCode.CorrectLevel.H,
|
||||||
});
|
});
|
||||||
} catch { el.innerHTML = ''; }
|
} catch { el.innerHTML = ''; }
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Offline-Cache + Push Notifications + Tile-Cache
|
Offline-Cache + Push Notifications + Tile-Cache
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const CACHE_VERSION = 'by-v516';
|
const CACHE_VERSION = 'by-v517';
|
||||||
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
|
||||||
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache
|
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue