From bda3198c764c3427ba1f6c9c90efd117963c08d6 Mon Sep 17 00:00:00 2001 From: Dogfather Date: Thu, 20 Aug 2026 16:07:51 +0200 Subject: [PATCH] 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 --- assets/js/i18n-supporter.js | 26 +++ .../migrations/0005_supporter_polls.sql | 32 +++ server-internal/db.js | 10 +- server-internal/index.js | 12 ++ server-internal/lib/permissions.js | 3 +- server-internal/routes/polls.js | 178 ++++++++++++++++ supporter.html | 128 ++++++++++++ verwaltung.html | 192 ++++++++++++++++++ 8 files changed, 576 insertions(+), 5 deletions(-) create mode 100644 cloudflare-worker/migrations/0005_supporter_polls.sql create mode 100644 server-internal/routes/polls.js diff --git a/assets/js/i18n-supporter.js b/assets/js/i18n-supporter.js index 79b86c4e..c0def527 100644 --- a/assets/js/i18n-supporter.js +++ b/assets/js/i18n-supporter.js @@ -102,4 +102,30 @@ window.I18N_PAGE = { de: "Zur DogiCrew 🩵", "de-CH": "Zur DogiCrew 🩵", en: "Go to the DogiCrew 🩵", fr: "Aller à la DogiCrew 🩵", pt: "Ir para a DogiCrew 🩵", }, + + /* Supporter-Abstimmungen (20.08.2026) */ + su_poll_eyebrow: { + de: "🗳️ Aktuelle Abstimmung", "de-CH": "🗳️ Aktueui Abstimmig", + en: "🗳️ Current Poll", fr: "🗳️ Sondage en cours", pt: "🗳️ Enquete atual", + }, + su_poll_btn_abstimmen: { + de: "Abstimmen", "de-CH": "Abstimme", + en: "Vote", fr: "Voter", pt: "Votar", + }, + su_poll_bitte_waehlen: { + de: "Bitte wähle eine Antwort aus.", "de-CH": "Bitte wähl e Antwort us.", + en: "Please choose an answer.", fr: "Merci de choisir une réponse.", pt: "Por favor, escolha uma resposta.", + }, + su_poll_danke: { + de: "Danke, deine Stimme wurde gezählt! 💙", "de-CH": "Danke, dini Stimm isch zellt worde! 💙", + en: "Thanks, your vote was counted! 💙", fr: "Merci, ton vote a été pris en compte ! 💙", pt: "Obrigado, seu voto foi contado! 💙", + }, + su_poll_stimmen: { + de: "Stimmen", "de-CH": "Stimme", + en: "votes", fr: "votes", pt: "votos", + }, + su_poll_fehler: { + de: "Abstimmen hat leider nicht geklappt. Bitte versuch es nochmal.", "de-CH": "Abstimme het leider nid klappt. Bitte versuech's nomol.", + en: "Voting didn't work. Please try again.", fr: "Le vote a échoué. Merci de réessayer.", pt: "A votação falhou. Por favor, tente novamente.", + }, }; diff --git a/cloudflare-worker/migrations/0005_supporter_polls.sql b/cloudflare-worker/migrations/0005_supporter_polls.sql new file mode 100644 index 00000000..b6e308e4 --- /dev/null +++ b/cloudflare-worker/migrations/0005_supporter_polls.sql @@ -0,0 +1,32 @@ +-- ===================================================================== +-- Supporter-Abstimmungen (20.08.2026) — "nächste Ausbaustufe" aus +-- Dogfather_VanVan_Supporter_Abo.odt Abschnitt 20 (Bonus-Features, dort +-- bewusst nicht in den verbindlichen Abnahmekriterien Abschnitt 34, siehe +-- Supporter-Abo-System.md "Bewusst nicht Teil dieser ersten Ausbaustufe"). +-- Dogi erstellt in der Verwaltung eine Frage + Antwortoptionen auf Deutsch, +-- die anderen Sprachen werden wie bei "Event des Jahres" automatisch beim +-- Speichern übersetzt (lib/translate.js). Jeder DogiCrew-Supporter darf pro +-- Abstimmung genau einmal abstimmen (UNIQUE-Constraint statt Anwendungslogik +-- — robust auch bei gleichzeitigen Anfragen). +-- ===================================================================== + +CREATE TABLE IF NOT EXISTS supporter_polls ( + id TEXT PRIMARY KEY, + question TEXT NOT NULL, -- JSON {de, "de-CH", en, fr, pt} + options TEXT NOT NULL, -- JSON-Array von {de, "de-CH", en, fr, pt} + status TEXT NOT NULL DEFAULT 'active', -- active | closed + created_at TEXT NOT NULL, + created_by TEXT NOT NULL, + closed_at TEXT +); + +CREATE TABLE IF NOT EXISTS supporter_poll_votes ( + id TEXT PRIMARY KEY, + poll_id TEXT NOT NULL REFERENCES supporter_polls(id), + supporter_id TEXT NOT NULL REFERENCES supporter_users(id), + option_index INTEGER NOT NULL, + voted_at TEXT NOT NULL, + UNIQUE(poll_id, supporter_id) +); + +CREATE INDEX IF NOT EXISTS idx_supporter_poll_votes_poll ON supporter_poll_votes (poll_id); diff --git a/server-internal/db.js b/server-internal/db.js index 52e4e580..074fb9ba 100644 --- a/server-internal/db.js +++ b/server-internal/db.js @@ -1,10 +1,11 @@ /* ===================================================================== db.js — SQLite-Datenbank (better-sqlite3), Ersatz für Cloudflare D1. - Führt beim ersten Start alle vier Migrationen aus ../cloudflare-worker/migrations/ + Führt beim ersten Start alle Migrationen aus ../cloudflare-worker/migrations/ in der ursprünglichen Reihenfolge aus (0001 Basis-Schema, 0002 app_settings, - 0003 Supporter-Abo, 0004 TikTok/Discord-Spalten) — anders als bei VanVans Shop - ist hier NIE alles in einer konsolidierten schema.sql zusammengefasst worden, - deshalb müssen wir hier tatsächlich alle vier der Reihe nach anwenden. + 0003 Supporter-Abo, 0004 TikTok/Discord-Spalten, 0005 Supporter-Abstimmungen) + — anders als bei VanVans Shop ist hier NIE alles in einer konsolidierten + schema.sql zusammengefasst worden, deshalb müssen wir hier tatsächlich alle + der Reihe nach anwenden. ===================================================================== */ import Database from "better-sqlite3"; @@ -27,6 +28,7 @@ const MIGRATION_FILES = [ "0002_app_settings.sql", "0003_supporter_abo.sql", "0004_supporter_oauth_providers.sql", + "0005_supporter_polls.sql", ]; function alreadyApplied(name) { diff --git a/server-internal/index.js b/server-internal/index.js index 4e753a92..7e1458ce 100644 --- a/server-internal/index.js +++ b/server-internal/index.js @@ -64,6 +64,9 @@ import { listSupporters, getSupporterDetail, updatePremium, listCollections, upsertCollection, getAdminOverview, } from "./routes/supporter-admin.js"; import { getEventsPublic, saveEvent, uploadEventImage } from "./routes/events.js"; +import { + createPoll, listPolls, closePoll, reopenPoll, deletePoll, getActivePollPublic, voteOnPoll, +} from "./routes/polls.js"; initDb(); @@ -219,6 +222,13 @@ app.post("/admin/supporter/premium/update", requireTeamSession, withSession(upda app.post("/admin/supporter/collections/list", requireTeamSession, withSession(listCollections)); app.post("/admin/supporter/collections/upsert", requireTeamSession, withSession(upsertCollection)); +// Supporter-Abstimmungen (20.08.2026) +app.post("/admin/polls/list", requireTeamSession, withSession(listPolls)); +app.post("/admin/polls/create", requireTeamSession, withSession(createPoll)); +app.post("/admin/polls/close", requireTeamSession, withSession(closePoll)); +app.post("/admin/polls/reopen", requireTeamSession, withSession(reopenPoll)); +app.post("/admin/polls/delete", requireTeamSession, withSession(deletePoll)); + /* ---------- Öffentlicher Supporter-Bereich (eigene Session, requireSupporterAuth) ---------- */ app.post("/supporter/logout", requireSupporterAuth, wrap(logoutSupporter)); @@ -231,6 +241,8 @@ app.post("/supporter/delete-account", requireSupporterAuth, withSupporter(delete app.post("/supporter/paypal/start", requireSupporterAuth, withSupporter(startPaypalSubscription)); app.post("/supporter/paypal/confirm", requireSupporterAuth, withSupporter(confirmPaypalSubscription)); app.post("/supporter/paypal/cancel", requireSupporterAuth, withSupporter(cancelPaypalSubscription)); +app.post("/supporter/polls/active", requireSupporterAuth, withSupporter(getActivePollPublic)); +app.post("/supporter/polls/vote", requireSupporterAuth, withSupporter(voteOnPoll)); app.use((req, res) => json(res, { ok: false, error: "Not found." }, 404)); diff --git a/server-internal/lib/permissions.js b/server-internal/lib/permissions.js index 2d7d8320..b82dcfca 100644 --- a/server-internal/lib/permissions.js +++ b/server-internal/lib/permissions.js @@ -45,6 +45,7 @@ export const PERMISSIONS = { SUPPORTER_VIEW: "Supporter-Abonnenten ansehen", SUPPORTER_MANAGE_PREMIUMS: "Supporter-Prämien & Versand verwalten", SUPPORTER_MANAGE_COLLECTIONS: "Supporter-Kollektionen verwalten", + POLLS_MANAGE: "Supporter-Abstimmungen erstellen & auswerten", EVENTS_MANAGE: "Startseite: \"Event des Jahres\" verwalten", }; @@ -56,7 +57,7 @@ export const PERMISSION_GROUPS = [ { label: "Interne Notizen", keys: ["NOTES_READ", "NOTES_WRITE", "NOTES_EDIT_OWN", "NOTES_EDIT_OTHERS", "NOTES_DELETE"] }, { label: "Teamverwaltung", keys: ["TEAM_VIEW", "TEAM_PREPARE_USER", "TEAM_LOCK_USER", "TEAM_ASSIGN_ROLE", "TEAM_CREATE_ROLE", "TEAM_EDIT_PERMISSIONS"] }, { label: "Einstellungen", keys: ["SETTINGS_EDIT_FORMS", "SETTINGS_EDIT_STATUS_TYPES", "SETTINGS_VIEW_STATS", "SETTINGS_VIEW_ACTIVITY_LOG", "SETTINGS_EDIT_GENERAL"] }, - { label: "DogiCrew 🩵", keys: ["SUPPORTER_VIEW", "SUPPORTER_MANAGE_PREMIUMS", "SUPPORTER_MANAGE_COLLECTIONS"] }, + { label: "DogiCrew 🩵", keys: ["SUPPORTER_VIEW", "SUPPORTER_MANAGE_PREMIUMS", "SUPPORTER_MANAGE_COLLECTIONS", "POLLS_MANAGE"] }, { label: "Startseite", keys: ["EVENTS_MANAGE"] }, ]; diff --git a/server-internal/routes/polls.js b/server-internal/routes/polls.js new file mode 100644 index 00000000..f054b08d --- /dev/null +++ b/server-internal/routes/polls.js @@ -0,0 +1,178 @@ +/* ===================================================================== + routes/polls.js — Supporter-Abstimmungen (20.08.2026), "nächste + Ausbaustufe" aus Dogfather_VanVan_Supporter_Abo.odt Abschnitt 20. + + Admin-Seite (verwaltung.html): Frage + Antwortoptionen auf Deutsch + erstellen (POLLS_MANAGE-Recht, Owner immer erlaubt), automatische + Übersetzung wie bei "Event des Jahres" (lib/translate.js), Ergebnisse + live einsehen, Abstimmung schließen oder löschen. + + Supporter-Bereich: genau EINE aktive Abstimmung wird angezeigt (die + zuletzt erstellte), einmal Stimme abgeben (UNIQUE(poll_id, supporter_id) + in der Datenbank verhindert doppelte Stimmen robust, auch bei + gleichzeitigen Anfragen — keine reine Anwendungslogik-Prüfung). + ===================================================================== */ +import { db } from "../db.js"; +import { generateId, nowIso } from "../lib/crypto.js"; +import { hasPermission } from "../lib/permissions.js"; +import { json } from "../lib/http.js"; +import { logAction } from "../lib/audit.js"; +import { uebersetzeAlleSprachen } from "../lib/translate.js"; + +const MIN_OPTIONEN = 2; +const MAX_OPTIONEN = 6; + +function can(session, key) { + if (!session) return false; + if (session.isOwner) return true; + return hasPermission(session.permissions, session.overrides, key); +} + +function forbidden(res) { + return json(res, { ok: false, error: "Keine Berechtigung." }, 403); +} + +function parsePoll(row) { + return { + id: row.id, + question: JSON.parse(row.question), + options: JSON.parse(row.options), + status: row.status, + createdAt: row.created_at, + createdBy: row.created_by, + closedAt: row.closed_at, + }; +} + +function voteCountsFor(pollId, optionCount) { + const rows = db + .prepare(`SELECT option_index, COUNT(*) as n FROM supporter_poll_votes WHERE poll_id = ? GROUP BY option_index`) + .all(pollId); + const counts = new Array(optionCount).fill(0); + for (const r of rows) { + if (r.option_index >= 0 && r.option_index < optionCount) counts[r.option_index] = r.n; + } + return counts; +} + +/* ---------- Admin (interne Team-Session, POLLS_MANAGE) ---------- */ + +export async function createPoll(req, res, session) { + if (!can(session, "POLLS_MANAGE")) return forbidden(res); + const body = req.body || {}; + const fragestellungDe = String(body.frage || "").trim().slice(0, 200); + const optionenDe = Array.isArray(body.optionen) + ? body.optionen.map((o) => String(o || "").trim().slice(0, 100)).filter(Boolean) + : []; + + if (!fragestellungDe) return json(res, { ok: false, error: "Bitte eine Frage eingeben." }, 400); + if (optionenDe.length < MIN_OPTIONEN) { + return json(res, { ok: false, error: `Mindestens ${MIN_OPTIONEN} Antwortoptionen nötig.` }, 400); + } + if (optionenDe.length > MAX_OPTIONEN) { + return json(res, { ok: false, error: `Höchstens ${MAX_OPTIONEN} Antwortoptionen erlaubt.` }, 400); + } + + const question = await uebersetzeAlleSprachen(fragestellungDe); + const options = await Promise.all(optionenDe.map((o) => uebersetzeAlleSprachen(o))); + + const id = generateId(); + const jetzt = nowIso(); + db.prepare( + `INSERT INTO supporter_polls (id, question, options, status, created_at, created_by) + VALUES (?, ?, ?, 'active', ?, ?)` + ).run(id, JSON.stringify(question), JSON.stringify(options), jetzt, session.actor); + + logAction(session.actor, "polls.created", id, { frage: fragestellungDe }); + return json(res, { ok: true, poll: { id, question, options, status: "active", createdAt: jetzt, closedAt: null } }); +} + +export async function listPolls(req, res, session) { + if (!can(session, "POLLS_MANAGE")) return forbidden(res); + const rows = db.prepare(`SELECT * FROM supporter_polls ORDER BY created_at DESC`).all(); + const polls = rows.map((row) => { + const p = parsePoll(row); + const counts = voteCountsFor(p.id, p.options.length); + return { ...p, counts, totalVotes: counts.reduce((a, b) => a + b, 0) }; + }); + return json(res, { ok: true, polls }); +} + +export async function closePoll(req, res, session) { + if (!can(session, "POLLS_MANAGE")) return forbidden(res); + const id = String(req.body?.pollId || ""); + const row = db.prepare(`SELECT id, status FROM supporter_polls WHERE id = ?`).get(id); + if (!row) return json(res, { ok: false, error: "Abstimmung nicht gefunden." }, 404); + db.prepare(`UPDATE supporter_polls SET status = 'closed', closed_at = ? WHERE id = ?`).run(nowIso(), id); + logAction(session.actor, "polls.closed", id, null); + return json(res, { ok: true }); +} + +export async function reopenPoll(req, res, session) { + if (!can(session, "POLLS_MANAGE")) return forbidden(res); + const id = String(req.body?.pollId || ""); + const row = db.prepare(`SELECT id FROM supporter_polls WHERE id = ?`).get(id); + if (!row) return json(res, { ok: false, error: "Abstimmung nicht gefunden." }, 404); + db.prepare(`UPDATE supporter_polls SET status = 'active', closed_at = NULL WHERE id = ?`).run(id); + logAction(session.actor, "polls.reopened", id, null); + return json(res, { ok: true }); +} + +export async function deletePoll(req, res, session) { + if (!can(session, "POLLS_MANAGE")) return forbidden(res); + const id = String(req.body?.pollId || ""); + db.prepare(`DELETE FROM supporter_poll_votes WHERE poll_id = ?`).run(id); + const info = db.prepare(`DELETE FROM supporter_polls WHERE id = ?`).run(id); + if (info.changes === 0) return json(res, { ok: false, error: "Abstimmung nicht gefunden." }, 404); + logAction(session.actor, "polls.deleted", id, null); + return json(res, { ok: true }); +} + +/* ---------- Supporter-Bereich (eigene Session, jeder aktive Supporter) ---------- */ + +export async function getActivePollPublic(req, res, supporter) { + const row = db.prepare(`SELECT * FROM supporter_polls WHERE status = 'active' ORDER BY created_at DESC LIMIT 1`).get(); + if (!row) return json(res, { ok: true, poll: null }); + const poll = parsePoll(row); + const meineStimme = db + .prepare(`SELECT option_index FROM supporter_poll_votes WHERE poll_id = ? AND supporter_id = ?`) + .get(poll.id, supporter.id); + const antwort = { ok: true, poll: { id: poll.id, question: poll.question, options: poll.options }, myVote: meineStimme ? meineStimme.option_index : null }; + // Ergebnisse nur zeigen, wenn schon abgestimmt wurde (sonst könnte das + // Ergebnis die eigene Stimme beeinflussen) — dieselbe Logik wie bei + // öffentlichen Umfrage-Tools üblich. + if (meineStimme) { + const counts = voteCountsFor(poll.id, poll.options.length); + antwort.counts = counts; + antwort.totalVotes = counts.reduce((a, b) => a + b, 0); + } + return json(res, antwort); +} + +export async function voteOnPoll(req, res, supporter) { + const pollId = String(req.body?.pollId || ""); + const optionIndex = Number(req.body?.optionIndex); + const poll = db.prepare(`SELECT * FROM supporter_polls WHERE id = ? AND status = 'active'`).get(pollId); + if (!poll) return json(res, { ok: false, error: "Diese Abstimmung ist nicht mehr aktiv." }, 404); + const options = JSON.parse(poll.options); + if (!Number.isInteger(optionIndex) || optionIndex < 0 || optionIndex >= options.length) { + return json(res, { ok: false, error: "Ungültige Antwortoption." }, 400); + } + + try { + db.prepare( + `INSERT INTO supporter_poll_votes (id, poll_id, supporter_id, option_index, voted_at) VALUES (?, ?, ?, ?, ?)` + ).run(generateId(), pollId, supporter.id, optionIndex, nowIso()); + } catch (err) { + // UNIQUE(poll_id, supporter_id) verhindert Doppelstimmen — sauber statt + // als Serverfehler durchzureichen. + if (String(err?.message || "").includes("UNIQUE")) { + return json(res, { ok: false, error: "Du hast bei dieser Abstimmung bereits mitgemacht." }, 409); + } + throw err; + } + + logAction("system", "polls.voted", pollId, { supporterId: supporter.id, optionIndex }); + const counts = voteCountsFor(pollId, options.length); + return json(res, { ok: true, counts, totalVotes: counts.reduce((a, b) => a + b, 0) }); +} diff --git a/supporter.html b/supporter.html index 83e956f9..6206a526 100644 --- a/supporter.html +++ b/supporter.html @@ -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 @@
+ + +

Mein Abo

@@ -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 `
+
+ ${istMeine ? "✓ " : ""}${escapeHtmlPoll(feldSprache(opt))} + ${n} ${escapeHtmlPoll(feldSprache(window.I18N_PAGE?.su_poll_stimmen))} · ${proz}% +
+
+
`; + }) + .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) => ``) + .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 diff --git a/verwaltung.html b/verwaltung.html index 6580d9d8..90fcd95f 100644 --- a/verwaltung.html +++ b/verwaltung.html @@ -529,6 +529,12 @@ transition: width .5s ease; } @keyframes abo-progress-shine { 0%,100% { background-position: 0% 50%; } 50% { background-position: 100% 50%; } } + /* Supporter-Abstimmungen (20.08.2026) */ + .vw-poll-option { margin-bottom: .8rem; } + .vw-poll-option:last-child { margin-bottom: 0; } + .vw-poll-option-head { display: flex; justify-content: space-between; gap: 1rem; font-size: .88rem; margin-bottom: .3rem; } + .vw-poll-bar-track { height: 12px; border-radius: 999px; background: rgba(255,255,255,.06); overflow: hidden; border: 1px solid var(--border); } + .vw-poll-bar-fill { height: 100%; border-radius: 999px; background: linear-gradient(90deg, #8b5cf6, #ff6fa5); box-shadow: 0 0 10px rgba(139,92,246,.4); transition: width .4s ease; } .vw-progress-celebrate { margin-top: .8rem; padding: .7rem 1rem; border-radius: 12px; font-size: .87rem; font-weight: 700; background: rgba(56,189,248,.1); border: 1px solid rgba(56,189,248,.35); color: #7dd3fc; @@ -790,6 +796,41 @@
+ +
+
+ 🗳️ Abstimmungen +
+ Anzeigen â–ľ +
+
+
+

Neue Abstimmung erstellen

+

+ Frage und Optionen bitte auf Deutsch eintippen — die anderen Sprachen werden beim + Speichern automatisch übersetzt. Erscheint für jede aktive DogiCrew-Person in ihrem + Supporter-Bereich, bis sie abgestimmt hat oder du sie schließt. +

+
+ + +
+
+ +
+ +
+

+
+
+
+
+