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:
parent
810c1a79dc
commit
e57c6db013
9 changed files with 469 additions and 20 deletions
|
|
@ -3,7 +3,7 @@
|
|||
Router, State-Management, Navigation, Initialisierung.
|
||||
============================================================ */
|
||||
|
||||
const APP_VER = '491'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||
const APP_VER = '492'; // ← 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';
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ window.Page_admin = (() => {
|
|||
{ id: 'analytics', label: 'Analytics', icon: 'target' },
|
||||
{ id: 'system', label: 'System', icon: 'gear' },
|
||||
{ id: 'jobs', label: 'Jobs', icon: 'clock' },
|
||||
{ id: 'partner', label: 'Partner & Codes', icon: 'handshake' },
|
||||
{ id: 'audit', label: 'Audit-Log', icon: 'clipboard-text' },
|
||||
];
|
||||
|
||||
|
|
@ -88,6 +89,7 @@ window.Page_admin = (() => {
|
|||
case 'analytics': await _renderAnalytics(el); break;
|
||||
case 'system': await _renderSystem(el); break;
|
||||
case 'jobs': await _renderJobs(el); break;
|
||||
case 'partner': await _renderPartner(el); break;
|
||||
case 'audit': await _renderAudit(el); break;
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
@ -1789,6 +1791,210 @@ window.Page_admin = (() => {
|
|||
// ------------------------------------------------------------------
|
||||
// TAB: AUDIT-LOG
|
||||
// ------------------------------------------------------------------
|
||||
async function _renderPartner(el) {
|
||||
const [codes] = await Promise.all([
|
||||
API.get('/api/admin/partner/codes'),
|
||||
]);
|
||||
|
||||
el.innerHTML = `
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-5)">
|
||||
|
||||
<!-- Neuen Code anlegen -->
|
||||
<div class="by-card" style="padding:var(--space-4)">
|
||||
<h3 style="margin:0 0 var(--space-3);font-size:var(--text-base)">Neuen Partner-Code erstellen</h3>
|
||||
<form id="adm-partner-create" style="display:flex;flex-direction:column;gap:var(--space-3)">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||
<div>
|
||||
<label class="form-label" style="font-size:var(--text-xs)">Code</label>
|
||||
<input class="form-control" name="code" placeholder="z. B. HUNDEBLOG"
|
||||
style="text-transform:uppercase;font-family:monospace;letter-spacing:.08em" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" style="font-size:var(--text-xs)">Bezeichnung</label>
|
||||
<input class="form-control" name="label" placeholder="z. B. Max Musterhund (Instagram)" required>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3);align-items:center">
|
||||
<div>
|
||||
<label class="form-label" style="font-size:var(--text-xs)">Max. Einlösungen <span style="color:var(--c-text-muted)">(leer = unbegrenzt)</span></label>
|
||||
<input class="form-control" name="max_uses" type="number" min="1" placeholder="∞">
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:var(--space-2);padding-top:var(--space-5)">
|
||||
<input type="checkbox" id="adm-grants-founder" name="grants_founder" checked
|
||||
style="width:16px;height:16px;accent-color:var(--c-primary)">
|
||||
<label for="adm-grants-founder" style="font-size:var(--text-sm);cursor:pointer">
|
||||
Gründer-Lizenz (lebenslang kostenlos)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm" style="align-self:flex-start">
|
||||
${UI.icon('plus')} Code erstellen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Aktive Codes -->
|
||||
<div class="by-card" style="padding:var(--space-4)">
|
||||
<h3 style="margin:0 0 var(--space-3);font-size:var(--text-base)">Aktive Codes</h3>
|
||||
<div id="adm-partner-codes-list">
|
||||
${codes.length === 0
|
||||
? `<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Noch keine Partner-Codes angelegt.</p>`
|
||||
: `<table style="width:100%;border-collapse:collapse;font-size:var(--text-sm)">
|
||||
<thead>
|
||||
<tr style="border-bottom:1px solid var(--c-border)">
|
||||
<th style="text-align:left;padding:var(--space-2) var(--space-3);color:var(--c-text-muted);font-weight:600;font-size:var(--text-xs)">Code</th>
|
||||
<th style="text-align:left;padding:var(--space-2) var(--space-3);color:var(--c-text-muted);font-weight:600;font-size:var(--text-xs)">Bezeichnung</th>
|
||||
<th style="text-align:center;padding:var(--space-2) var(--space-3);color:var(--c-text-muted);font-weight:600;font-size:var(--text-xs)">Nutzungen</th>
|
||||
<th style="text-align:center;padding:var(--space-2) var(--space-3);color:var(--c-text-muted);font-weight:600;font-size:var(--text-xs)">Gründer</th>
|
||||
<th style="padding:var(--space-2) var(--space-3)"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${codes.map(c => `
|
||||
<tr style="border-bottom:1px solid var(--c-border)" data-code-id="${c.id}">
|
||||
<td style="padding:var(--space-2) var(--space-3)">
|
||||
<code style="font-weight:700;color:var(--c-primary);letter-spacing:.08em">${c.code}</code>
|
||||
</td>
|
||||
<td style="padding:var(--space-2) var(--space-3);color:var(--c-text)">${c.label}</td>
|
||||
<td style="padding:var(--space-2) var(--space-3);text-align:center;font-weight:600">
|
||||
${c.uses}${c.max_uses ? `/${c.max_uses}` : ''}
|
||||
</td>
|
||||
<td style="padding:var(--space-2) var(--space-3);text-align:center">
|
||||
${c.grants_founder ? '✓' : '—'}
|
||||
</td>
|
||||
<td style="padding:var(--space-2) var(--space-3)">
|
||||
<button class="btn btn-ghost btn-sm adm-del-code" data-id="${c.id}"
|
||||
style="color:var(--c-danger,#dc2626);font-size:var(--text-xs)">
|
||||
${UI.icon('trash')} Löschen
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User-Status vergeben -->
|
||||
<div class="by-card" style="padding:var(--space-4)">
|
||||
<h3 style="margin:0 0 var(--space-3);font-size:var(--text-base)">Nutzer-Status manuell vergeben</h3>
|
||||
<form id="adm-partner-grant" style="display:flex;flex-direction:column;gap:var(--space-3)">
|
||||
<div>
|
||||
<label class="form-label" style="font-size:var(--text-xs)">User-ID oder Benutzername</label>
|
||||
<input class="form-control" name="user_search" id="adm-grant-search"
|
||||
placeholder="Name eingeben…" autocomplete="off">
|
||||
<div id="adm-grant-result" style="margin-top:var(--space-2)"></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:var(--space-4)">
|
||||
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer;font-size:var(--text-sm)">
|
||||
<input type="checkbox" name="is_founder" value="1"
|
||||
style="width:16px;height:16px;accent-color:var(--c-primary)">
|
||||
Gründer-Lizenz
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer;font-size:var(--text-sm)">
|
||||
<input type="checkbox" name="is_partner" value="1"
|
||||
style="width:16px;height:16px;accent-color:var(--c-primary)">
|
||||
Partner-Badge (Creator)
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-secondary btn-sm" style="align-self:flex-start">
|
||||
${UI.icon('check')} Status setzen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Code erstellen
|
||||
el.querySelector('#adm-partner-create')?.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const btn = e.target.querySelector('[type="submit"]');
|
||||
const fd = UI.formData(e.target);
|
||||
const code = (fd.code || '').trim().toUpperCase();
|
||||
if (!code) return;
|
||||
await UI.asyncButton(btn, async () => {
|
||||
await API.post('/api/admin/partner/codes', {
|
||||
code,
|
||||
label: fd.label || code,
|
||||
grants_founder: e.target.querySelector('[name="grants_founder"]').checked ? 1 : 0,
|
||||
max_uses: fd.max_uses ? parseInt(fd.max_uses) : null,
|
||||
});
|
||||
UI.toast.success(`Code "${code}" erstellt.`);
|
||||
await _renderPartner(el);
|
||||
});
|
||||
});
|
||||
|
||||
// Code löschen
|
||||
el.querySelectorAll('.adm-del-code').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!window.confirm(`Code wirklich löschen?`)) return;
|
||||
const id = btn.dataset.id;
|
||||
await UI.asyncButton(btn, async () => {
|
||||
await API.del(`/api/admin/partner/codes/${id}`);
|
||||
UI.toast.success('Code gelöscht.');
|
||||
await _renderPartner(el);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// User suchen für Status-Vergabe
|
||||
let _grantUserId = null;
|
||||
const searchInput = el.querySelector('#adm-grant-search');
|
||||
const grantResult = el.querySelector('#adm-grant-result');
|
||||
let _searchTimeout = null;
|
||||
searchInput?.addEventListener('input', () => {
|
||||
clearTimeout(_searchTimeout);
|
||||
_grantUserId = null;
|
||||
const q = searchInput.value.trim();
|
||||
if (q.length < 2) { grantResult.innerHTML = ''; return; }
|
||||
_searchTimeout = setTimeout(async () => {
|
||||
try {
|
||||
const users = await API.get(`/api/admin/users/search?q=${encodeURIComponent(q)}`);
|
||||
if (!users.length) {
|
||||
grantResult.innerHTML = `<p style="font-size:var(--text-xs);color:var(--c-text-muted)">Kein User gefunden.</p>`;
|
||||
return;
|
||||
}
|
||||
grantResult.innerHTML = users.map(u => `
|
||||
<div class="adm-grant-user" data-id="${u.id}" data-name="${u.name}"
|
||||
style="padding:var(--space-2) var(--space-3);border-radius:var(--radius-sm);
|
||||
cursor:pointer;background:var(--c-surface-2);margin-bottom:2px;
|
||||
font-size:var(--text-sm);display:flex;justify-content:space-between">
|
||||
<span><strong>${u.name}</strong></span>
|
||||
<span style="color:var(--c-text-muted);font-size:var(--text-xs)">
|
||||
${u.is_founder ? '⭐ Gründer ' : ''}${u.is_partner ? '🤝 Partner' : ''}
|
||||
</span>
|
||||
</div>
|
||||
`).join('');
|
||||
grantResult.querySelectorAll('.adm-grant-user').forEach(div => {
|
||||
div.addEventListener('click', () => {
|
||||
_grantUserId = parseInt(div.dataset.id);
|
||||
searchInput.value = div.dataset.name;
|
||||
grantResult.innerHTML = `<p style="font-size:var(--text-xs);color:var(--c-success,#16a34a)">✓ ${div.dataset.name} ausgewählt</p>`;
|
||||
});
|
||||
});
|
||||
} catch { grantResult.innerHTML = ''; }
|
||||
}, 400);
|
||||
});
|
||||
|
||||
el.querySelector('#adm-partner-grant')?.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
if (!_grantUserId) { UI.toast.warning('Bitte erst einen User auswählen.'); return; }
|
||||
const btn = e.target.querySelector('[type="submit"]');
|
||||
const isFounder = e.target.querySelector('[name="is_founder"]').checked ? 1 : 0;
|
||||
const isPartner = e.target.querySelector('[name="is_partner"]').checked ? 1 : 0;
|
||||
await UI.asyncButton(btn, async () => {
|
||||
const result = await API.post(`/api/admin/partner/users/${_grantUserId}/grant`, {
|
||||
is_founder: isFounder,
|
||||
is_partner: isPartner,
|
||||
});
|
||||
UI.toast.success(`Status für ${result.name} gesetzt.`);
|
||||
grantResult.innerHTML = `<p style="font-size:var(--text-xs);color:var(--c-success,#16a34a)">✓ Gründer: ${result.is_founder ? 'Ja' : 'Nein'} | Partner: ${result.is_partner ? 'Ja' : 'Nein'}</p>`;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function _renderAudit(el) {
|
||||
el.innerHTML = `
|
||||
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Offline-Cache + Push Notifications + Tile-Cache
|
||||
============================================================ */
|
||||
|
||||
const CACHE_VERSION = 'by-v514';
|
||||
const CACHE_VERSION = 'by-v515';
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue