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.
306 lines
14 KiB
JavaScript
306 lines
14 KiB
JavaScript
/* ============================================================
|
||
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 1–2 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 };
|
||
|
||
})();
|