Neue Funktion: Supporter-Abstimmungen (Verwaltung + DogiCrew-Bereich)
Naechste "Ausbaustufe" aus Dogfather_VanVan_Supporter_Abo.odt Abschnitt 20 (siehe Supporter-Abo-System.md), auf Nutzerwunsch "perfektioniere meine Verwaltungsseite": Dogi/VanVan koennen in verwaltung.html eine Frage mit 2-6 Antwortoptionen auf Deutsch erstellen, automatische Uebersetzung beim Speichern (gleiches Muster wie "Event des Jahres"). Jede aktive DogiCrew- Person sieht die Abstimmung in ihrem Supporter-Bereich, stimmt genau einmal ab (UNIQUE-Constraint in der DB, nicht nur Anwendungslogik), sieht danach die Live-Ergebnisse. Admin-Seite zeigt Ergebnisbalken live, kann schliessen/ wiedereroeffnen/loeschen. - Neue Migration 0005_supporter_polls.sql (supporter_polls, supporter_poll_votes), neue Berechtigung POLLS_MANAGE. - server-internal/routes/polls.js: 30 End-to-End-Tests gegen eine Fake-DB bestanden (better-sqlite3 laesst sich lokal nicht kompilieren). - verwaltung.html: neue "Abstimmungen"-Kiste im bestehenden vw-overview-box-Stil (violett/pink Ergebnisbalken). - supporter.html: neue "Aktuelle Abstimmung"-Karte im bestehenden Gold-Look, Optionen -> Stimme -> Ergebnisbalken, alle 5 Sprachen. Backend-Teil (server-internal/, cloudflare-worker/migrations/) noch ohne Deploy-Zugriff -- Dogi muss ihn manuell auf dogiintern ausrollen. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
+128
@@ -42,6 +42,22 @@
|
||||
.abo-erfolg { color: #baf7d6; font-size: .88rem; margin-top: .5rem; display: none; }
|
||||
.abo-erfolg.zeige { display: block; }
|
||||
|
||||
/* Supporter-Abstimmungen (20.08.2026) — im selben Gold-Ton wie der Rest
|
||||
des Supporter-Bereichs, keine eigene Farbwelt. */
|
||||
.poll-option-btn {
|
||||
display: block; width: 100%; text-align: left; padding: .8rem 1rem; margin-bottom: .6rem;
|
||||
border-radius: 10px; border: 1.5px solid var(--border); background: rgba(255,255,255,.03);
|
||||
color: var(--text); font: inherit; cursor: pointer; transition: border-color .15s ease, background .15s ease;
|
||||
}
|
||||
.poll-option-btn:hover { border-color: var(--accent-2); }
|
||||
.poll-option-btn.gewaehlt { border-color: var(--accent-2); background: rgba(212,175,55,.14); font-weight: 700; }
|
||||
.poll-result-row { margin-bottom: .8rem; }
|
||||
.poll-result-row:last-child { margin-bottom: 0; }
|
||||
.poll-result-head { display: flex; justify-content: space-between; gap: 1rem; font-size: .88rem; margin-bottom: .3rem; }
|
||||
.poll-result-head.meine-stimme { color: #ffe9a8; font-weight: 800; }
|
||||
.poll-bar-track { height: 12px; border-radius: 999px; background: rgba(255,255,255,.06); overflow: hidden; border: 1px solid var(--border); }
|
||||
.poll-bar-fill { height: 100%; border-radius: 999px; background: linear-gradient(90deg, var(--accent-2), var(--accent)); transition: width .4s ease; }
|
||||
|
||||
/* Gemeinsamer Karten-Rahmen mit Gold-Schimmerlinie oben — dieselbe
|
||||
Machart wie .abo-preis-box auf abonnieren.html, damit der Bereich
|
||||
erkennbar zusammengehört. */
|
||||
@@ -323,6 +339,18 @@
|
||||
<div class="container">
|
||||
<div id="supp-kuendigungs-hinweis" class="supp-hinweis" style="display:none;"></div>
|
||||
|
||||
<!-- Supporter-Abstimmungen (20.08.2026) — nur sichtbar, wenn gerade
|
||||
eine Abstimmung aktiv ist bzw. noch nicht abgestimmt wurde, siehe
|
||||
ladePoll() weiter unten. -->
|
||||
<div class="card supp-card" id="supp-poll-card" style="display:none;margin-bottom:2.4rem;">
|
||||
<span class="eyebrow" data-i18n="su_poll_eyebrow">🗳️ Aktuelle Abstimmung</span>
|
||||
<h3 id="poll-frage-text" style="margin:.5rem 0 1.1rem;">–</h3>
|
||||
<div id="poll-optionen-container"></div>
|
||||
<button type="button" class="btn btn-primary" id="poll-abstimmen-btn" style="margin-top:.4rem;" data-i18n="su_poll_btn_abstimmen">Abstimmen</button>
|
||||
<p class="abo-erfolg" id="poll-erfolg" data-i18n="su_poll_danke">Danke, deine Stimme wurde gezählt! 💙</p>
|
||||
<p class="abo-fehler" id="poll-fehler"></p>
|
||||
</div>
|
||||
|
||||
<h2 data-i18n="su_fenster1_h2">Mein Abo</h2>
|
||||
<div class="card supp-card">
|
||||
<div class="supp-stats">
|
||||
@@ -600,8 +628,108 @@
|
||||
// Profil-Formular vorbefüllen
|
||||
document.getElementById("edit-name").value = data.profile.displayName || "";
|
||||
document.getElementById("edit-tiktok").value = data.profile.tiktokUsername || "";
|
||||
|
||||
// Supporter-Abstimmungen (20.08.2026) — nur mit echtem Token abrufen,
|
||||
// nicht im Vorschau-Modus mit Beispieldaten (kein API-Aufruf gewollt).
|
||||
if (!nurMockNoetig) await ladePoll();
|
||||
else document.getElementById("supp-poll-card").style.display = "none";
|
||||
}
|
||||
|
||||
/* ---------- Supporter-Abstimmungen (20.08.2026) ----------
|
||||
Zeigt die aktuell aktive Abstimmung (falls vorhanden). Vor der eigenen
|
||||
Stimme: nur die Antwortoptionen zum Anklicken, keine Ergebnisse (damit
|
||||
sie die eigene Wahl nicht beeinflussen). Nach der Stimme (auch bei
|
||||
einem erneuten Seitenaufruf, dank myVote vom Server): Ergebnisbalken
|
||||
statt Auswahl-Buttons. */
|
||||
const feldSprache = (mehrsprachigesFeld) =>
|
||||
window.dogiFeld ? window.dogiFeld(mehrsprachigesFeld) : mehrsprachigesFeld?.de || "";
|
||||
|
||||
let aktuellePoll = null;
|
||||
let gewaehlteOption = null;
|
||||
|
||||
function renderPollErgebnisse(poll, counts, totalVotes, meineOption) {
|
||||
const container = document.getElementById("poll-optionen-container");
|
||||
container.innerHTML = poll.options
|
||||
.map((opt, i) => {
|
||||
const n = counts?.[i] || 0;
|
||||
const proz = totalVotes ? Math.round((n / totalVotes) * 100) : 0;
|
||||
const istMeine = i === meineOption;
|
||||
return `<div class="poll-result-row">
|
||||
<div class="poll-result-head${istMeine ? " meine-stimme" : ""}">
|
||||
<span>${istMeine ? "✓ " : ""}${escapeHtmlPoll(feldSprache(opt))}</span>
|
||||
<span>${n} ${escapeHtmlPoll(feldSprache(window.I18N_PAGE?.su_poll_stimmen))} · ${proz}%</span>
|
||||
</div>
|
||||
<div class="poll-bar-track"><div class="poll-bar-fill" style="width:${proz}%;"></div></div>
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
document.getElementById("poll-abstimmen-btn").style.display = "none";
|
||||
}
|
||||
|
||||
function escapeHtmlPoll(text) {
|
||||
return String(text ?? "").replace(/[&<>"']/g, (z) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[z]));
|
||||
}
|
||||
|
||||
function renderPollAuswahl(poll) {
|
||||
const container = document.getElementById("poll-optionen-container");
|
||||
gewaehlteOption = null;
|
||||
container.innerHTML = poll.options
|
||||
.map((opt, i) => `<button type="button" class="poll-option-btn" data-option-index="${i}">${escapeHtmlPoll(feldSprache(opt))}</button>`)
|
||||
.join("");
|
||||
container.querySelectorAll(".poll-option-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
gewaehlteOption = Number(btn.dataset.optionIndex);
|
||||
container.querySelectorAll(".poll-option-btn").forEach((b) => b.classList.toggle("gewaehlt", b === btn));
|
||||
});
|
||||
});
|
||||
document.getElementById("poll-abstimmen-btn").style.display = "";
|
||||
}
|
||||
|
||||
async function ladePoll() {
|
||||
const karte = document.getElementById("supp-poll-card");
|
||||
let res;
|
||||
try {
|
||||
res = await S.apiFetch("/supporter/polls/active", {}, true);
|
||||
} catch {
|
||||
karte.style.display = "none";
|
||||
return;
|
||||
}
|
||||
if (!res.poll) {
|
||||
karte.style.display = "none";
|
||||
return;
|
||||
}
|
||||
aktuellePoll = res.poll;
|
||||
karte.style.display = "";
|
||||
document.getElementById("poll-frage-text").textContent = feldSprache(res.poll.question);
|
||||
document.getElementById("poll-erfolg").classList.remove("zeige");
|
||||
document.getElementById("poll-fehler").classList.remove("zeige");
|
||||
|
||||
if (res.myVote !== null && res.myVote !== undefined) {
|
||||
renderPollErgebnisse(res.poll, res.counts, res.totalVotes, res.myVote);
|
||||
document.getElementById("poll-erfolg").classList.add("zeige");
|
||||
} else {
|
||||
renderPollAuswahl(res.poll);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("poll-abstimmen-btn").addEventListener("click", async () => {
|
||||
const fehlerEl = document.getElementById("poll-fehler");
|
||||
fehlerEl.classList.remove("zeige");
|
||||
if (gewaehlteOption === null || !aktuellePoll) {
|
||||
fehlerEl.textContent = feldSprache(window.I18N_PAGE?.su_poll_bitte_waehlen) || "Bitte wähle eine Antwort aus.";
|
||||
fehlerEl.classList.add("zeige");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await S.apiFetch("/supporter/polls/vote", { pollId: aktuellePoll.id, optionIndex: gewaehlteOption }, true);
|
||||
renderPollErgebnisse(aktuellePoll, res.counts, res.totalVotes, gewaehlteOption);
|
||||
document.getElementById("poll-erfolg").classList.add("zeige");
|
||||
} catch (err) {
|
||||
fehlerEl.textContent = err.message || "Abstimmen hat leider nicht geklappt. Bitte versuch es nochmal.";
|
||||
fehlerEl.classList.add("zeige");
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------- Detail-Modals für die klickbaren Kennzahl-Kärtchen ----------
|
||||
Nutzer-Wunsch 04.08.2026: "wenn man auf diese 2 Kisten drückt, sollen
|
||||
die viel mehr Infos kriegen ... richtig geil". Recherche, was für
|
||||
|
||||
Reference in New Issue
Block a user