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:
2026-08-20 16:07:51 +02:00
co-authored by Claude Opus 5
parent 189b72ec73
commit bda3198c76
8 changed files with 576 additions and 5 deletions
+26
View File
@@ -102,4 +102,30 @@ window.I18N_PAGE = {
de: "Zur DogiCrew 🩵", "de-CH": "Zur DogiCrew 🩵", de: "Zur DogiCrew 🩵", "de-CH": "Zur DogiCrew 🩵",
en: "Go to the DogiCrew 🩵", fr: "Aller à la DogiCrew 🩵", pt: "Ir para a 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.",
},
}; };
@@ -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);
+6 -4
View File
@@ -1,10 +1,11 @@
/* ===================================================================== /* =====================================================================
db.js — SQLite-Datenbank (better-sqlite3), Ersatz für Cloudflare D1. 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, in der ursprünglichen Reihenfolge aus (0001 Basis-Schema, 0002 app_settings,
0003 Supporter-Abo, 0004 TikTok/Discord-Spalten) — anders als bei VanVans Shop 0003 Supporter-Abo, 0004 TikTok/Discord-Spalten, 0005 Supporter-Abstimmungen)
ist hier NIE alles in einer konsolidierten schema.sql zusammengefasst worden, — anders als bei VanVans Shop ist hier NIE alles in einer konsolidierten
deshalb müssen wir hier tatsächlich alle vier der Reihe nach anwenden. schema.sql zusammengefasst worden, deshalb müssen wir hier tatsächlich alle
der Reihe nach anwenden.
===================================================================== */ ===================================================================== */
import Database from "better-sqlite3"; import Database from "better-sqlite3";
@@ -27,6 +28,7 @@ const MIGRATION_FILES = [
"0002_app_settings.sql", "0002_app_settings.sql",
"0003_supporter_abo.sql", "0003_supporter_abo.sql",
"0004_supporter_oauth_providers.sql", "0004_supporter_oauth_providers.sql",
"0005_supporter_polls.sql",
]; ];
function alreadyApplied(name) { function alreadyApplied(name) {
+12
View File
@@ -64,6 +64,9 @@ import {
listSupporters, getSupporterDetail, updatePremium, listCollections, upsertCollection, getAdminOverview, listSupporters, getSupporterDetail, updatePremium, listCollections, upsertCollection, getAdminOverview,
} from "./routes/supporter-admin.js"; } from "./routes/supporter-admin.js";
import { getEventsPublic, saveEvent, uploadEventImage } from "./routes/events.js"; import { getEventsPublic, saveEvent, uploadEventImage } from "./routes/events.js";
import {
createPoll, listPolls, closePoll, reopenPoll, deletePoll, getActivePollPublic, voteOnPoll,
} from "./routes/polls.js";
initDb(); 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/list", requireTeamSession, withSession(listCollections));
app.post("/admin/supporter/collections/upsert", requireTeamSession, withSession(upsertCollection)); 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) ---------- */ /* ---------- Öffentlicher Supporter-Bereich (eigene Session, requireSupporterAuth) ---------- */
app.post("/supporter/logout", requireSupporterAuth, wrap(logoutSupporter)); 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/start", requireSupporterAuth, withSupporter(startPaypalSubscription));
app.post("/supporter/paypal/confirm", requireSupporterAuth, withSupporter(confirmPaypalSubscription)); app.post("/supporter/paypal/confirm", requireSupporterAuth, withSupporter(confirmPaypalSubscription));
app.post("/supporter/paypal/cancel", requireSupporterAuth, withSupporter(cancelPaypalSubscription)); 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)); app.use((req, res) => json(res, { ok: false, error: "Not found." }, 404));
+2 -1
View File
@@ -45,6 +45,7 @@ export const PERMISSIONS = {
SUPPORTER_VIEW: "Supporter-Abonnenten ansehen", SUPPORTER_VIEW: "Supporter-Abonnenten ansehen",
SUPPORTER_MANAGE_PREMIUMS: "Supporter-Prämien & Versand verwalten", SUPPORTER_MANAGE_PREMIUMS: "Supporter-Prämien & Versand verwalten",
SUPPORTER_MANAGE_COLLECTIONS: "Supporter-Kollektionen verwalten", SUPPORTER_MANAGE_COLLECTIONS: "Supporter-Kollektionen verwalten",
POLLS_MANAGE: "Supporter-Abstimmungen erstellen & auswerten",
EVENTS_MANAGE: "Startseite: \"Event des Jahres\" verwalten", 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: "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: "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: "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"] }, { label: "Startseite", keys: ["EVENTS_MANAGE"] },
]; ];
+178
View File
@@ -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) });
}
+128
View File
@@ -42,6 +42,22 @@
.abo-erfolg { color: #baf7d6; font-size: .88rem; margin-top: .5rem; display: none; } .abo-erfolg { color: #baf7d6; font-size: .88rem; margin-top: .5rem; display: none; }
.abo-erfolg.zeige { display: block; } .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 /* Gemeinsamer Karten-Rahmen mit Gold-Schimmerlinie oben — dieselbe
Machart wie .abo-preis-box auf abonnieren.html, damit der Bereich Machart wie .abo-preis-box auf abonnieren.html, damit der Bereich
erkennbar zusammengehört. */ erkennbar zusammengehört. */
@@ -323,6 +339,18 @@
<div class="container"> <div class="container">
<div id="supp-kuendigungs-hinweis" class="supp-hinweis" style="display:none;"></div> <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> <h2 data-i18n="su_fenster1_h2">Mein Abo</h2>
<div class="card supp-card"> <div class="card supp-card">
<div class="supp-stats"> <div class="supp-stats">
@@ -600,8 +628,108 @@
// Profil-Formular vorbefüllen // Profil-Formular vorbefüllen
document.getElementById("edit-name").value = data.profile.displayName || ""; document.getElementById("edit-name").value = data.profile.displayName || "";
document.getElementById("edit-tiktok").value = data.profile.tiktokUsername || ""; 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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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 ---------- /* ---------- Detail-Modals für die klickbaren Kennzahl-Kärtchen ----------
Nutzer-Wunsch 04.08.2026: "wenn man auf diese 2 Kisten drückt, sollen 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 die viel mehr Infos kriegen ... richtig geil". Recherche, was für
+192
View File
@@ -529,6 +529,12 @@
transition: width .5s ease; transition: width .5s ease;
} }
@keyframes abo-progress-shine { 0%,100% { background-position: 0% 50%; } 50% { background-position: 100% 50%; } } @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 { .vw-progress-celebrate {
margin-top: .8rem; padding: .7rem 1rem; border-radius: 12px; font-size: .87rem; font-weight: 700; 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; background: rgba(56,189,248,.1); border: 1px solid rgba(56,189,248,.35); color: #7dd3fc;
@@ -790,6 +796,41 @@
<div class="vw-overview-body" id="zahlungen-body"></div> <div class="vw-overview-body" id="zahlungen-body"></div>
</div> </div>
<!-- Supporter-Abstimmungen (20.08.2026) — "nächste Ausbaustufe" aus
Dogfather_VanVan_Supporter_Abo.odt Abschnitt 20, eigenes
POLLS_MANAGE-Recht, siehe DogFather Website - Offene Punkte /
Supporter-Abo-System.md. Gleiches Kisten-Muster wie die 3 Boxen
oben, daher automatisch von der bestehenden
.vw-overview-head-Klick-Logik weiter unten miterfasst. -->
<div class="card vw-card vw-shine-card vw-noise vw-overview-box" id="box-abstimmungen">
<div class="vw-overview-head" data-toggle="box-abstimmungen">
<span class="vw-overview-title">🗳️ Abstimmungen</span>
<div class="vw-overview-summary" id="abstimmungen-summary"></div>
<span class="vw-overview-toggle">Anzeigen <span class="pfeil">▾</span></span>
</div>
<div class="vw-overview-body" id="abstimmungen-body">
<div class="card" style="padding:1.2rem;margin-bottom:1.2rem;background:rgba(255,255,255,.03);">
<h3 class="vw-subh" style="margin-top:0;">Neue Abstimmung erstellen</h3>
<p class="small muted" style="margin:0 0 1rem;">
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.
</p>
<div class="field">
<label for="poll-frage">Frage</label>
<input type="text" id="poll-frage" maxlength="200" placeholder="z. B. Welches Format als nächstes?" />
</div>
<div id="poll-optionen-liste"></div>
<button type="button" class="vw-refresh-btn" id="poll-option-hinzufuegen" style="margin-top:.6rem;">+ Option hinzufügen</button>
<div style="margin-top:1.2rem;">
<button type="button" class="btn btn-primary" id="poll-erstellen-btn">Abstimmung starten</button>
</div>
<p class="small" id="poll-erstellen-status" style="margin-top:.6rem;"></p>
</div>
<div id="poll-liste"></div>
</div>
</div>
<!-- Nutzer-Wunsch 04.08.2026 (8. Runde): "dieses Fenster soll nicht <!-- Nutzer-Wunsch 04.08.2026 (8. Runde): "dieses Fenster soll nicht
einfach kacke weiß aussehen, soll der ganzen Seite angepasst einfach kacke weiß aussehen, soll der ganzen Seite angepasst
sein" — das aufgeklappte Dropdown eines nativen <select> wird sein" — das aufgeklappte Dropdown eines nativen <select> wird
@@ -972,6 +1013,13 @@
document.getElementById("events-root").style.display = ""; document.getElementById("events-root").style.display = "";
await ladeUndRenderEvents(session); await ladeUndRenderEvents(session);
} }
const pollsBox = document.getElementById("box-abstimmungen");
if (window.AdminAuth.sessionHasPermission(session, "POLLS_MANAGE")) {
pollsBox.style.display = "";
await ladeUndRenderPolls();
} else {
pollsBox.style.display = "none";
}
await ladeAlleSupporter(); await ladeAlleSupporter();
} }
@@ -982,6 +1030,7 @@
document.getElementById("vw-stats-root").style.display = "none"; document.getElementById("vw-stats-root").style.display = "none";
document.getElementById("supp-root").style.display = "none"; document.getElementById("supp-root").style.display = "none";
document.getElementById("events-root").style.display = "none"; document.getElementById("events-root").style.display = "none";
document.getElementById("box-abstimmungen").style.display = "none";
} }
/* ===== "Event des Jahres" — Verwaltung (20.08.2026) ===== /* ===== "Event des Jahres" — Verwaltung (20.08.2026) =====
@@ -1118,6 +1167,149 @@
}); });
} }
/* ===== Supporter-Abstimmungen (20.08.2026) =====
Formular mit dynamischer Optionsliste (mind. 2, max. 6), Übersetzung
passiert serverseitig beim Speichern (wie "Event des Jahres"). Liste
zeigt jede Abstimmung mit Live-Ergebnisbalken + Schließen/Wiedereröffnen/
Löschen — dieselbe Kisten-Optik wie die 3 Übersichts-Boxen oben. */
const POLL_STATUS_LABELS = { active: "Aktiv", closed: "Geschlossen" };
function pollOptionsHtml(anzahl) {
let html = "";
for (let i = 0; i < anzahl; i++) {
html += `<div class="field" style="margin-top:.6rem;" data-poll-option-feld>
<label>Option ${i + 1}</label>
<input type="text" class="poll-option-input" maxlength="100" />
</div>`;
}
return html;
}
function baueOptionsListe() {
document.getElementById("poll-optionen-liste").innerHTML = pollOptionsHtml(2);
}
baueOptionsListe();
document.getElementById("poll-option-hinzufuegen").addEventListener("click", () => {
const liste = document.getElementById("poll-optionen-liste");
const anzahl = liste.querySelectorAll("[data-poll-option-feld]").length;
if (anzahl >= 6) return;
const feld = document.createElement("div");
feld.className = "field";
feld.style.marginTop = ".6rem";
feld.setAttribute("data-poll-option-feld", "");
feld.innerHTML = `<label>Option ${anzahl + 1}</label><input type="text" class="poll-option-input" maxlength="100" />`;
liste.appendChild(feld);
});
document.getElementById("poll-erstellen-btn").addEventListener("click", async () => {
const statusEl = document.getElementById("poll-erstellen-status");
const frage = document.getElementById("poll-frage").value.trim();
const optionen = [...document.querySelectorAll(".poll-option-input")]
.map((el) => el.value.trim())
.filter(Boolean);
if (!frage) {
statusEl.textContent = "Bitte eine Frage eingeben.";
statusEl.style.color = "#ff6b6b";
return;
}
if (optionen.length < 2) {
statusEl.textContent = "Mindestens 2 Antwortoptionen eintragen.";
statusEl.style.color = "#ff6b6b";
return;
}
statusEl.textContent = "Wird erstellt und übersetzt …";
statusEl.style.color = "";
const res = await window.AdminAuth.apiCall(API_BASE_URL, "/admin/polls/create", { frage, optionen });
if (!res.ok) {
statusEl.textContent = res.error || "Erstellen fehlgeschlagen.";
statusEl.style.color = "#ff6b6b";
return;
}
statusEl.textContent = "✅ Abstimmung ist live im Supporter-Bereich.";
statusEl.style.color = "#baf7d6";
document.getElementById("poll-frage").value = "";
baueOptionsListe();
await ladeUndRenderPolls();
});
function pollKarteHtml(poll) {
const gesamt = poll.totalVotes || 0;
const balken = poll.options
.map((opt, i) => {
const n = poll.counts?.[i] || 0;
const proz = gesamt ? Math.round((n / gesamt) * 100) : 0;
return `<div class="vw-poll-option">
<div class="vw-poll-option-head">
<span>${escapeHtml(opt.de)}</span>
<span class="small" style="color:var(--text-muted);">${n} × (${proz}%)</span>
</div>
<div class="vw-poll-bar-track"><div class="vw-poll-bar-fill" style="width:${proz}%;"></div></div>
</div>`;
})
.join("");
const istAktiv = poll.status === "active";
return `<div class="card vw-card vw-shine-card" style="margin-bottom:1rem;padding:1.3rem 1.4rem;" data-poll-id="${poll.id}">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:1rem;flex-wrap:wrap;">
<div>
<strong>${escapeHtml(poll.question.de)}</strong>
<div class="small" style="color:var(--text-muted);">${POLL_STATUS_LABELS[poll.status] || poll.status} · ${gesamt} Stimme${gesamt === 1 ? "" : "n"} · erstellt am ${fmtDatum(poll.createdAt)}</div>
</div>
<div class="btn-row" style="gap:.5rem;">
${istAktiv
? `<button type="button" class="vw-refresh-btn poll-schliessen-btn">Schließen</button>`
: `<button type="button" class="vw-refresh-btn poll-wiedereroeffnen-btn">Wiedereröffnen</button>`}
<button type="button" class="vw-refresh-btn poll-loeschen-btn" style="color:#ff8a8a;">Löschen</button>
</div>
</div>
<div style="margin-top:1rem;">${balken}</div>
</div>`;
}
async function ladeUndRenderPolls() {
const summaryEl = document.getElementById("abstimmungen-summary");
const listeEl = document.getElementById("poll-liste");
const res = await window.AdminAuth.apiCall(API_BASE_URL, "/admin/polls/list", {});
if (!res.ok) {
listeEl.innerHTML = `<p class="vw-leer">Abstimmungen konnten nicht geladen werden.</p>`;
return;
}
const polls = res.polls || [];
const aktiveAnzahl = polls.filter((p) => p.status === "active").length;
summaryEl.innerHTML = polls.length
? `<span class="vw-overview-chip">${aktiveAnzahl} aktiv</span><span class="vw-overview-chip">${polls.length} gesamt</span>`
: "";
listeEl.innerHTML = polls.length
? polls.map(pollKarteHtml).join("")
: `<p class="vw-leer">Noch keine Abstimmung erstellt.</p>`;
listeEl.querySelectorAll(".poll-schliessen-btn").forEach((btn) => {
btn.addEventListener("click", async () => {
const pollId = btn.closest("[data-poll-id]").dataset.pollId;
btn.disabled = true;
await window.AdminAuth.apiCall(API_BASE_URL, "/admin/polls/close", { pollId });
await ladeUndRenderPolls();
});
});
listeEl.querySelectorAll(".poll-wiedereroeffnen-btn").forEach((btn) => {
btn.addEventListener("click", async () => {
const pollId = btn.closest("[data-poll-id]").dataset.pollId;
btn.disabled = true;
await window.AdminAuth.apiCall(API_BASE_URL, "/admin/polls/reopen", { pollId });
await ladeUndRenderPolls();
});
});
listeEl.querySelectorAll(".poll-loeschen-btn").forEach((btn) => {
btn.addEventListener("click", async () => {
if (!confirm("Diese Abstimmung inkl. aller Stimmen unwiderruflich löschen?")) return;
const pollId = btn.closest("[data-poll-id]").dataset.pollId;
btn.disabled = true;
await window.AdminAuth.apiCall(API_BASE_URL, "/admin/polls/delete", { pollId });
await ladeUndRenderPolls();
});
});
}
// Nutzer-Wunsch 04.08.2026 (6. Runde): zwei getrennte Kisten/Formulare // Nutzer-Wunsch 04.08.2026 (6. Runde): zwei getrennte Kisten/Formulare
// (Dogi + VanVan) statt einem gemeinsamen Feld — beide rufen denselben // (Dogi + VanVan) statt einem gemeinsamen Feld — beide rufen denselben
// Login-Endpunkt auf, die Fehlermeldung erscheint jeweils nur in der // Login-Endpunkt auf, die Fehlermeldung erscheint jeweils nur in der