From 0a262989f35ff7e3a30dfe4389ae930ab374d372 Mon Sep 17 00:00:00 2001 From: rene Date: Sun, 7 Jun 2026 19:06:51 +0200 Subject: [PATCH] =?UTF-8?q?Feature:=20Partner-Dashboard=20(#partner-dashbo?= =?UTF-8?q?ard)=20=E2=80=94=20operative=20Daten=20raus=20aus=20dem=20Profi?= =?UTF-8?q?l-Editor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- VERSION | 2 +- backend/routes/auth.py | 4 +- backend/routes/partner.py | 31 +++ backend/static/index.html | 24 +-- backend/static/js/app.js | 3 +- backend/static/js/pages/partner-dashboard.js | 212 +++++++++++++++++++ backend/static/js/pages/partner-profil.js | 75 +------ backend/static/js/pages/settings.js | 15 +- backend/static/js/worlds.js | 4 +- backend/static/landing.html | 2 +- backend/static/sw.js | 2 +- tests/test_partner_qr.py | 9 + 12 files changed, 287 insertions(+), 96 deletions(-) create mode 100644 backend/static/js/pages/partner-dashboard.js diff --git a/VERSION b/VERSION index 50a143c..15448f1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1260 \ No newline at end of file +1261 \ No newline at end of file diff --git a/backend/routes/auth.py b/backend/routes/auth.py index c0e7e5a..db5683d 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -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") diff --git a/backend/routes/partner.py b/backend/routes/partner.py index 23f0bcd..f7a61df 100644 --- a/backend/routes/partner.py +++ b/backend/routes/partner.py @@ -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).""" diff --git a/backend/static/index.html b/backend/static/index.html index 4ed0115..af4939e 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -86,14 +86,14 @@ Ban Yaro - + - - - - - + + + + + @@ -612,11 +612,11 @@ - - - - - + + + + + @@ -626,7 +626,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 573ad40..96cee9d 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -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 }, diff --git a/backend/static/js/pages/partner-dashboard.js b/backend/static/js/pages/partner-dashboard.js new file mode 100644 index 0000000..22e21aa --- /dev/null +++ b/backend/static/js/pages/partner-dashboard.js @@ -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 = ` +
+
+

+ ${UI.icon('handshake')} Partner-Bereich +

+

+ Dein Code, deine Zahlen, deine QR-Kontingente. +

+
+
+
Lade…
+
+
+ `; + } + + 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 = `

${UI.escape(e.message || 'Fehler beim Laden.')}

`; + } + } + + function _renderDashboard() { + const codes = _stats?.codes || []; + return ` + ${codes.length === 0 ? ` +
+

+ Dir ist noch kein Partner-Code zugeordnet.
+ Melde dich bei partner@banyaro.app — wir richten ihn ein. +

+
` : codes.map(c => _renderCodeCard(c)).join('')} + + ${_renderQrSection()} + ${_renderProfileCard()} + `; + } + + function _renderCodeCard(c) { + const link = `https://banyaro.app/?ref=${encodeURIComponent(c.code)}`; + return ` +
+
Dein Einladungscode
+
+ ${UI.escape(c.code)} + +
+
+
+
${c.registrations}
+
Registrierungen
+
+
+
${c.registrations_month}
+
diesen Monat
+
+
+
${c.attempts}
+
unbestätigt
+
+
+
`; + } + + function _renderQrSection() { + if (!_qrBatches.length) return ''; + return ` +
+
Meine QR-Codes
+

+ Deine gedruckten QR-Codes (Sticker, Flyer). Jeder Scan und jede Registrierung + darüber wird gezählt — so siehst du, was wo funktioniert. +

+ ${_qrBatches.map(b => ` +
+
+
+
${UI.escape(b.label)}
+
+ ${b.quantity} Codes · ${(b.created_at || '').slice(0, 10)} · + ${b.codes_used}/${b.quantity} verbraucht +
+
+
+
${b.scans}
+
Scans
+
+
+
${b.registrations}${b.attempts ? ` +${b.attempts}` : ''}
+
Registr.
+
+ + + ${UI.icon('file-pdf')} PDF + +
+ +
`).join('')} +
`; + } + + function _renderProfileCard() { + const p = _stats?.profile || {}; + let badge; + if (p.approved === 1) badge = `✓ Öffentlich sichtbar`; + else if (p.approved === -1) badge = `✗ Abgelehnt`; + else if (p.submitted_at) badge = `⏳ In Prüfung`; + else if (p.exists) badge = `Entwurf`; + else badge = `Noch nicht angelegt`; + return ` +
+
+
+
Öffentliches Profil
+ ${badge} +
+ +
+
`; + } + + 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 ` +
+ #${c.seq} + banyaro.app/q/${UI.escape(c.token)} + ${c.scans} Scan${c.scans === 1 ? '' : 's'} + ${used + ? `● verbraucht` + : scanned + ? `◐ gescannt` + : `○ frei`} +
`; + }).join(''); + } catch (err) { UI.toast.error(err.message); } + }); + }); + + el.querySelector('#pd-edit-profile')?.addEventListener('click', () => App.navigate('partner-profil')); + } + + return { init, refresh, onDogChange }; + +})(); diff --git a/backend/static/js/pages/partner-profil.js b/backend/static/js/pages/partner-profil.js index da834ec..a2bf015 100644 --- a/backend/static/js/pages/partner-profil.js +++ b/backend/static/js/pages/partner-profil.js @@ -27,6 +27,8 @@ window.Page_partner_profil = (() => {

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 + Partner-Bereich.

@@ -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 = (() => {
- ${_qrBatches.length ? ` - -
-
Meine QR-Codes
-

- Deine gedruckten QR-Codes (Sticker, Flyer). Jeder Scan und jede Registrierung - darüber wird gezählt — so siehst du, was wo funktioniert. -

- ${_qrBatches.map(b => ` -
-
-
-
${UI.escape(b.label)}
-
- ${b.quantity} Codes · ${(b.created_at || '').slice(0, 10)} · - ${b.codes_used}/${b.quantity} verbraucht -
-
-
-
${b.scans}
-
Scans
-
-
-
${b.registrations}${b.attempts ? ` +${b.attempts}` : ''}
-
Registr.
-
- - - ${UI.icon('file-pdf')} PDF - -
- -
`).join('')} -
` : ''} -
+
+ + +
` : ''} @@ -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')); } diff --git a/backend/static/js/worlds.js b/backend/static/js/worlds.js index 91fe314..e54f867 100644 --- a/backend/static/js/worlds.js +++ b/backend/static/js/worlds.js @@ -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; } diff --git a/backend/static/landing.html b/backend/static/landing.html index c57180e..66378b4 100644 --- a/backend/static/landing.html +++ b/backend/static/landing.html @@ -4,7 +4,7 @@ - + Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz diff --git a/backend/static/sw.js b/backend/static/sw.js index 6057213..29d104a 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -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 diff --git a/tests/test_partner_qr.py b/tests/test_partner_qr.py index 84fa51e..fe3eed2 100644 --- a/tests/test_partner_qr.py +++ b/tests/test_partner_qr.py @@ -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"]