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

@ -87,7 +87,7 @@ def get_current_user(
user_id = int(payload["sub"]) user_id = int(payload["sub"])
with db() as conn: with db() as conn:
row = conn.execute( 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,) (user_id,)
).fetchone() ).fetchone()

View file

@ -559,6 +559,9 @@ def _migrate(conn_factory):
("users", "ki_zucht_paarung", "INTEGER NOT NULL DEFAULT 1"), ("users", "ki_zucht_paarung", "INTEGER NOT NULL DEFAULT 1"),
("users", "ki_zucht_beschreibung", "INTEGER NOT NULL DEFAULT 1"), ("users", "ki_zucht_beschreibung", "INTEGER NOT NULL DEFAULT 1"),
("users", "ki_zucht_jahresbericht", "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: with conn_factory() as conn:
for table, column, col_type in migrations: 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); 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 # 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()] existing_te = [row[1] for row in conn.execute("PRAGMA table_info(training_exercises)").fetchall()]
if 'js_exercise_id' not in existing_te: if 'js_exercise_id' not in existing_te:

View file

@ -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.zucht_hunde import router as zucht_hunde_router
from routes.breeder_export import router as breeder_export_router from routes.breeder_export import router as breeder_export_router
from routes.zucht_ki import router as zucht_ki_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(auth_router, prefix="/api/auth", tags=["Auth"])
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) 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(zucht_hunde_router, prefix="/api", tags=["Zuchtkartei"])
app.include_router(breeder_export_router, prefix="/api", tags=["Export"]) 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(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(webcal_router, prefix="/api/webcal", tags=["WebCal"])
app.include_router(profile_router, prefix="/api/profile", tags=["Profil"]) app.include_router(profile_router, prefix="/api/profile", tags=["Profil"])
app.include_router(import_router, prefix="/api/import", tags=["Import"]) app.include_router(import_router, prefix="/api/import", tags=["Import"])

View file

@ -78,13 +78,36 @@ async def register(data: RegisterRequest, response: Response, request: Request):
new_user_id = user["id"] new_user_id = user["id"]
if data.ref_code: if data.ref_code:
referrer = conn.execute( code_upper = data.ref_code.strip().upper()
"SELECT id FROM users WHERE referral_code=? AND id != ?", # Zuerst prüfen ob es ein Partner-Code ist
(data.ref_code.strip().upper(), new_user_id) partner = conn.execute(
"SELECT id, grants_founder, max_uses, uses FROM partner_codes WHERE code=?",
(code_upper,)
).fetchone() ).fetchone()
if referrer: if partner:
conn.execute("UPDATE users SET referred_by=? WHERE id=?", # Nur einlösen wenn max_uses nicht erreicht
(referrer['id'], new_user_id)) 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"]) token = create_token(user["id"], user["rolle"])
_set_cookie(response, token) _set_cookie(response, token)

136
backend/routes/partner.py Normal file
View 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

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung. 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 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';

View file

@ -19,6 +19,7 @@ window.Page_admin = (() => {
{ id: 'analytics', label: 'Analytics', icon: 'target' }, { id: 'analytics', label: 'Analytics', icon: 'target' },
{ id: 'system', label: 'System', icon: 'gear' }, { id: 'system', label: 'System', icon: 'gear' },
{ id: 'jobs', label: 'Jobs', icon: 'clock' }, { id: 'jobs', label: 'Jobs', icon: 'clock' },
{ id: 'partner', label: 'Partner & Codes', icon: 'handshake' },
{ id: 'audit', label: 'Audit-Log', icon: 'clipboard-text' }, { id: 'audit', label: 'Audit-Log', icon: 'clipboard-text' },
]; ];
@ -88,6 +89,7 @@ window.Page_admin = (() => {
case 'analytics': await _renderAnalytics(el); break; case 'analytics': await _renderAnalytics(el); break;
case 'system': await _renderSystem(el); break; case 'system': await _renderSystem(el); break;
case 'jobs': await _renderJobs(el); break; case 'jobs': await _renderJobs(el); break;
case 'partner': await _renderPartner(el); break;
case 'audit': await _renderAudit(el); break; case 'audit': await _renderAudit(el); break;
} }
} catch (e) { } catch (e) {
@ -1789,6 +1791,210 @@ window.Page_admin = (() => {
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// TAB: AUDIT-LOG // 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) { async function _renderAudit(el) {
el.innerHTML = ` el.innerHTML = `
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)"> <div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">

View file

@ -124,14 +124,21 @@ window.Page_settings = (() => {
<div> <div>
<div style="font-weight:700;font-size:var(--text-lg)">${_esc(u.name)}</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> <div style="color:var(--c-text-secondary);font-size:var(--text-sm)">${_esc(u.email)}</div>
${u.is_premium <div style="display:flex;flex-wrap:wrap;gap:var(--space-1);margin-top:var(--space-1)">
? `<span class="badge badge-primary" style="margin-top:var(--space-1)"> ${u.is_premium
<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#star"></use></svg> Ban Yaro Plus ? `<span class="badge badge-primary">
</span>` <svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#star"></use></svg> Ban Yaro Plus
: `<span class="badge" style="margin-top:var(--space-1); </span>`
color:var(--c-text-secondary)"> : `<span class="badge" style="color:var(--c-text-secondary)">Kostenlos</span>`}
Kostenlos ${u.is_founder
</span>`} ? `<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> </div>
</div> </div>
@ -1283,6 +1290,16 @@ window.Page_settings = (() => {
</div> </div>
</div> </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)"> <button type="submit" class="btn btn-primary w-full" style="margin-top:var(--space-2)">
Konto erstellen Konto erstellen
</button> </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 => { document.getElementById('auth-form')?.addEventListener('submit', async e => {
e.preventDefault(); e.preventDefault();
const btn = e.target.querySelector('[type="submit"]'); const btn = e.target.querySelector('[type="submit"]');
@ -1390,8 +1445,10 @@ window.Page_settings = (() => {
} }
await UI.asyncButton(btn, async () => { await UI.asyncButton(btn, async () => {
const refCode = sessionStorage.getItem('by_ref_code') || ''; const partnerCode = (fd.partner_code || '').trim().toUpperCase() || undefined;
const result = await API.auth.register(fd.email, fd.password, fd.name.trim(), refCode || 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); localStorage.setItem('by_token', result.token);
if (refCode) sessionStorage.removeItem('by_ref_code'); if (refCode) sessionStorage.removeItem('by_ref_code');
@ -1401,7 +1458,10 @@ window.Page_settings = (() => {
_appState.activeDog = null; _appState.activeDog = null;
document.getElementById('header-login-btn')?.remove(); 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(); App.showOnboarding();
}); });
}); });

View file

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