banyaro/backend/static/js/pages/partner-profil.js
rene f604ab7c4f 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.
2026-06-07 18:20:23 +02:00

306 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ============================================================
BAN YARO — Partner-Profil-Editor
Nur für User mit is_partner=1.
============================================================ */
window.Page_partner_profil = (() => {
let _container = null;
let _profile = null;
async function init(container, appState) {
_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)">
Mein Partner-Profil
</h1>
<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.
</p>
</div>
<div id="pp-content">
<div style="text-align:center;padding:var(--space-8);color:var(--c-text-muted)">Lade…</div>
</div>
</div>
`;
}
let _qrBatches = [];
async function _load() {
const el = _container.querySelector('#pp-content');
try {
const d = await API.get('/partner/my-profile');
_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) {
el.innerHTML = `<p class="text-danger">${e.message}</p>`;
}
}
function _statusBadge() {
if (!_profile?.submitted_at && !_profile?.approved) return '';
const a = _profile.approved;
if (a === 1) return `<span style="background:#dcfce7;color:#16a34a;padding:3px 10px;border-radius:999px;font-size:var(--text-xs);font-weight:700">✓ Freigegeben</span>`;
if (a === -1) return `<span style="background:#fee2e2;color:#dc2626;padding:3px 10px;border-radius:999px;font-size:var(--text-xs);font-weight:700">✗ Abgelehnt</span>`;
if (_profile.submitted_at) return `<span style="background:#fef9c3;color:#a16207;padding:3px 10px;border-radius:999px;font-size:var(--text-xs);font-weight:700">⏳ In Prüfung</span>`;
return `<span style="color:var(--c-text-muted);font-size:var(--text-xs)">Entwurf</span>`;
}
function _renderEditor() {
const p = _profile || {};
const photos = p.photos || [];
return `
<!-- Status -->
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-4)">
<span class="text-sm-muted">Status:</span>
${_statusBadge() || '<span style="color:var(--c-text-muted);font-size:var(--text-xs)">Noch kein Profil angelegt</span>'}
</div>
<!-- Logo -->
<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-3)">Logo</div>
<div style="display:flex;align-items:center;gap:var(--space-4)">
<div id="pp-logo-preview" style="width:80px;height:80px;border-radius:var(--radius-md);
background:var(--c-surface-2);display:flex;align-items:center;justify-content:center;
overflow:hidden;flex-shrink:0">
${p.logo_url
? `<img src="${UI.escape(p.logo_url)}" style="width:100%;height:100%;object-fit:contain">`
: `<svg class="ph-icon" style="width:32px;height:32px;opacity:.3"><use href="/icons/phosphor.svg#image"></use></svg>`}
</div>
<div>
<label class="btn btn-secondary btn-sm" style="cursor:pointer">
Logo hochladen
<input type="file" id="pp-logo-input" accept="image/*" class="hidden">
</label>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-1)">
PNG, JPG oder WebP · max. 5 MB · wird quadratisch zugeschnitten
</div>
</div>
</div>
</div>
<!-- Texte -->
<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-3)">Texte</div>
<form id="pp-text-form" class="flex-col-gap-3">
<div class="form-group">
<label class="form-label">Anzeigename *</label>
<input class="form-control" name="display_name" type="text" maxlength="60" required
placeholder="z. B. Hundeblog Musterfrau"
value="${UI.escape(p.display_name || '')}">
</div>
<div class="form-group">
<label class="form-label">Kurzslogan <span style="font-weight:400;color:var(--c-text-muted)">(max. 80 Zeichen)</span></label>
<input class="form-control" name="tagline" type="text" maxlength="80"
placeholder="z. B. Hundetrainerin · 15.000 Follower auf Instagram"
value="${UI.escape(p.tagline || '')}">
</div>
<div class="form-group">
<label class="form-label">Über dich / euer Kanal</label>
<textarea class="form-control" name="bio" rows="4" maxlength="500"
placeholder="Wer bist du, was machst du, was verbindet dich mit Hunden?">${UI.escape(p.bio || p.pp_bio || '')}</textarea>
</div>
<div class="form-group">
<label class="form-label">Website</label>
<input class="form-control" name="website" type="url"
placeholder="https://deine-seite.de"
value="${UI.escape(p.website || '')}">
</div>
<div class="form-group">
<label class="form-label">Instagram</label>
<input class="form-control" name="instagram" type="text"
placeholder="@deinkanal"
value="${UI.escape(p.instagram || '')}">
</div>
<button type="submit" class="btn btn-secondary btn-sm" style="align-self:flex-start">
Texte speichern
</button>
</form>
</div>
<!-- Fotos -->
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-3)">
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:var(--space-2)">
<div style="font-size:var(--text-xs);font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--c-text-muted)">
Fotos & Videos <span style="font-weight:400">(max. 6)</span>
</div>
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-2)">
JPG, PNG, HEIC, MP4, MOV · max. 200 MB pro Datei
</div>
<div class="mb-3">
${_storageBar(p._storage_mb || 0, p._storage_limit_mb || 200)}
</div>
<div id="pp-photos-grid" style="display:grid;grid-template-columns:repeat(3,1fr);
gap:var(--space-2);margin-bottom:var(--space-3)">
${photos.map((url, i) => {
const isVid = url.endsWith('.mp4') || url.endsWith('.webm');
return `
<div style="position:relative;aspect-ratio:1;border-radius:var(--radius-md);overflow:hidden;
background:var(--c-surface-2)">
${isVid
? `<video src="${UI.escape(url)}" style="width:100%;height:100%;object-fit:cover" muted playsinline loop
data-hover-play></video>
<div style="position:absolute;bottom:4px;left:4px;background:rgba(0,0,0,.55);
border-radius:4px;padding:1px 5px;font-size:10px;color:#fff">▶ Video</div>`
: `<img src="${UI.escape(url)}" style="width:100%;height:100%;object-fit:cover">`}
<button class="pp-photo-del" data-idx="${i}"
style="position:absolute;top:4px;right:4px;background:rgba(0,0,0,.6);
border:none;border-radius:50%;width:24px;height:24px;cursor:pointer;
color:#fff;font-size:14px;display:flex;align-items:center;justify-content:center">
×
</button>
</div>`;
}).join('')}
${photos.length < 6 ? `
<label style="aspect-ratio:1;border-radius:var(--radius-md);border:2px dashed var(--c-border);
display:flex;align-items:center;justify-content:center;cursor:pointer;
color:var(--c-text-muted);flex-direction:column;gap:4px">
<svg class="ph-icon" style="width:24px;height:24px"><use href="/icons/phosphor.svg#plus"></use></svg>
<span style="font-size:10px">Foto</span>
<input type="file" id="pp-photo-input" accept="image/*,video/*" class="hidden">
</label>` : ''}
</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">
Zur Freigabe einreichen
</button>
</div>
`;
}
function _bindEvents(el) {
// Logo hochladen
el.querySelector('#pp-logo-input')?.addEventListener('change', async e => {
const file = e.target.files[0];
if (!file) return;
const fd = new FormData();
fd.append('file', file);
try {
const r = await API.upload('/partner/my-profile/logo', fd);
el.querySelector('#pp-logo-preview').innerHTML =
`<img src="${UI.escape(r.logo_url)}" style="width:100%;height:100%;object-fit:contain">`;
_profile = { ..._profile, logo_url: r.logo_url };
UI.toast.success('Logo gespeichert.');
} catch (err) { UI.toast.error(err.message); }
});
// Texte speichern
el.querySelector('#pp-text-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = e.target.querySelector('[type="submit"]');
const fd = UI.formData(e.target);
await UI.asyncButton(btn, async () => {
await API.put('/partner/my-profile', fd);
_profile = { ..._profile, ...fd };
UI.toast.success('Gespeichert.');
});
});
// Foto/Video hochladen
el.querySelector('#pp-photo-input')?.addEventListener('change', async e => {
const file = e.target.files[0];
if (!file) return;
const isVideo = file.type.startsWith('video/');
const fd = new FormData();
fd.append('file', file);
if (isVideo) UI.toast.info('Video wird hochgeladen und komprimiert das kann 12 Minuten dauern …', 120_000);
try {
const r = await API.upload('/partner/my-profile/photos', fd);
_profile = { ..._profile, photos: r.photos };
await _load();
UI.toast.success(isVideo ? 'Video hinzugefügt.' : 'Foto hinzugefügt.');
} catch (err) { UI.toast.error(err.message); }
});
// Foto löschen
el.querySelectorAll('.pp-photo-del').forEach(btn => {
btn.addEventListener('click', async () => {
const idx = parseInt(btn.dataset.idx);
try {
const r = await API.post(`/partner/my-profile/photos/${idx}/delete`, {});
_profile = { ..._profile, photos: r.photos };
await _load();
} catch (err) { UI.toast.error(err.message); }
});
});
// Einreichen
el.querySelector('#pp-submit-btn')?.addEventListener('click', async () => {
const btn = el.querySelector('#pp-submit-btn');
await UI.asyncButton(btn, async () => {
await API.post('/partner/my-profile/submit', {});
UI.toast.success('Eingereicht! Wir prüfen dein Profil und schalten es bald frei.');
await _load();
});
});
}
function _storageBar(usedMb, limitMb) {
const pct = Math.min(100, Math.round((usedMb / limitMb) * 100));
const color = pct > 85 ? '#dc2626' : pct > 60 ? '#f59e0b' : '#22c55e';
return `
<div style="display:flex;align-items:center;gap:var(--space-2);font-size:var(--text-xs);color:var(--c-text-muted)">
<div style="flex:1;height:4px;background:var(--c-surface-2);border-radius:2px;overflow:hidden">
<div style="width:${pct}%;height:100%;background:${color};border-radius:2px;transition:width .4s"></div>
</div>
<span style="white-space:nowrap;color:${pct > 85 ? '#dc2626' : 'var(--c-text-muted)'}">
${usedMb.toFixed(1)} / ${limitMb} MB
</span>
</div>`;
}
return { init, refresh, onDogChange };
})();