Feature: Upgrade-Anfragen-System — User-Flow + Admin-Panel (SW by-v920)

- DB: upgrade_requests-Tabelle (user_id, tier, message, fulfilled_at)
- POST /api/upgrade-request: Anfrage speichern + Admin-Benachrichtigungsmail
- GET/POST /api/admin/upgrade-requests[/{id}/fulfill]: Admin-Endpunkte
  — fulfill setzt subscription_tier + sendet Bestätigungsmail an User
- action-items: upgrades_pending zählt offene Anfragen → Badge im Admin
- Admin-Tab "Upgrades": Tabelle offener/erledigter Anfragen, Freischalten-Button
  mit Confirm-Modal, automatischer Tier-Setzung und Bestätigungsmail
- Settings: Upgrade-Modal sendet echte API-Anfrage statt nur mailto
  — doppelte Anfrage wird erkannt (already:true → Toast statt Fehler)
- api.js: API.auth.upgradeRequest(tier, message) hinzugefügt
- SW by-v920, APP_VER 920
This commit is contained in:
rene 2026-05-14 09:59:11 +02:00
parent d61fd155c5
commit f6b37717b4
9 changed files with 268 additions and 27 deletions

View file

@ -26,6 +26,7 @@ window.Page_admin = (() => {
{ id: 'hilfe', label: 'Hilfe/FAQ', icon: 'question' },
{ id: 'uebungen_admin', label: 'Übungen', icon: 'barbell' },
{ id: 'referrals', label: 'Referrals', icon: 'share-network' },
{ id: 'upgrades', label: 'Upgrades', icon: 'crown-simple' },
];
// ------------------------------------------------------------------
@ -90,6 +91,7 @@ window.Page_admin = (() => {
try { d = await API.get('/admin/action-items'); } catch { return; }
const items = [
{ key: 'upgrades_pending', label: 'Upgrade-Anfragen', tab: 'upgrades', icon: 'crown-simple' },
{ key: 'jobs_pending', label: 'Bewerbungen', tab: 'bewerbungen', icon: 'user-plus' },
{ key: 'breeder_pending', label: 'Züchter-Anträge', tab: 'zuchter', icon: 'certificate' },
{ key: 'reports_open', label: 'Meldungen', tab: 'moderation', icon: 'warning' },
@ -163,6 +165,7 @@ window.Page_admin = (() => {
case 'hilfe': await _renderHilfe(el); break;
case 'uebungen_admin': await _renderUebungenAdmin(el); break;
case 'referrals': await _renderReferrals(el); break;
case 'upgrades': await _renderUpgrades(el); break;
}
} catch (e) {
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.');
@ -3419,6 +3422,104 @@ window.Page_admin = (() => {
</div>`;
}
// ------------------------------------------------------------------
// TAB: UPGRADES
// ------------------------------------------------------------------
async function _renderUpgrades(el) {
const rows = await API.get('/admin/upgrade-requests');
const tierBadge = t => {
const cfg = { pro: ['Pro', '#16a34a'], breeder: ['Züchter', '#C4843A'] };
const [label, color] = cfg[t] || [t, '#888'];
return `<span style="display:inline-block;padding:1px 8px;border-radius:999px;
font-size:11px;font-weight:700;background:${color};color:#fff">${label}</span>`;
};
const pending = rows.filter(r => !r.fulfilled_at);
const done = rows.filter(r => r.fulfilled_at);
const _row = (r, showBtn) => `
<tr>
<td style="padding:var(--space-2) var(--space-3)">${_esc(r.name)}<br>
<span style="font-size:var(--text-xs);color:var(--c-text-muted)">${_esc(r.email)}</span></td>
<td style="padding:var(--space-2) var(--space-3)">${tierBadge(r.tier)}</td>
<td style="padding:var(--space-2) var(--space-3);font-size:var(--text-xs);color:var(--c-text-muted)">
${r.message ? _esc(r.message) : '—'}</td>
<td style="padding:var(--space-2) var(--space-3);font-size:var(--text-xs);color:var(--c-text-muted)">
${r.created_at?.slice(0,10) || ''}</td>
<td style="padding:var(--space-2) var(--space-3)">
${showBtn
? `<button class="btn btn-sm adm-fulfill-btn" data-id="${r.id}" data-name="${_esc(r.name)}" data-tier="${r.tier}"
style="background:#16a34a;color:#fff;border:none;padding:4px 12px;
border-radius:var(--radius-md);cursor:pointer;font-size:var(--text-xs);font-weight:600">
Freischalten
</button>`
: `<span style="font-size:var(--text-xs);color:var(--c-success)">✓ ${r.fulfilled_at?.slice(0,10)}</span>`}
</td>
</tr>`;
const thead = `<thead><tr>
${['Nutzer','Tarif','Nachricht','Datum','Aktion'].map(h =>
`<th style="padding:var(--space-2) var(--space-3);text-align:left;font-size:var(--text-xs);
color:var(--c-text-muted);font-weight:600;border-bottom:1px solid var(--c-border)">${h}</th>`
).join('')}</tr></thead>`;
el.innerHTML = `
<div class="card adm-table-card" style="margin-bottom:var(--space-4)">
<div class="by-card-section-header">Offene Anfragen (${pending.length})</div>
<div class="adm-table-scroll">
<table class="adm-table" style="width:100%;border-collapse:collapse">
${thead}
<tbody>
${pending.length
? pending.map(r => _row(r, true)).join('')
: `<tr><td colspan="5" style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">
Keine offenen Anfragen
</td></tr>`}
</tbody>
</table>
</div>
</div>
${done.length ? `
<div class="card adm-table-card">
<div class="by-card-section-header">Erledigt (${done.length})</div>
<div class="adm-table-scroll">
<table class="adm-table" style="width:100%;border-collapse:collapse">
${thead}
<tbody>${done.map(r => _row(r, false)).join('')}</tbody>
</table>
</div>
</div>` : ''}`;
el.querySelectorAll('.adm-fulfill-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const { id, name, tier } = btn.dataset;
const tierLabel = { pro: 'Pro', breeder: 'Züchter' }[tier] || tier;
const ok = await UI.modal.confirm({
title: `${name} auf ${tierLabel} freischalten?`,
body: `<p style="font-size:var(--text-sm)">
Der Account wird auf <strong>${tierLabel}</strong> gesetzt und
eine Bestätigungsmail gesendet.
</p>`,
confirmLabel: 'Freischalten',
danger: false,
});
if (!ok) return;
btn.disabled = true;
btn.textContent = '…';
try {
const res = await API.post(`/admin/upgrade-requests/${id}/fulfill`);
UI.toast.success(`${res.user} wurde auf ${tierLabel} freigeschaltet.`);
_renderTab();
} catch (e) {
UI.toast.error(e.message);
btn.disabled = false;
btn.textContent = 'Freischalten';
}
});
});
}
return { init, refresh, onDogChange };
})();