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:
@@ -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);
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|
||||||
|
|||||||
@@ -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"] },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user