Feature: Partner-Codes + Gründer-Lizenz-System für Influencer-Kooperationen

- partner_codes Tabelle: Code, Label, max_uses, grants_founder, uses-Counter
- users: is_founder + is_partner Flags (DB-Migration + auth.py SELECT)
- Registrierung: Partner-Code löst Gründer-Lizenz aus (vor User-Referral geprüft)
- API: GET/POST/DELETE /api/admin/partner/codes, POST /api/admin/partner/users/{id}/grant
- API: GET /api/partner/codes/{code}/info (öffentlich, für Registrierungsvalidierung)
- API: GET /api/admin/users/search (Name-Suche für Admin-UI)
- Admin-Tab "Partner & Codes": Code anlegen, Stats, User-Status vergeben
- Registrierungsformular: Einladungscode-Feld mit Live-Validierung
- Settings: Gründer (lila) + Partner (blau) Badge neben Kostenlos/Plus
- SW by-v515, APP_VER 492
This commit is contained in:
rene 2026-04-29 21:20:16 +02:00
parent 810c1a79dc
commit e57c6db013
9 changed files with 469 additions and 20 deletions

View file

@ -124,14 +124,21 @@ window.Page_settings = (() => {
<div>
<div style="font-weight:700;font-size:var(--text-lg)">${_esc(u.name)}</div>
<div style="color:var(--c-text-secondary);font-size:var(--text-sm)">${_esc(u.email)}</div>
${u.is_premium
? `<span class="badge badge-primary" style="margin-top:var(--space-1)">
<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#star"></use></svg> Ban Yaro Plus
</span>`
: `<span class="badge" style="margin-top:var(--space-1);
color:var(--c-text-secondary)">
Kostenlos
</span>`}
<div style="display:flex;flex-wrap:wrap;gap:var(--space-1);margin-top:var(--space-1)">
${u.is_premium
? `<span class="badge badge-primary">
<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#star"></use></svg> Ban Yaro Plus
</span>`
: `<span class="badge" style="color:var(--c-text-secondary)">Kostenlos</span>`}
${u.is_founder
? `<span class="badge" style="background:#7c3aed;color:#fff">
<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#key"></use></svg> Gründer
</span>` : ''}
${u.is_partner
? `<span class="badge" style="background:#0ea5e9;color:#fff">
<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#handshake"></use></svg> Partner
</span>` : ''}
</div>
</div>
</div>
</div>
@ -1283,6 +1290,16 @@ window.Page_settings = (() => {
</div>
</div>
</div>
<div class="form-group" style="margin-top:var(--space-2)">
<label class="form-label" style="font-size:var(--text-xs)">
Einladungscode <span style="color:var(--c-text-muted);font-weight:400">(optional)</span>
</label>
<input class="form-control" type="text" name="partner_code" id="reg-partner-code"
placeholder="z. B. HUNDEBLOG" autocomplete="off"
style="text-transform:uppercase;font-family:monospace;letter-spacing:.08em">
<div id="reg-partner-hint" style="display:none;margin-top:var(--space-1);font-size:var(--text-xs);
padding:var(--space-2) var(--space-3);border-radius:var(--radius-sm)"></div>
</div>
<button type="submit" class="btn btn-primary w-full" style="margin-top:var(--space-2)">
Konto erstellen
</button>
@ -1375,6 +1392,44 @@ window.Page_settings = (() => {
});
}
// Partner-Code live validieren
const partnerInput = document.getElementById('reg-partner-code');
const partnerHint = document.getElementById('reg-partner-hint');
let _partnerValid = false;
if (partnerInput) {
// Vorausfüllen falls via sessionStorage gesetzt
const stored = sessionStorage.getItem('by_ref_code') || '';
if (stored) partnerInput.value = stored;
let _debounce = null;
partnerInput.addEventListener('input', () => {
const code = partnerInput.value.trim().toUpperCase();
partnerInput.value = code;
clearTimeout(_debounce);
partnerHint.style.display = 'none';
_partnerValid = false;
if (code.length < 3) return;
_debounce = setTimeout(async () => {
try {
const info = await API.get(`/api/partner/codes/${encodeURIComponent(code)}/info`);
if (info.redeemable) {
partnerHint.textContent = info.grants_founder
? `✓ Gültiger Code von "${info.label}" — du erhältst eine lebenslange Gründer-Lizenz!`
: `✓ Gültiger Einladungscode von "${info.label}"`;
partnerHint.style.cssText = 'display:block;background:var(--c-success-bg,#f0fdf4);color:var(--c-success,#16a34a);padding:var(--space-2) var(--space-3);border-radius:var(--radius-sm);font-size:var(--text-xs)';
_partnerValid = true;
} else {
partnerHint.textContent = 'Dieser Code ist bereits vollständig eingelöst.';
partnerHint.style.cssText = 'display:block;background:#fef2f2;color:#dc2626;padding:var(--space-2) var(--space-3);border-radius:var(--radius-sm);font-size:var(--text-xs)';
}
} catch {
partnerHint.textContent = 'Code nicht gefunden.';
partnerHint.style.cssText = 'display:block;color:var(--c-text-muted);font-size:var(--text-xs)';
}
}, 500);
});
}
document.getElementById('auth-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = e.target.querySelector('[type="submit"]');
@ -1390,8 +1445,10 @@ window.Page_settings = (() => {
}
await UI.asyncButton(btn, async () => {
const refCode = sessionStorage.getItem('by_ref_code') || '';
const result = await API.auth.register(fd.email, fd.password, fd.name.trim(), refCode || undefined);
const partnerCode = (fd.partner_code || '').trim().toUpperCase() || undefined;
const refCode = sessionStorage.getItem('by_ref_code') || '';
const finalCode = partnerCode || refCode || undefined;
const result = await API.auth.register(fd.email, fd.password, fd.name.trim(), finalCode);
localStorage.setItem('by_token', result.token);
if (refCode) sessionStorage.removeItem('by_ref_code');
@ -1401,7 +1458,10 @@ window.Page_settings = (() => {
_appState.activeDog = null;
document.getElementById('header-login-btn')?.remove();
UI.toast.success(`Willkommen bei Ban Yaro, ${_appState.user.name}!`);
const greeting = _appState.user.is_founder
? `Willkommen, Gründer ${_appState.user.name}! 🎉`
: `Willkommen bei Ban Yaro, ${_appState.user.name}!`;
UI.toast.success(greeting);
App.showOnboarding();
});
});