Feature: Partner-Dashboard (#partner-dashboard) — operative Daten raus aus dem Profil-Editor
Rene: QR-Stats gehören nicht ins öffentliche Profil, eigene Seite fehlte.
Neue Seite 'Partner-Bereich' (Welten-Chip 🤝 zwischen Moderation und Admin,
role:partner — sichtbar für is_partner + Admin; _mergeDefaults reicht den
Chip an bestehende Welt-Configs nach):
- Einladungscode groß + Link-kopieren-Button
- Kacheln: Registrierungen gesamt / diesen Monat / unbestätigt
- QR-Kontingente mit Einzel-Code-Status (aus partner-profil.js hierher verschoben)
- Profil-Status-Karte (Entwurf/Prüfung/frei) mit Sprung zum Editor
Backend: GET /partner/my-stats (Codes mit Zahlen + Profil-Status).
Settings-Partner-Karte: zwei Buttons (Partner-Bereich primär, Profil sekundär);
Dank-Mail-CTA zeigt auf #partner-dashboard. Suite: 52 passed.
This commit is contained in:
parent
3d7d5dc1c4
commit
0a262989f3
12 changed files with 287 additions and 96 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
1260
|
||||
1261
|
||||
|
|
@ -466,11 +466,11 @@ def _notify_partner_registration(user_id: int):
|
|||
+ (f"\n{qr_line}\n" if qr_line else "")
|
||||
+ f"\nDeine Bilanz mit dem Code {pc['code']}: {total} bestätigte Registrierungen insgesamt, {month} in diesem Monat.\n"
|
||||
+ (f"{founder_line}\n" if founder_line else "")
|
||||
+ f"\nDeine Statistik: {_APP_URL}/#partner-profil\n")
|
||||
+ f"\nDein Partner-Bereich: {_APP_URL}/#partner-dashboard\n")
|
||||
try:
|
||||
from routes.outreach import _send_smtp
|
||||
from mailer import email_html
|
||||
html = email_html(body_html, cta_url=f"{_APP_URL}/#partner-profil", cta_label="Meine Partner-Statistik")
|
||||
html = email_html(body_html, cta_url=f"{_APP_URL}/#partner-dashboard", cta_label="Mein Partner-Bereich")
|
||||
_send_smtp(pc["owner_email"], subject, plain, "partner", html=html)
|
||||
except Exception as exc:
|
||||
_log_smtp_failure(pc["owner_email"], subject, plain, exc, context="partner_thank_you")
|
||||
|
|
|
|||
|
|
@ -750,6 +750,37 @@ def qr_batch_pdf_admin(batch_id: int, user=Depends(require_admin)):
|
|||
headers={"Content-Disposition": f'attachment; filename="banyaro-qr-{batch_id}.pdf"'})
|
||||
|
||||
|
||||
@router.get("/partner/my-stats")
|
||||
def my_partner_stats(user=Depends(require_partner)):
|
||||
"""Dashboard-Zahlen für den Partner: eigene Codes mit Registrierungen/Versuchen
|
||||
+ Status des öffentlichen Profils."""
|
||||
with db() as conn:
|
||||
codes = conn.execute(
|
||||
"""SELECT pc.id, pc.code, pc.label, pc.uses, pc.grants_founder,
|
||||
(SELECT COUNT(*) FROM users u
|
||||
WHERE u.referred_by = -pc.id AND u.email_verified = 1) AS registrations,
|
||||
(SELECT COUNT(*) FROM users u
|
||||
WHERE u.referred_by = -pc.id AND u.email_verified = 0) AS attempts,
|
||||
(SELECT COUNT(*) FROM users u
|
||||
WHERE u.referred_by = -pc.id AND u.email_verified = 1
|
||||
AND strftime('%Y-%m', u.created_at) = strftime('%Y-%m', 'now')) AS registrations_month
|
||||
FROM partner_codes pc
|
||||
WHERE pc.owner_user_id = ?
|
||||
ORDER BY pc.created_at""",
|
||||
(user["id"],)
|
||||
).fetchall()
|
||||
profile = _pp_get_or_empty(conn, user["id"])
|
||||
return {
|
||||
"codes": [dict(c) for c in codes],
|
||||
"profile": {
|
||||
"exists": bool(profile),
|
||||
"approved": profile.get("approved", 0),
|
||||
"submitted_at": profile.get("submitted_at"),
|
||||
"display_name": profile.get("display_name"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/partner/my-qr")
|
||||
def my_qr_batches(user=Depends(require_partner)):
|
||||
"""Übergabe/Self-Service: eigene Kontingente mit Stats (Code-Besitzer)."""
|
||||
|
|
|
|||
|
|
@ -86,14 +86,14 @@
|
|||
<title>Ban Yaro</title>
|
||||
|
||||
<!-- Theme + theme-color Statusleiste vor CSS setzen -->
|
||||
<script src="/js/boot-early.js?v=1260"></script>
|
||||
<script src="/js/boot-early.js?v=1261"></script>
|
||||
|
||||
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
|
||||
<link rel="stylesheet" href="/css/design-system.css?v=1260">
|
||||
<link rel="stylesheet" href="/css/layout.css?v=1260">
|
||||
<link rel="stylesheet" href="/css/components.css?v=1260">
|
||||
<link rel="stylesheet" href="/css/utilities.css?v=1260">
|
||||
<link rel="stylesheet" href="/css/lists.css?v=1260">
|
||||
<link rel="stylesheet" href="/css/design-system.css?v=1261">
|
||||
<link rel="stylesheet" href="/css/layout.css?v=1261">
|
||||
<link rel="stylesheet" href="/css/components.css?v=1261">
|
||||
<link rel="stylesheet" href="/css/utilities.css?v=1261">
|
||||
<link rel="stylesheet" href="/css/lists.css?v=1261">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
|
@ -612,11 +612,11 @@
|
|||
<div id="modal-container"></div>
|
||||
|
||||
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
|
||||
<script src="/js/api.js?v=1260"></script>
|
||||
<script src="/js/ui.js?v=1260"></script>
|
||||
<script src="/js/app.js?v=1260"></script>
|
||||
<script src="/js/worlds.js?v=1260"></script>
|
||||
<script src="/js/offline-indicator.js?v=1260"></script>
|
||||
<script src="/js/api.js?v=1261"></script>
|
||||
<script src="/js/ui.js?v=1261"></script>
|
||||
<script src="/js/app.js?v=1261"></script>
|
||||
<script src="/js/worlds.js?v=1261"></script>
|
||||
<script src="/js/offline-indicator.js?v=1261"></script>
|
||||
|
||||
<!-- Feature-Seiten werden lazy geladen -->
|
||||
|
||||
|
|
@ -626,7 +626,7 @@
|
|||
|
||||
|
||||
<!-- Boot: Offline-Banner + SW-Registration (extrahiert für CSP) -->
|
||||
<script src="/js/boot.js?v=1260"></script>
|
||||
<script src="/js/boot.js?v=1261"></script>
|
||||
|
||||
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Router, State-Management, Navigation, Initialisierung.
|
||||
============================================================ */
|
||||
|
||||
const APP_VER = '1260'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||
const APP_VER = '1261'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||
const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt
|
||||
window.APP_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator)
|
||||
window.APP_VERSION = APP_VERSION;
|
||||
|
|
@ -81,6 +81,7 @@ const App = (() => {
|
|||
gruender: { title: '100 Gründer', module: null },
|
||||
partner: { title: 'Unsere Partner', module: null },
|
||||
'partner-profil': { title: 'Partner-Profil', module: null, requiresAuth: true },
|
||||
'partner-dashboard': { title: 'Partner-Bereich', module: null, requiresAuth: true },
|
||||
jobs: { title: 'Wir suchen dich', module: null },
|
||||
expenses: { title: 'Ausgaben', module: null, requiresAuth: true },
|
||||
recalls: { title: 'Rückrufe', module: null },
|
||||
|
|
|
|||
212
backend/static/js/pages/partner-dashboard.js
Normal file
212
backend/static/js/pages/partner-dashboard.js
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
/* ============================================================
|
||||
BAN YARO — Partner-Dashboard
|
||||
Operative Daten für Partner: Code + Einladungslink, Statistik,
|
||||
QR-Kontingente mit Einzel-Code-Status, Profil-Status.
|
||||
(Die öffentliche Präsenz wird in partner-profil.js gepflegt.)
|
||||
============================================================ */
|
||||
|
||||
window.Page_partner_dashboard = (() => {
|
||||
|
||||
let _container = null;
|
||||
let _stats = null;
|
||||
let _qrBatches = [];
|
||||
|
||||
async function init(container) {
|
||||
_container = container;
|
||||
_render();
|
||||
await _load();
|
||||
}
|
||||
|
||||
function refresh() { _load(); }
|
||||
function onDogChange() {}
|
||||
|
||||
function _render() {
|
||||
_container.innerHTML = `
|
||||
<div style="max-width:640px;margin:0 auto;padding:var(--space-4)">
|
||||
<div style="margin-bottom:var(--space-5)">
|
||||
<h1 style="font-size:var(--text-xl);font-weight:800;margin:0 0 var(--space-1)">
|
||||
${UI.icon('handshake')} Partner-Bereich
|
||||
</h1>
|
||||
<p style="color:var(--c-text-secondary);font-size:var(--text-sm);margin:0">
|
||||
Dein Code, deine Zahlen, deine QR-Kontingente.
|
||||
</p>
|
||||
</div>
|
||||
<div id="pd-content">
|
||||
<div style="text-align:center;padding:var(--space-8);color:var(--c-text-muted)">Lade…</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function _load() {
|
||||
const el = _container.querySelector('#pd-content');
|
||||
try {
|
||||
_stats = await API.get('/partner/my-stats');
|
||||
_qrBatches = (await API.get('/partner/my-qr').catch(() => [])) || [];
|
||||
el.innerHTML = _renderDashboard();
|
||||
_bindEvents(el);
|
||||
} catch (e) {
|
||||
el.innerHTML = `<p class="text-danger">${UI.escape(e.message || 'Fehler beim Laden.')}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function _renderDashboard() {
|
||||
const codes = _stats?.codes || [];
|
||||
return `
|
||||
${codes.length === 0 ? `
|
||||
<div class="card" style="padding:var(--space-5);text-align:center;margin-bottom:var(--space-3)">
|
||||
<p class="text-sm-secondary" style="margin:0">
|
||||
Dir ist noch kein Partner-Code zugeordnet.<br>
|
||||
Melde dich bei <a href="mailto:partner@banyaro.app" class="text-primary">partner@banyaro.app</a> — wir richten ihn ein.
|
||||
</p>
|
||||
</div>` : codes.map(c => _renderCodeCard(c)).join('')}
|
||||
|
||||
${_renderQrSection()}
|
||||
${_renderProfileCard()}
|
||||
`;
|
||||
}
|
||||
|
||||
function _renderCodeCard(c) {
|
||||
const link = `https://banyaro.app/?ref=${encodeURIComponent(c.code)}`;
|
||||
return `
|
||||
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-3)">
|
||||
<div style="font-size:var(--text-xs);font-weight:700;text-transform:uppercase;
|
||||
letter-spacing:.06em;color:var(--c-text-muted);margin-bottom:var(--space-2)">Dein Einladungscode</div>
|
||||
<div style="display:flex;align-items:center;gap:var(--space-3);flex-wrap:wrap;margin-bottom:var(--space-3)">
|
||||
<code style="font-size:var(--text-lg);font-weight:800;letter-spacing:.1em;color:var(--c-primary)">${UI.escape(c.code)}</code>
|
||||
<button class="btn btn-sm btn-secondary pd-copy" data-link="${UI.escape(link)}">
|
||||
${UI.icon('copy')} Link kopieren
|
||||
</button>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:var(--space-2);text-align:center">
|
||||
<div style="background:var(--c-surface-2);border-radius:var(--radius-md);padding:var(--space-3)">
|
||||
<div style="font-size:var(--text-xl);font-weight:800;color:${c.registrations > 0 ? 'var(--c-success,#16a34a)' : 'var(--c-text)'}">${c.registrations}</div>
|
||||
<div class="text-xs-muted">Registrierungen</div>
|
||||
</div>
|
||||
<div style="background:var(--c-surface-2);border-radius:var(--radius-md);padding:var(--space-3)">
|
||||
<div style="font-size:var(--text-xl);font-weight:800">${c.registrations_month}</div>
|
||||
<div class="text-xs-muted">diesen Monat</div>
|
||||
</div>
|
||||
<div style="background:var(--c-surface-2);border-radius:var(--radius-md);padding:var(--space-3)"
|
||||
title="Registriert, aber E-Mail (noch) nicht bestätigt">
|
||||
<div style="font-size:var(--text-xl);font-weight:800;color:var(--c-text-muted)">${c.attempts}</div>
|
||||
<div class="text-xs-muted">unbestätigt</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _renderQrSection() {
|
||||
if (!_qrBatches.length) return '';
|
||||
return `
|
||||
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-3)">
|
||||
<div style="font-size:var(--text-xs);font-weight:700;text-transform:uppercase;
|
||||
letter-spacing:.06em;color:var(--c-text-muted);margin-bottom:var(--space-2)">Meine QR-Codes</div>
|
||||
<p class="text-xs-muted" style="margin:0 0 var(--space-3)">
|
||||
Deine gedruckten QR-Codes (Sticker, Flyer). Jeder Scan und jede Registrierung
|
||||
darüber wird gezählt — so siehst du, was wo funktioniert.
|
||||
</p>
|
||||
${_qrBatches.map(b => `
|
||||
<div style="border-bottom:1px solid var(--c-border)">
|
||||
<div style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-2) 0">
|
||||
<div class="flex-1-min">
|
||||
<div style="font-weight:600;font-size:var(--text-sm)">${UI.escape(b.label)}</div>
|
||||
<div class="text-xs-muted">
|
||||
${b.quantity} Codes · ${(b.created_at || '').slice(0, 10)} ·
|
||||
<span style="color:${b.codes_used > 0 ? 'var(--c-success,#16a34a)' : 'inherit'}">${b.codes_used}/${b.quantity} verbraucht</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="text-align:center;min-width:48px">
|
||||
<div style="font-weight:700">${b.scans}</div>
|
||||
<div class="text-xs-muted">Scans</div>
|
||||
</div>
|
||||
<div style="text-align:center;min-width:48px"
|
||||
title="Registriert und E-Mail bestätigt${b.attempts ? ` — dazu ${b.attempts} unbestätigte` : ''}">
|
||||
<div style="font-weight:700;color:${b.registrations > 0 ? 'var(--c-success,#16a34a)' : 'inherit'}">${b.registrations}${b.attempts ? `<span class="text-xs-muted" style="font-weight:400"> +${b.attempts}</span>` : ''}</div>
|
||||
<div class="text-xs-muted">Registr.</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-ghost pd-qr-codes-btn" data-id="${b.id}" title="Einzel-Codes anzeigen">
|
||||
${UI.icon('list')}
|
||||
</button>
|
||||
<a class="btn btn-sm btn-secondary" href="/api/partner/my-qr/${b.id}/pdf" download>
|
||||
${UI.icon('file-pdf')} PDF
|
||||
</a>
|
||||
</div>
|
||||
<div class="hidden" id="pd-qr-codes-${b.id}" style="padding:0 0 var(--space-3)">
|
||||
<div class="text-sm-muted">Lädt…</div>
|
||||
</div>
|
||||
</div>`).join('')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _renderProfileCard() {
|
||||
const p = _stats?.profile || {};
|
||||
let badge;
|
||||
if (p.approved === 1) badge = `<span class="badge" style="background:#dcfce7;color:#16a34a">✓ Öffentlich sichtbar</span>`;
|
||||
else if (p.approved === -1) badge = `<span class="badge" style="background:#fee2e2;color:#dc2626">✗ Abgelehnt</span>`;
|
||||
else if (p.submitted_at) badge = `<span class="badge" style="background:#fef9c3;color:#a16207">⏳ In Prüfung</span>`;
|
||||
else if (p.exists) badge = `<span class="badge">Entwurf</span>`;
|
||||
else badge = `<span class="badge">Noch nicht angelegt</span>`;
|
||||
return `
|
||||
<div class="card" style="padding:var(--space-4)">
|
||||
<div style="display:flex;align-items:center;gap:var(--space-3)">
|
||||
<div class="flex-1-min">
|
||||
<div style="font-size:var(--text-xs);font-weight:700;text-transform:uppercase;
|
||||
letter-spacing:.06em;color:var(--c-text-muted);margin-bottom:var(--space-1)">Öffentliches Profil</div>
|
||||
${badge}
|
||||
</div>
|
||||
<button class="btn btn-sm btn-secondary" id="pd-edit-profile">
|
||||
${UI.icon('pencil-simple')} Bearbeiten
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _bindEvents(el) {
|
||||
// Einladungslink kopieren
|
||||
el.querySelectorAll('.pd-copy').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(btn.dataset.link);
|
||||
UI.toast.success('Einladungslink kopiert.');
|
||||
} catch {
|
||||
UI.toast.info(btn.dataset.link);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Einzel-Code-Status (lazy, .hidden via classList)
|
||||
el.querySelectorAll('.pd-qr-codes-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const box = el.querySelector(`#pd-qr-codes-${btn.dataset.id}`);
|
||||
if (!box) return;
|
||||
box.classList.toggle('hidden');
|
||||
if (box.classList.contains('hidden') || box.dataset.loaded === '1') return;
|
||||
try {
|
||||
const codes = await API.get(`/partner/my-qr/${btn.dataset.id}/codes`);
|
||||
box.dataset.loaded = '1';
|
||||
box.innerHTML = codes.map(c => {
|
||||
const used = c.registrations > 0;
|
||||
const scanned = c.scans > 0;
|
||||
return `
|
||||
<div style="display:flex;align-items:center;gap:var(--space-2);padding:3px 0;font-size:var(--text-xs);border-bottom:1px dashed var(--c-border)">
|
||||
<span style="font-weight:700;min-width:34px">#${c.seq}</span>
|
||||
<code class="flex-1-min" style="color:var(--c-text-muted)">banyaro.app/q/${UI.escape(c.token)}</code>
|
||||
<span class="text-xs-muted" style="min-width:60px;text-align:right">${c.scans} Scan${c.scans === 1 ? '' : 's'}</span>
|
||||
${used
|
||||
? `<span class="badge" style="background:#dcfce7;color:#16a34a" title="Registrierung am ${(c.first_registration_at || '').slice(0, 10)}">● verbraucht</span>`
|
||||
: scanned
|
||||
? `<span class="badge" style="background:#fef9c3;color:#a16207" title="Gescannt${c.last_scan_at ? ' am ' + c.last_scan_at.slice(0, 10) : ''}, noch keine bestätigte Registrierung">◐ gescannt</span>`
|
||||
: `<span class="badge" style="background:var(--c-surface-2);color:var(--c-text-muted)">○ frei</span>`}
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch (err) { UI.toast.error(err.message); }
|
||||
});
|
||||
});
|
||||
|
||||
el.querySelector('#pd-edit-profile')?.addEventListener('click', () => App.navigate('partner-profil'));
|
||||
}
|
||||
|
||||
return { init, refresh, onDogChange };
|
||||
|
||||
})();
|
||||
|
|
@ -27,6 +27,8 @@ window.Page_partner_profil = (() => {
|
|||
<p style="color:var(--c-text-secondary);font-size:var(--text-sm);margin:0">
|
||||
Richte deine öffentliche Präsenz auf der Partner-Seite ein.
|
||||
Nach dem Absenden prüfen wir dein Profil und schalten es frei.
|
||||
Deine Zahlen und QR-Codes findest du im
|
||||
<a href="#partner-dashboard" class="text-primary">Partner-Bereich</a>.
|
||||
</p>
|
||||
</div>
|
||||
<div id="pp-content">
|
||||
|
|
@ -36,8 +38,6 @@ window.Page_partner_profil = (() => {
|
|||
`;
|
||||
}
|
||||
|
||||
let _qrBatches = [];
|
||||
|
||||
async function _load() {
|
||||
const el = _container.querySelector('#pp-content');
|
||||
try {
|
||||
|
|
@ -45,7 +45,6 @@ window.Page_partner_profil = (() => {
|
|||
_profile = d.profile || {};
|
||||
_profile._storage_mb = d.storage_mb || 0;
|
||||
_profile._storage_limit_mb = d.storage_limit_mb || 200;
|
||||
_qrBatches = (await API.get('/partner/my-qr').catch(() => [])) || [];
|
||||
el.innerHTML = _renderEditor();
|
||||
_bindEvents(el);
|
||||
} catch (e) {
|
||||
|
|
@ -181,47 +180,6 @@ window.Page_partner_profil = (() => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
${_qrBatches.length ? `
|
||||
<!-- QR-Kontingente: gedruckte Codes mit Scan-/Registrierungs-Stats -->
|
||||
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-3)">
|
||||
<div style="font-size:var(--text-xs);font-weight:700;text-transform:uppercase;
|
||||
letter-spacing:.06em;color:var(--c-text-muted);margin-bottom:var(--space-2)">Meine QR-Codes</div>
|
||||
<p class="text-xs-muted" style="margin:0 0 var(--space-3)">
|
||||
Deine gedruckten QR-Codes (Sticker, Flyer). Jeder Scan und jede Registrierung
|
||||
darüber wird gezählt — so siehst du, was wo funktioniert.
|
||||
</p>
|
||||
${_qrBatches.map(b => `
|
||||
<div style="border-bottom:1px solid var(--c-border)">
|
||||
<div style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-2) 0">
|
||||
<div class="flex-1-min">
|
||||
<div style="font-weight:600;font-size:var(--text-sm)">${UI.escape(b.label)}</div>
|
||||
<div class="text-xs-muted">
|
||||
${b.quantity} Codes · ${(b.created_at || '').slice(0, 10)} ·
|
||||
<span style="color:${b.codes_used > 0 ? 'var(--c-success,#16a34a)' : 'inherit'}">${b.codes_used}/${b.quantity} verbraucht</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="text-align:center;min-width:48px">
|
||||
<div style="font-weight:700">${b.scans}</div>
|
||||
<div class="text-xs-muted">Scans</div>
|
||||
</div>
|
||||
<div style="text-align:center;min-width:48px"
|
||||
title="Registriert und E-Mail bestätigt${b.attempts ? ` — dazu ${b.attempts} unbestätigte` : ''}">
|
||||
<div style="font-weight:700;color:${b.registrations > 0 ? 'var(--c-success,#16a34a)' : 'inherit'}">${b.registrations}${b.attempts ? `<span class="text-xs-muted" style="font-weight:400"> +${b.attempts}</span>` : ''}</div>
|
||||
<div class="text-xs-muted">Registr.</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-ghost pp-qr-codes-btn" data-id="${b.id}" title="Einzel-Codes anzeigen">
|
||||
${UI.icon('list')}
|
||||
</button>
|
||||
<a class="btn btn-sm btn-secondary" href="/api/partner/my-qr/${b.id}/pdf" download>
|
||||
${UI.icon('file-pdf')} PDF
|
||||
</a>
|
||||
</div>
|
||||
<div class="hidden" id="pp-qr-codes-${b.id}" style="padding:0 0 var(--space-3)">
|
||||
<div class="text-sm-muted">Lädt…</div>
|
||||
</div>
|
||||
</div>`).join('')}
|
||||
</div>` : ''}
|
||||
|
||||
<!-- Absenden -->
|
||||
<div style="display:flex;gap:var(--space-3);justify-content:flex-end;margin-top:var(--space-4)">
|
||||
<button id="pp-submit-btn" class="btn btn-primary">
|
||||
|
|
@ -287,35 +245,6 @@ window.Page_partner_profil = (() => {
|
|||
});
|
||||
});
|
||||
|
||||
// Einzel-Code-Status eines QR-Kontingents (lazy, .hidden via classList)
|
||||
el.querySelectorAll('.pp-qr-codes-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const box = el.querySelector(`#pp-qr-codes-${btn.dataset.id}`);
|
||||
if (!box) return;
|
||||
box.classList.toggle('hidden');
|
||||
if (box.classList.contains('hidden') || box.dataset.loaded === '1') return;
|
||||
try {
|
||||
const codes = await API.get(`/partner/my-qr/${btn.dataset.id}/codes`);
|
||||
box.dataset.loaded = '1';
|
||||
box.innerHTML = codes.map(c => {
|
||||
const used = c.registrations > 0;
|
||||
const scanned = c.scans > 0;
|
||||
return `
|
||||
<div style="display:flex;align-items:center;gap:var(--space-2);padding:3px 0;font-size:var(--text-xs);border-bottom:1px dashed var(--c-border)">
|
||||
<span style="font-weight:700;min-width:34px">#${c.seq}</span>
|
||||
<code class="flex-1-min" style="color:var(--c-text-muted)">banyaro.app/q/${UI.escape(c.token)}</code>
|
||||
<span class="text-xs-muted" style="min-width:60px;text-align:right">${c.scans} Scan${c.scans === 1 ? '' : 's'}</span>
|
||||
${used
|
||||
? `<span class="badge" style="background:#dcfce7;color:#16a34a" title="Registrierung am ${(c.first_registration_at || '').slice(0, 10)}">● verbraucht</span>`
|
||||
: scanned
|
||||
? `<span class="badge" style="background:#fef9c3;color:#a16207" title="Gescannt${c.last_scan_at ? ' am ' + c.last_scan_at.slice(0, 10) : ''}, noch keine bestätigte Registrierung">◐ gescannt</span>`
|
||||
: `<span class="badge" style="background:var(--c-surface-2);color:var(--c-text-muted)">○ frei</span>`}
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch (err) { UI.toast.error(err.message); }
|
||||
});
|
||||
});
|
||||
|
||||
// Einreichen
|
||||
el.querySelector('#pp-submit-btn')?.addEventListener('click', async () => {
|
||||
const btn = el.querySelector('#pp-submit-btn');
|
||||
|
|
|
|||
|
|
@ -672,11 +672,16 @@ window.Page_settings = (() => {
|
|||
<div class="p-4">
|
||||
<p class="text-sm-secondary" style="margin:0 0 var(--space-3)">
|
||||
Als Partner hast du vollen Pro-Zugang und eine öffentliche Karte auf der
|
||||
Partner-Seite. Richte dein Profil ein — nach der Freigabe ist es für alle sichtbar.
|
||||
Partner-Seite. Deine Zahlen und QR-Codes findest du im Partner-Bereich.
|
||||
</p>
|
||||
<button class="btn btn-secondary btn-sm" id="settings-partner-profile-btn">
|
||||
${UI.icon('pencil-simple')} Mein Partner-Profil
|
||||
</button>
|
||||
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap">
|
||||
<button class="btn btn-primary btn-sm" id="settings-partner-dashboard-btn">
|
||||
${UI.icon('handshake')} Partner-Bereich
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" id="settings-partner-profile-btn">
|
||||
${UI.icon('pencil-simple')} Öffentliches Profil
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
|
||||
|
|
@ -1676,6 +1681,8 @@ window.Page_settings = (() => {
|
|||
_loadReferral();
|
||||
_loadBreederCard();
|
||||
|
||||
document.getElementById('settings-partner-dashboard-btn')
|
||||
?.addEventListener('click', () => App.navigate('partner-dashboard'));
|
||||
document.getElementById('settings-partner-profile-btn')
|
||||
?.addEventListener('click', () => App.navigate('partner-profil'));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -578,6 +578,7 @@ window.Worlds = (() => {
|
|||
{ icon:'sparkle', label:'Social', page:'social', role:'social',
|
||||
fab:[{ icon:'sparkle', color:'#EC4899', label:'Social-Post', sub:'Beitrag erstellen', page:'social', action:'openNew' }] },
|
||||
{ icon:'shield-check', label:'Moderation', page:'moderation', role:'mod' },
|
||||
{ icon:'handshake', label:'Partner', page:'partner-dashboard', role:'partner' },
|
||||
{ icon:'gear', label:'Admin', page:'admin', role:'admin' },
|
||||
// ── NEUE FEATURES ────────────────────────────────────────────
|
||||
{ icon:'fork-knife', label:'Ernährung', page:'ernaehrung', pro: true,
|
||||
|
|
@ -587,7 +588,7 @@ window.Worlds = (() => {
|
|||
];
|
||||
|
||||
const _DEFAULT_CONFIG = {
|
||||
jetzt: ['notes','expenses','erste-hilfe','playdate','chat','wetter','social','moderation','admin'],
|
||||
jetzt: ['notes','expenses','erste-hilfe','playdate','chat','wetter','social','moderation','partner-dashboard','admin'],
|
||||
hund: ['diary','health','uebungen','trainingsplaene','adoption','sitting','wiki','wurfboerse',
|
||||
'litters','zuchthunde','laeufi','ernaehrung','personality'],
|
||||
welt: ['map','forum','friends','walks','poison','recalls','lost','routes','events',
|
||||
|
|
@ -681,6 +682,7 @@ window.Worlds = (() => {
|
|||
}
|
||||
if (chip.role === 'social') return u?.is_social_media || u?.rolle === 'admin';
|
||||
if (chip.role === 'mod') return u?.rolle === 'admin' || u?.rolle === 'moderator' || u?.is_moderator;
|
||||
if (chip.role === 'partner') return !!u?.is_partner || u?.rolle === 'admin';
|
||||
if (chip.role === 'admin') return u?.rolle === 'admin';
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<script src="/js/landing-init.js?v=1260"></script>
|
||||
<script src="/js/landing-init.js?v=1261"></script>
|
||||
<title>Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz</title>
|
||||
<meta name="description" content="Ban Yaro: Die kostenlose All-in-One Hunde-App für DACH. Tagebuch, Giftköder-Alarm, Training mit KI, Forum, Wurfbörse, Stammbaum, Inzucht-Check — DSGVO-konform, offline-fähig, direkt im Browser.">
|
||||
<meta name="keywords" content="Hunde App, Hunde Community, Wurfbörse, Züchter, Welpen kaufen, Stammbaum Hund, Inzuchtkoeffizient, Hundezucht, Impfpass Hund, Giftköder Alarm, Gassi Community, Hundetraining App, Hunde Forum, Hunde KI, Hundefilm Datenbank, Welpen Marktplatz">
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
============================================================ */
|
||||
|
||||
// ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab
|
||||
const VER = '1260';
|
||||
const VER = '1261';
|
||||
const CACHE_VERSION = `by-v${VER}`;
|
||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||
|
|
|
|||
|
|
@ -215,3 +215,12 @@ def test_partner_self_service_qr(client, admin, user):
|
|||
assert first["first_registration_at"]
|
||||
r = client.get("/api/partner/my-qr", headers=user["headers"])
|
||||
assert r.json()[0]["codes_used"] == 1
|
||||
|
||||
# Dashboard-Stats: eigener Code mit Zahlen + Profil-Status
|
||||
r = client.get("/api/partner/my-stats", headers=user["headers"])
|
||||
assert r.status_code == 200
|
||||
d = r.json()
|
||||
mycode = [c for c in d["codes"] if c["id"] == code["id"]][0]
|
||||
assert mycode["registrations"] == 1
|
||||
assert mycode["registrations_month"] == 1
|
||||
assert "approved" in d["profile"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue