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:
parent
cadfb24a8d
commit
f604ab7c4f
16 changed files with 621 additions and 23 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue