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
|
|
@ -87,7 +87,7 @@ def get_current_user(
|
|||
user_id = int(payload["sub"])
|
||||
with db() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason, is_social_media, notes_ki_enabled, breeder_status FROM users WHERE id=?",
|
||||
"SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason, is_social_media, notes_ki_enabled, breeder_status, is_founder, is_partner FROM users WHERE id=?",
|
||||
(user_id,)
|
||||
).fetchone()
|
||||
|
||||
|
|
|
|||
|
|
@ -559,6 +559,9 @@ def _migrate(conn_factory):
|
|||
("users", "ki_zucht_paarung", "INTEGER NOT NULL DEFAULT 1"),
|
||||
("users", "ki_zucht_beschreibung", "INTEGER NOT NULL DEFAULT 1"),
|
||||
("users", "ki_zucht_jahresbericht", "INTEGER NOT NULL DEFAULT 1"),
|
||||
# Partner-Code + Gründer-Lizenz
|
||||
("users", "is_founder", "INTEGER NOT NULL DEFAULT 0"),
|
||||
("users", "is_partner", "INTEGER NOT NULL DEFAULT 0"),
|
||||
]
|
||||
with conn_factory() as conn:
|
||||
for table, column, col_type in migrations:
|
||||
|
|
@ -1485,6 +1488,25 @@ def _migrate(conn_factory):
|
|||
CREATE INDEX IF NOT EXISTS idx_bj_user ON breeder_jahresberichte(user_id, jahr DESC);
|
||||
""")
|
||||
|
||||
# Partner-Codes (Influencer-Kooperationen + Gründer-Lizenz)
|
||||
try:
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS partner_codes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
code TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
label TEXT NOT NULL,
|
||||
grants_founder INTEGER NOT NULL DEFAULT 1,
|
||||
max_uses INTEGER DEFAULT NULL,
|
||||
uses INTEGER NOT NULL DEFAULT 0,
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_partner_codes_code ON partner_codes(code);
|
||||
""")
|
||||
logger.info("Migration: partner_codes Tabelle bereit.")
|
||||
except Exception as e:
|
||||
logger.warning(f"Migration partner_codes: {e}")
|
||||
|
||||
# js_exercise_id zu training_exercises — verbindet training_exercises mit exercise_progress
|
||||
existing_te = [row[1] for row in conn.execute("PRAGMA table_info(training_exercises)").fetchall()]
|
||||
if 'js_exercise_id' not in existing_te:
|
||||
|
|
|
|||
|
|
@ -162,6 +162,7 @@ from routes.breeder_photos import router as breeder_photos_router
|
|||
from routes.zucht_hunde import router as zucht_hunde_router
|
||||
from routes.breeder_export import router as breeder_export_router
|
||||
from routes.zucht_ki import router as zucht_ki_router
|
||||
from routes.partner import router as partner_router
|
||||
|
||||
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
|
||||
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
|
||||
|
|
@ -193,6 +194,7 @@ app.include_router(breeder_photos_router, prefix="/api", tags=["Züchter-
|
|||
app.include_router(zucht_hunde_router, prefix="/api", tags=["Zuchtkartei"])
|
||||
app.include_router(breeder_export_router, prefix="/api", tags=["Export"])
|
||||
app.include_router(zucht_ki_router, prefix="/api", tags=["Züchter-KI"])
|
||||
app.include_router(partner_router, prefix="/api", tags=["Partner"])
|
||||
app.include_router(webcal_router, prefix="/api/webcal", tags=["WebCal"])
|
||||
app.include_router(profile_router, prefix="/api/profile", tags=["Profil"])
|
||||
app.include_router(import_router, prefix="/api/import", tags=["Import"])
|
||||
|
|
|
|||
|
|
@ -78,13 +78,36 @@ async def register(data: RegisterRequest, response: Response, request: Request):
|
|||
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)
|
||||
code_upper = data.ref_code.strip().upper()
|
||||
# Zuerst prüfen ob es ein Partner-Code ist
|
||||
partner = conn.execute(
|
||||
"SELECT id, grants_founder, max_uses, uses FROM partner_codes WHERE code=?",
|
||||
(code_upper,)
|
||||
).fetchone()
|
||||
if referrer:
|
||||
conn.execute("UPDATE users SET referred_by=? WHERE id=?",
|
||||
(referrer['id'], new_user_id))
|
||||
if partner:
|
||||
# Nur einlösen wenn max_uses nicht erreicht
|
||||
if partner["max_uses"] is None or partner["uses"] < partner["max_uses"]:
|
||||
conn.execute(
|
||||
"UPDATE partner_codes SET uses=uses+1 WHERE id=?",
|
||||
(partner["id"],)
|
||||
)
|
||||
updates = {"referred_by": -partner["id"]}
|
||||
if partner["grants_founder"]:
|
||||
updates["is_founder"] = 1
|
||||
set_clause = ", ".join(f"{k}=?" for k in updates)
|
||||
conn.execute(
|
||||
f"UPDATE users SET {set_clause} WHERE id=?",
|
||||
(*updates.values(), new_user_id)
|
||||
)
|
||||
else:
|
||||
# Fallback: Referral-Code eines anderen Users
|
||||
referrer = conn.execute(
|
||||
"SELECT id FROM users WHERE referral_code=? AND id != ?",
|
||||
(code_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"])
|
||||
_set_cookie(response, token)
|
||||
|
|
|
|||
136
backend/routes/partner.py
Normal file
136
backend/routes/partner.py
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
"""BAN YARO — Partner-Codes + Gründer-Lizenz"""
|
||||
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from pydantic import BaseModel
|
||||
from database import db
|
||||
from auth import require_admin, get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class PartnerCodeCreate(BaseModel):
|
||||
code: str
|
||||
label: str
|
||||
grants_founder: int = 1
|
||||
max_uses: Optional[int] = None
|
||||
|
||||
|
||||
class GrantRequest(BaseModel):
|
||||
is_founder: Optional[int] = None
|
||||
is_partner: Optional[int] = None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Admin: Partner-Codes verwalten
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@router.get("/admin/partner/codes")
|
||||
def list_partner_codes(user=Depends(require_admin)):
|
||||
"""Alle Partner-Codes mit Stats (admin only)."""
|
||||
with db() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT pc.id, pc.code, pc.label, pc.grants_founder,
|
||||
pc.max_uses, pc.uses, pc.created_at,
|
||||
u.name AS created_by_name
|
||||
FROM partner_codes pc
|
||||
LEFT JOIN users u ON u.id = pc.created_by
|
||||
ORDER BY pc.created_at DESC"""
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@router.post("/admin/partner/codes", status_code=201)
|
||||
def create_partner_code(data: PartnerCodeCreate, user=Depends(require_admin)):
|
||||
"""Neuen Partner-Code erstellen (admin only)."""
|
||||
code = data.code.strip().upper()
|
||||
if not code:
|
||||
raise HTTPException(400, "Code darf nicht leer sein.")
|
||||
with db() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM partner_codes WHERE code=?", (code,)
|
||||
).fetchone()
|
||||
if existing:
|
||||
raise HTTPException(400, "Dieser Code existiert bereits.")
|
||||
conn.execute(
|
||||
"""INSERT INTO partner_codes (code, label, grants_founder, max_uses, created_by)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
(code, data.label.strip(), data.grants_founder, data.max_uses, user["id"])
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM partner_codes WHERE code=?", (code,)
|
||||
).fetchone()
|
||||
return dict(row)
|
||||
|
||||
|
||||
@router.delete("/admin/partner/codes/{code_id}", status_code=204)
|
||||
def delete_partner_code(code_id: int, user=Depends(require_admin)):
|
||||
"""Partner-Code löschen (admin only)."""
|
||||
with db() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM partner_codes WHERE id=?", (code_id,)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(404, "Partner-Code nicht gefunden.")
|
||||
conn.execute("DELETE FROM partner_codes WHERE id=?", (code_id,))
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/admin/partner/users/{user_id}/grant")
|
||||
def grant_user_status(user_id: int, data: GrantRequest, user=Depends(require_admin)):
|
||||
"""Founder- und/oder Partner-Status für einen User setzen (admin only)."""
|
||||
updates = {}
|
||||
if data.is_founder is not None:
|
||||
updates["is_founder"] = data.is_founder
|
||||
if data.is_partner is not None:
|
||||
updates["is_partner"] = data.is_partner
|
||||
if not updates:
|
||||
raise HTTPException(400, "Mindestens is_founder oder is_partner muss angegeben werden.")
|
||||
with db() as conn:
|
||||
target = conn.execute("SELECT id FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
if not target:
|
||||
raise HTTPException(404, "User nicht gefunden.")
|
||||
set_clause = ", ".join(f"{k}=?" for k in updates)
|
||||
conn.execute(
|
||||
f"UPDATE users SET {set_clause} WHERE id=?",
|
||||
(*updates.values(), user_id)
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT id, name, email, is_founder, is_partner FROM users WHERE id=?",
|
||||
(user_id,)
|
||||
).fetchone()
|
||||
return dict(row)
|
||||
|
||||
|
||||
@router.get("/admin/users/search")
|
||||
def search_users(q: str, user=Depends(require_admin)):
|
||||
"""User-Suche für Admin (Name-Präfix, max. 10 Ergebnisse)."""
|
||||
with db() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT id, name, email, is_founder, is_partner, rolle
|
||||
FROM users WHERE name LIKE ? COLLATE NOCASE
|
||||
ORDER BY name LIMIT 10""",
|
||||
(f"{q}%",)
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Öffentlich: Code-Info für Registrierungsseite
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@router.get("/partner/codes/{code}/info")
|
||||
def partner_code_info(code: str):
|
||||
"""Gibt zurück ob ein Partner-Code existiert und dessen Label (öffentlich)."""
|
||||
with db() as conn:
|
||||
row = conn.execute(
|
||||
"""SELECT code, label, grants_founder, max_uses, uses
|
||||
FROM partner_codes WHERE code=?""",
|
||||
(code.strip().upper(),)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Partner-Code nicht gefunden.")
|
||||
r = dict(row)
|
||||
# Einlösbar?
|
||||
r["redeemable"] = r["max_uses"] is None or r["uses"] < r["max_uses"]
|
||||
return r
|
||||
|
|
@ -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