Feature: QR-Kontingente für Partner — Bestellung, Übergabe, Rückverfolgung

Partner verteilen gedruckte QR-Codes (Sticker/Flyer); jeder physische Code
ist einzeln rückverfolgbar von Scan bis Registrierung.

Backend:
- partner_qr_batches + partner_qr_codes (Token 8-stellig, ohne 0/O/1/l/I),
  users.referred_qr, partner_codes.owner_user_id (+Backfill über referred_by)
- /q/{token}: Scan zählen (scans, first/last_scan_at) → Redirect
  /?ref=CODE&qr=TOKEN — dockt am bestehenden Referral-Flow an
- Registrierung: qr_token wird nur zugeordnet, wenn er zum eingelösten
  Partner-Code gehört (Manipulationsschutz)
- Admin: Kontingent bestellen (max 500), Liste mit Scans/Registrierungen,
  Löschen (Zweiklick), druckfertiges A4-PDF (segno+fpdf2, 3×4 Grid mit
  Kurz-URL + laufender Nummer), Code-Besitzer zuordnen
- Partner-Self-Service: /partner/my-qr (+PDF) für Code-Besitzer

Frontend:
- Admin-Partner-Tab: Karte 'QR-Kontingente' (Bestellung, Stats, PDF, Besitzer)
- Partner-Profil: 'Meine QR-Codes' mit Scans/Registrierungen + PDF-Download
- boot.js/app.js speichern ?qr=, Registrierung schickt qr_token mit

Neu: segno==1.6.6 (pure-python QR). Tests: 5 neue (PDF, Scan-Zählung,
Attribution, Fremd-Token-Schutz, Self-Service). Suite: 51 passed.
This commit is contained in:
rene 2026-06-07 18:20:23 +02:00
parent cadfb24a8d
commit f604ab7c4f
16 changed files with 621 additions and 23 deletions

View file

@ -623,6 +623,10 @@ def _migrate(conn_factory):
("users", "is_partner", "INTEGER NOT NULL DEFAULT 0"),
("users", "founder_number", "INTEGER"),
("users", "is_founder_pending", "INTEGER NOT NULL DEFAULT 0"),
# QR-Rückverfolgung: über welchen physischen QR-Code (Sticker/Flyer) kam die Registrierung
("users", "referred_qr", "TEXT"),
# Partner-Code → Besitzer (für Self-Service: eigene QR-Kontingente + Stats einsehen)
("partner_codes", "owner_user_id", "INTEGER"),
# Passwort-Zurücksetzen
("users", "password_reset_token", "TEXT"),
("users", "password_reset_expires", "TEXT"),
@ -1685,6 +1689,48 @@ def _migrate(conn_factory):
except Exception as e:
logger.warning(f"Migration partner_profiles: {e}")
# QR-Kontingente für Partner (gedruckte Sticker/Flyer mit Rückverfolgung)
# Jeder physische QR-Code hat einen eigenen Token → Scan- und
# Registrierungs-Tracking pro Einzelcode und pro Kontingent.
try:
conn.executescript("""
CREATE TABLE IF NOT EXISTS partner_qr_batches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
partner_code_id INTEGER NOT NULL REFERENCES partner_codes(id) ON DELETE CASCADE,
label TEXT NOT NULL,
quantity INTEGER NOT NULL,
created_by INTEGER REFERENCES users(id),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS partner_qr_codes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
batch_id INTEGER NOT NULL REFERENCES partner_qr_batches(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
seq INTEGER NOT NULL,
scans INTEGER NOT NULL DEFAULT 0,
first_scan_at TEXT,
last_scan_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_pqr_token ON partner_qr_codes(token);
CREATE INDEX IF NOT EXISTS idx_pqr_batch ON partner_qr_codes(batch_id);
""")
logger.info("Migration: partner_qr Tabellen bereit.")
except Exception as e:
logger.warning(f"Migration partner_qr: {e}")
try:
# Backfill: Partner, die sich mit ihrem eigenen Code registriert haben,
# als Code-Besitzer verknüpfen (für Self-Service-Zugriff auf QR-Stats).
# Eigener try-Block: owner_user_id kommt auf frischen DBs erst im 2nd pass.
conn.execute("""
UPDATE partner_codes SET owner_user_id = (
SELECT u.id FROM users u
WHERE u.referred_by = -partner_codes.id AND u.is_partner = 1
LIMIT 1
) WHERE owner_user_id IS NULL
""")
except Exception as e:
logger.debug(f"Backfill partner_codes.owner_user_id übersprungen: {e}")
# Outreach-Log (Admin-E-Mail-Versand)
try:
conn.executescript("""

View file

@ -2156,6 +2156,37 @@ setTimeout(() => location.href = '/?_t=' + Date.now() + '&hard=1', 6000);
return HTMLResponse(content=html, headers={"Cache-Control": "no-store"})
# /q/{token} — Partner-QR-Scan: zählen + auf Registrierung mit Code umleiten
# ------------------------------------------------------------------
@app.get("/q/{token}")
async def partner_qr_scan(token: str):
from fastapi.responses import RedirectResponse as _Redirect
from database import db as _db
token = token.strip()
with _db() as conn:
row = conn.execute(
"""SELECT q.token, pc.code
FROM partner_qr_codes q
JOIN partner_qr_batches b ON b.id = q.batch_id
JOIN partner_codes pc ON pc.id = b.partner_code_id
WHERE q.token = ?""",
(token,)
).fetchone()
if not row:
return _Redirect("/", status_code=302)
conn.execute(
"""UPDATE partner_qr_codes
SET scans = scans + 1,
first_scan_at = COALESCE(first_scan_at, datetime('now')),
last_scan_at = datetime('now')
WHERE token = ?""",
(token,)
)
# ?ref= nutzt den bestehenden Partner-Code-Flow, ?qr= ergänzt die Einzelcode-Zuordnung
return _Redirect(f"/?ref={row['code']}&qr={row['token']}", status_code=302)
# ------------------------------------------------------------------
# /partner — Influencer-Landingpage
# ------------------------------------------------------------------
@app.get("/partner")

View file

@ -15,5 +15,6 @@ apscheduler==3.10.4
odfpy==1.4.1
polyline==2.0.2
fpdf2==2.8.3
segno==1.6.6
python-dateutil>=2.9
brotli-asgi==1.4.0

View file

@ -153,6 +153,7 @@ class RegisterRequest(BaseModel):
password: str = Field(..., min_length=8, max_length=200)
name: str = Field(..., min_length=2, max_length=40)
ref_code: Optional[str] = Field(None, max_length=50)
qr_token: Optional[str] = Field(None, max_length=20) # physischer Partner-QR (Sticker/Flyer)
def _gen_referral_code() -> str:
@ -227,6 +228,16 @@ async def register(data: RegisterRequest, response: Response, request: Request):
if redeemed:
updates = {"referred_by": -partner["id"]}
# QR-Rückverfolgung: Token muss zu einem Kontingent DIESES Codes gehören
if data.qr_token:
qr = conn.execute(
"""SELECT q.token FROM partner_qr_codes q
JOIN partner_qr_batches b ON b.id = q.batch_id
WHERE q.token=? AND b.partner_code_id=?""",
(data.qr_token.strip(), partner["id"])
).fetchone()
if qr:
updates["referred_qr"] = qr["token"]
if partner["grants_founder"]:
total_founders = conn.execute(
"SELECT COUNT(*) FROM users WHERE is_founder=1"

View file

@ -37,10 +37,12 @@ def list_partner_codes(user=Depends(require_admin)):
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
pc.max_uses, pc.uses, pc.created_at, pc.owner_user_id,
u.name AS created_by_name,
o.name AS owner_name
FROM partner_codes pc
LEFT JOIN users u ON u.id = pc.created_by
LEFT JOIN users o ON o.id = pc.owner_user_id
ORDER BY pc.created_at DESC"""
).fetchall()
return [dict(r) for r in rows]
@ -546,3 +548,220 @@ def review_partner_profile(user_id: int, data: PartnerProfileReview, user=Depend
)
profile = _pp_get_or_empty(conn, user_id)
return {"profile": profile}
# ------------------------------------------------------------------
# QR-Kontingente — gedruckte Sticker/Flyer mit Scan- und
# Registrierungs-Rückverfolgung pro Einzelcode und Kontingent.
# Bestellung: Admin legt Kontingent für einen Partner-Code an.
# Übergabe: PDF-Download (Admin + Partner im eigenen Profil).
# ------------------------------------------------------------------
_QR_MAX_QUANTITY = 500
# Ohne verwechselbare Zeichen (0/O, 1/l/I) — Tokens landen gedruckt auf Stickern
_QR_ALPHABET = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789"
_QR_BASE_URL = os.getenv("APP_URL", "https://banyaro.app")
def _qr_new_token(conn) -> str:
import secrets
for _ in range(20):
token = "".join(secrets.choice(_QR_ALPHABET) for _ in range(8))
if not conn.execute(
"SELECT 1 FROM partner_qr_codes WHERE token=?", (token,)
).fetchone():
return token
raise HTTPException(500, "Token-Generierung fehlgeschlagen.")
def _qr_batch_stats(conn, batch_id: int) -> dict:
row = conn.execute(
"""SELECT COUNT(*) AS codes, COALESCE(SUM(q.scans),0) AS scans,
(SELECT COUNT(*) FROM users u
JOIN partner_qr_codes q2 ON q2.token = u.referred_qr
WHERE q2.batch_id = ?) AS registrations
FROM partner_qr_codes q WHERE q.batch_id = ?""",
(batch_id, batch_id)
).fetchone()
return dict(row)
def _qr_list_batches(conn, where_sql: str, params: tuple) -> list:
rows = conn.execute(
f"""SELECT b.id, b.label, b.quantity, b.created_at,
pc.code, pc.label AS code_label, pc.id AS partner_code_id
FROM partner_qr_batches b
JOIN partner_codes pc ON pc.id = b.partner_code_id
{where_sql}
ORDER BY b.created_at DESC""",
params
).fetchall()
result = []
for r in rows:
d = dict(r)
d.update(_qr_batch_stats(conn, r["id"]))
result.append(d)
return result
def _qr_batch_pdf(conn, batch_id: int) -> bytes:
"""Druckfertiges A4-PDF: 3×4 QR-Codes pro Seite mit Kurz-URL + laufender Nummer."""
import io as _io
import segno
from fpdf import FPDF
batch = conn.execute(
"""SELECT b.id, b.label, b.quantity, pc.code, pc.label AS code_label
FROM partner_qr_batches b
JOIN partner_codes pc ON pc.id = b.partner_code_id
WHERE b.id=?""",
(batch_id,)
).fetchone()
if not batch:
raise HTTPException(404, "Kontingent nicht gefunden.")
codes = conn.execute(
"SELECT token, seq FROM partner_qr_codes WHERE batch_id=? ORDER BY seq",
(batch_id,)
).fetchall()
pdf = FPDF(format="A4")
pdf.set_auto_page_break(False)
pdf.set_title(f"Ban Yaro QR-Kontingent — {batch['label']}")
COLS, ROWS = 3, 4
CELL_W, CELL_H = 60, 64 # mm — Zelle inkl. Beschriftung
MARGIN_X = (210 - COLS * CELL_W) / 2
MARGIN_Y = 18
QR_SIZE = 42 # mm
def _latin1(s: str) -> str:
return s.encode("latin-1", "replace").decode("latin-1")
for i, c in enumerate(codes):
pos = i % (COLS * ROWS)
if pos == 0:
pdf.add_page()
pdf.set_font("Helvetica", "B", 11)
pdf.set_text_color(60)
pdf.cell(0, 6, _latin1(f"Ban Yaro — {batch['code_label']} · Kontingent: {batch['label']} ({batch['quantity']} Stk.)"),
align="C", new_x="LMARGIN", new_y="NEXT")
col, row_ = pos % COLS, pos // COLS
x = MARGIN_X + col * CELL_W
y = MARGIN_Y + 10 + row_ * CELL_H
url = f"{_QR_BASE_URL}/q/{c['token']}"
buf = _io.BytesIO()
segno.make(url, error="m").save(buf, kind="png", scale=8, border=2)
buf.seek(0)
pdf.image(buf, x=x + (CELL_W - QR_SIZE) / 2, y=y, w=QR_SIZE, h=QR_SIZE)
pdf.set_xy(x, y + QR_SIZE + 1)
pdf.set_font("Helvetica", "", 8)
pdf.set_text_color(90)
pdf.cell(CELL_W, 4, _latin1(f"banyaro.app/q/{c['token']}"), align="C", new_x="LEFT", new_y="NEXT")
pdf.set_x(x)
pdf.set_font("Helvetica", "B", 8)
pdf.cell(CELL_W, 4, f"#{c['seq']}", align="C")
return bytes(pdf.output())
class QrBatchCreate(BaseModel):
label: str = Field(..., min_length=1, max_length=100)
quantity: int = Field(..., ge=1, le=_QR_MAX_QUANTITY)
@router.post("/admin/partner/codes/{code_id}/qr-batches", status_code=201)
def create_qr_batch(code_id: int, data: QrBatchCreate, user=Depends(require_admin)):
"""Bestellung: neues QR-Kontingent für einen Partner-Code anlegen."""
with db() as conn:
code = conn.execute(
"SELECT id FROM partner_codes WHERE id=?", (code_id,)
).fetchone()
if not code:
raise HTTPException(404, "Partner-Code nicht gefunden.")
conn.execute(
"INSERT INTO partner_qr_batches (partner_code_id, label, quantity, created_by) VALUES (?,?,?,?)",
(code_id, data.label.strip(), data.quantity, user["id"])
)
batch_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
for seq in range(1, data.quantity + 1):
conn.execute(
"INSERT INTO partner_qr_codes (batch_id, token, seq) VALUES (?,?,?)",
(batch_id, _qr_new_token(conn), seq)
)
batches = _qr_list_batches(conn, "WHERE b.id=?", (batch_id,))
return batches[0]
@router.get("/admin/partner/qr-batches")
def list_qr_batches(user=Depends(require_admin)):
"""Alle QR-Kontingente mit Stats (Scans, Registrierungen)."""
with db() as conn:
return _qr_list_batches(conn, "", ())
@router.delete("/admin/partner/qr-batches/{batch_id}", status_code=204)
def delete_qr_batch(batch_id: int, user=Depends(require_admin)):
"""Kontingent löschen (z. B. Fehlbestellung) — Codes via CASCADE mit weg."""
with db() as conn:
if not conn.execute(
"SELECT id FROM partner_qr_batches WHERE id=?", (batch_id,)
).fetchone():
raise HTTPException(404, "Kontingent nicht gefunden.")
conn.execute("DELETE FROM partner_qr_batches WHERE id=?", (batch_id,))
return None
@router.get("/admin/partner/qr-batches/{batch_id}/pdf")
def qr_batch_pdf_admin(batch_id: int, user=Depends(require_admin)):
from fastapi.responses import Response
with db() as conn:
pdf = _qr_batch_pdf(conn, batch_id)
return Response(content=pdf, media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="banyaro-qr-{batch_id}.pdf"'})
@router.get("/partner/my-qr")
def my_qr_batches(user=Depends(require_partner)):
"""Übergabe/Self-Service: eigene Kontingente mit Stats (Code-Besitzer)."""
with db() as conn:
return _qr_list_batches(
conn, "WHERE pc.owner_user_id = ?", (user["id"],)
)
@router.get("/partner/my-qr/{batch_id}/pdf")
def qr_batch_pdf_partner(batch_id: int, user=Depends(require_partner)):
from fastapi.responses import Response
with db() as conn:
own = conn.execute(
"""SELECT b.id FROM partner_qr_batches b
JOIN partner_codes pc ON pc.id = b.partner_code_id
WHERE b.id=? AND pc.owner_user_id=?""",
(batch_id, user["id"])
).fetchone()
if not own and user.get("rolle") != "admin":
raise HTTPException(403, "Kein Zugriff auf dieses Kontingent.")
pdf = _qr_batch_pdf(conn, batch_id)
return Response(content=pdf, media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="banyaro-qr-{batch_id}.pdf"'})
class CodeOwnerSet(BaseModel):
user_id: int
@router.post("/admin/partner/codes/{code_id}/owner")
def set_code_owner(code_id: int, data: CodeOwnerSet, user=Depends(require_admin)):
"""Partner-Code einem User zuordnen (für Self-Service-QR-Zugriff)."""
with db() as conn:
if not conn.execute("SELECT id FROM partner_codes WHERE id=?", (code_id,)).fetchone():
raise HTTPException(404, "Partner-Code nicht gefunden.")
if not conn.execute("SELECT id FROM users WHERE id=?", (data.user_id,)).fetchone():
raise HTTPException(404, "User nicht gefunden.")
conn.execute(
"UPDATE partner_codes SET owner_user_id=? WHERE id=?",
(data.user_id, code_id)
)
return {"ok": True}

View file

@ -86,14 +86,14 @@
<title>Ban Yaro</title>
<!-- Theme + theme-color Statusleiste vor CSS setzen -->
<script src="/js/boot-early.js?v=1256"></script>
<script src="/js/boot-early.js?v=1257"></script>
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
<link rel="stylesheet" href="/css/design-system.css?v=1256">
<link rel="stylesheet" href="/css/layout.css?v=1256">
<link rel="stylesheet" href="/css/components.css?v=1256">
<link rel="stylesheet" href="/css/utilities.css?v=1256">
<link rel="stylesheet" href="/css/lists.css?v=1256">
<link rel="stylesheet" href="/css/design-system.css?v=1257">
<link rel="stylesheet" href="/css/layout.css?v=1257">
<link rel="stylesheet" href="/css/components.css?v=1257">
<link rel="stylesheet" href="/css/utilities.css?v=1257">
<link rel="stylesheet" href="/css/lists.css?v=1257">
</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=1256"></script>
<script src="/js/ui.js?v=1256"></script>
<script src="/js/app.js?v=1256"></script>
<script src="/js/worlds.js?v=1256"></script>
<script src="/js/offline-indicator.js?v=1256"></script>
<script src="/js/api.js?v=1257"></script>
<script src="/js/ui.js?v=1257"></script>
<script src="/js/app.js?v=1257"></script>
<script src="/js/worlds.js?v=1257"></script>
<script src="/js/offline-indicator.js?v=1257"></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=1256"></script>
<script src="/js/boot.js?v=1257"></script>
</body>

View file

@ -114,9 +114,10 @@ const API = (() => {
login(email, password) {
return post('/auth/login', { email, password });
},
register(email, password, name, ref_code) {
register(email, password, name, ref_code, qr_token) {
const body = { email, password, name };
if (ref_code) body.ref_code = ref_code;
if (qr_token) body.qr_token = qr_token; // Partner-QR (Sticker/Flyer) — Rückverfolgung
return post('/auth/register', body);
},
logout() {

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '1256'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '1257'; // ← 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;
@ -1140,10 +1140,13 @@ const App = (() => {
// überlebt App-Schließen, sodass die Zuordnung auch bei späterer Registrierung klappt)
const urlParams = new URLSearchParams(window.location.search);
const refCode = urlParams.get('ref');
const qrToken = urlParams.get('qr');
if (refCode) {
try {
localStorage.setItem('by_ref_code', refCode.toUpperCase());
localStorage.setItem('by_ref_code_ts', String(Date.now()));
// Partner-QR-Token (Sticker/Flyer) für Einzelcode-Rückverfolgung mitspeichern
if (qrToken) localStorage.setItem('by_qr_token', qrToken);
} catch {}
// URL bereinigen ohne Reload
history.replaceState({}, '', window.location.pathname + window.location.hash);

View file

@ -17,6 +17,9 @@
localStorage.setItem('by_ref_code', rc.toUpperCase());
localStorage.setItem('by_ref_code_ts', String(Date.now()));
}
// Partner-QR-Token (?qr= aus /q/{token}-Redirect) — Rückverfolgung pro Sticker/Flyer
var qt = new URLSearchParams(location.search).get('qr');
if (qt) localStorage.setItem('by_qr_token', qt);
// Vektor-Basemap-Feature-Flag aus ?vectormap=1/0 SOFORT sichern (bevor Boot
// die URL-Query strippt). Wird in ui.js Map.create ausgewertet.
var vm = new URLSearchParams(location.search).get('vectormap');

View file

@ -2290,8 +2290,9 @@ window.Page_admin = (() => {
// TAB: AUDIT-LOG
// ------------------------------------------------------------------
async function _renderPartner(el) {
const codes = (await API.get('/admin/partner/codes')) || [];
const profiles = (await API.get('/admin/partner/profiles').catch(() => [])) || [];
const codes = (await API.get('/admin/partner/codes')) || [];
const profiles = (await API.get('/admin/partner/profiles').catch(() => [])) || [];
const qrBatches = (await API.get('/admin/partner/qr-batches').catch(() => [])) || [];
el.innerHTML = `
<div style="display:flex;flex-direction:column;gap:var(--space-5)">
@ -2364,7 +2365,14 @@ window.Page_admin = (() => {
<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);color:var(--c-text)">
${c.label}
<div class="text-xs-muted">
${c.owner_name
? `👤 ${UI.escape(c.owner_name)}`
: `<button class="btn btn-ghost btn-sm adm-code-owner" data-id="${c.id}" style="font-size:var(--text-xs);padding:0 4px">👤 Besitzer zuordnen</button>`}
</div>
</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>
@ -2385,6 +2393,69 @@ window.Page_admin = (() => {
</div>
</div>
<!-- QR-Kontingente (Sticker/Flyer mit Rückverfolgung) -->
<div class="by-card p-4">
<h3 style="margin:0 0 var(--space-2);font-size:var(--text-base)">QR-Kontingente</h3>
<p class="text-xs-muted" style="margin:0 0 var(--space-3)">
Druckfertige QR-Codes für Partner (Sticker, Flyer, Visitenkarten). Jeder Code ist einzeln
rückverfolgbar: Scans und Registrierungen werden pro Kontingent gezählt.
</p>
<form id="adm-qr-create" class="flex-col-gap-3" style="margin-bottom:var(--space-4)">
<div style="display:grid;grid-template-columns:1fr 1fr 100px;gap:var(--space-3)">
<div>
<label class="form-label text-xs">Partner-Code</label>
<select class="form-control" name="code_id" required>
${codes.map(c => `<option value="${c.id}">${UI.escape(c.code)}${UI.escape(c.label)}</option>`).join('')}
</select>
</div>
<div>
<label class="form-label text-xs">Bezeichnung</label>
<input class="form-control" name="label" placeholder="z. B. Sticker-Bestellung Juni" required>
</div>
<div>
<label class="form-label text-xs">Stückzahl</label>
<input class="form-control" name="quantity" type="number" min="1" max="500" value="24" required>
</div>
</div>
<button type="submit" class="btn btn-primary btn-sm" style="align-self:flex-start"
${codes.length === 0 ? 'disabled title="Zuerst einen Partner-Code anlegen"' : ''}>
${UI.icon('qr-code')} Kontingent erstellen
</button>
</form>
${qrBatches.length === 0
? `<p class="text-sm-muted">Noch keine Kontingente bestellt.</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)">Kontingent</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)">Stk.</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)">Scans</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)">Registr.</th>
<th style="padding:var(--space-2) var(--space-3)"></th>
</tr>
</thead>
<tbody>
${qrBatches.map(b => `
<tr style="border-bottom:1px solid var(--c-border)">
<td style="padding:var(--space-2) var(--space-3)"><code style="font-weight:700;color:var(--c-primary)">${UI.escape(b.code)}</code></td>
<td style="padding:var(--space-2) var(--space-3)">${UI.escape(b.label)}<div class="text-xs-muted">${(b.created_at || '').slice(0, 10)}</div></td>
<td style="padding:var(--space-2) var(--space-3);text-align:center">${b.quantity}</td>
<td style="padding:var(--space-2) var(--space-3);text-align:center;font-weight:600">${b.scans}</td>
<td style="padding:var(--space-2) var(--space-3);text-align:center;font-weight:600;color:${b.registrations > 0 ? 'var(--c-success,#16a34a)' : 'inherit'}">${b.registrations}</td>
<td style="padding:var(--space-2) var(--space-3);text-align:right;white-space:nowrap">
<a class="btn btn-sm btn-secondary" href="/api/admin/partner/qr-batches/${b.id}/pdf" download>
${UI.icon('file-pdf')} PDF
</a>
<button class="btn btn-ghost btn-sm adm-qr-del text-danger" data-id="${b.id}" data-label="${UI.escape(b.label)}">
${UI.icon('trash')}
</button>
</td>
</tr>`).join('')}
</tbody>
</table>`}
</div>
<!-- Partner-Profil-Freigaben -->
<div class="by-card p-4">
<h3 style="margin:0 0 var(--space-3);font-size:var(--text-base)">
@ -2483,6 +2554,54 @@ window.Page_admin = (() => {
</div>
`;
// Code-Besitzer zuordnen (Self-Service-QR-Zugriff für den Partner)
el.querySelectorAll('.adm-code-owner').forEach(btn => {
btn.addEventListener('click', async () => {
const q = window.prompt('Benutzername des Partners (exakt):');
if (!q) return;
try {
const hits = await API.get(`/admin/users/search?q=${encodeURIComponent(q.trim())}`);
const hit = (hits || []).find(u => u.name.toLowerCase() === q.trim().toLowerCase()) || (hits || [])[0];
if (!hit) { UI.toast.warning('Kein User gefunden.'); return; }
await API.post(`/admin/partner/codes/${btn.dataset.id}/owner`, { user_id: hit.id });
UI.toast.success(`Code gehört jetzt ${hit.name} — er sieht seine QR-Kontingente im Partner-Profil.`);
await _renderPartner(el);
} catch (err) { UI.toast.error(err.message); }
});
});
// QR-Kontingent anlegen
el.querySelector('#adm-qr-create')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = e.target.querySelector('[type="submit"]');
const fd = UI.formData(e.target);
await UI.asyncButton(btn, async () => {
const b = await API.post(`/admin/partner/codes/${fd.code_id}/qr-batches`, {
label: fd.label,
quantity: parseInt(fd.quantity),
});
UI.toast.success(`Kontingent "${b.label}" mit ${b.quantity} QR-Codes erstellt.`);
await _renderPartner(el);
});
});
// QR-Kontingent löschen (Zweiklick-Pattern statt confirm — Memory: kein Modal-in-Modal)
el.querySelectorAll('.adm-qr-del').forEach(btn => {
btn.addEventListener('click', async () => {
if (btn.dataset.armed !== '1') {
btn.dataset.armed = '1';
btn.textContent = 'Wirklich löschen?';
setTimeout(() => { btn.dataset.armed = '0'; btn.innerHTML = UI.icon('trash'); }, 3000);
return;
}
try {
await API.del(`/admin/partner/qr-batches/${btn.dataset.id}`);
UI.toast.success(`Kontingent "${btn.dataset.label}" gelöscht.`);
await _renderPartner(el);
} catch (err) { UI.toast.error(err.message); }
});
});
// Partner-Profil-Vorschau auf-/zuklappen (.hidden hat !important → classList)
el.querySelectorAll('.adm-pp-preview').forEach(btn => {
btn.addEventListener('click', () => {

View file

@ -36,6 +36,8 @@ window.Page_partner_profil = (() => {
`;
}
let _qrBatches = [];
async function _load() {
const el = _container.querySelector('#pp-content');
try {
@ -43,6 +45,7 @@ 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) {
@ -178,6 +181,35 @@ 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="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-2) 0;border-bottom:1px solid var(--c-border)">
<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)}</div>
</div>
<div style="text-align:center;min-width:54px">
<div style="font-weight:700">${b.scans}</div>
<div class="text-xs-muted">Scans</div>
</div>
<div style="text-align:center;min-width:54px">
<div style="font-weight:700;color:${b.registrations > 0 ? 'var(--c-success,#16a34a)' : 'inherit'}">${b.registrations}</div>
<div class="text-xs-muted">Registr.</div>
</div>
<a class="btn btn-sm btn-secondary" href="/api/partner/my-qr/${b.id}/pdf" download>
${UI.icon('file-pdf')} PDF
</a>
</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">

View file

@ -2613,8 +2613,11 @@ window.Page_settings = (() => {
const partnerCode = (fd.partner_code || '').trim().toUpperCase() || undefined;
const refCode = _storedRefCode();
const finalCode = partnerCode || refCode || undefined;
const result = await API.auth.register(fd.email, fd.password, fd.name.trim(), finalCode);
// QR-Token mitschicken — Backend ordnet ihn nur zu, wenn er zum Code passt
const qrToken = (() => { try { return localStorage.getItem('by_qr_token') || undefined; } catch { return undefined; } })();
const result = await API.auth.register(fd.email, fd.password, fd.name.trim(), finalCode, qrToken);
if (refCode) _clearRefCode();
try { localStorage.removeItem('by_qr_token'); } catch {}
if (result.pending_verification) {
_renderVerifyPending(fd.email);

View file

@ -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=1256"></script>
<script src="/js/landing-init.js?v=1257"></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">

View file

@ -4,7 +4,7 @@
============================================================ */
// ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab
const VER = '1256';
const VER = '1257';
const CACHE_VERSION = `by-v${VER}`;
const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten