might be trash, cause llm messed up the files
This commit is contained in:
parent
f079de53e6
commit
33a1745284
@ -5,7 +5,87 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "current-vote-text",
|
"id": "current-vote-text",
|
||||||
"content": "<p>Derzeit läuft eine Abstimmung zur Änderung der Vereinssatzung.</p><p>Bitte nutzen Sie den Ihnen zugesandten Link, um an der Abstimmung teilzunehmen.</p><p>Bei Fragen wenden Sie sich bitte an den Vorstand.</p>"
|
"content": "<p>Derzeit laufen mehrere Abstimmungen zu Vereinsangelegenheiten.</p><p>Bitte nutzen Sie den Ihnen zugesandten Link, um an den Abstimmungen teilzunehmen.</p><p>Bei Fragen wenden Sie sich bitte an den Vorstand.</p>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "polls",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"id": "satzung-2025",
|
||||||
|
"title": "Änderung der Vereinssatzung",
|
||||||
|
"question": "<p>Stimmen Sie der vorgeschlagenen Änderung der Vereinssatzung zu?</p>",
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"id": "abstain",
|
||||||
|
"label": "Ich enthalte mich234"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "va9h6snd",
|
||||||
|
"label": "Das Reicht"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "yes",
|
||||||
|
"label": "Ja, ich stimme zu"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "1w5u17px",
|
||||||
|
"label": "5674567"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "no",
|
||||||
|
"label": "Neinneinenen"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "2puzfaka",
|
||||||
|
"label": "2345tg2"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"active": true,
|
||||||
|
"createdAt": "2025-03-01T12:00:00.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "9ms9oij4",
|
||||||
|
"title": "qwe",
|
||||||
|
"question": "<p>Stimmen Sie dem Vorschlag zu?<qwe/p>",
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"id": "yes",
|
||||||
|
"label": "Ja, ich stimme zu"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "no",
|
||||||
|
"label": "Nein, ich stimme nicht zu"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "abstain",
|
||||||
|
"label": "Ich enthalte mich"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"active": true,
|
||||||
|
"createdAt": "2025-03-08T22:07:06.863Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dvp2ibas",
|
||||||
|
"title": "titel eingabe test",
|
||||||
|
"question": "frage eingabe test",
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"id": "yes",
|
||||||
|
"label": "Ja, ich stimme zu"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "no",
|
||||||
|
"label": "Nein, ich stimme nicht zu"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "abstain",
|
||||||
|
"label": "Ich enthalte mich"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"active": true,
|
||||||
|
"createdAt": "2025-03-08T22:12:56.471Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "vote-question",
|
"id": "vote-question",
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"id": "0dfdb724-b6f2-47db-917b-0d233bef5e93",
|
"id": "0dfdb724-b6f2-47db-917b-0d233bef5e93",
|
||||||
|
"pollId": "satzung-2025",
|
||||||
"vote": "abstain",
|
"vote": "abstain",
|
||||||
"comment": "ich was.",
|
"comment": "ich was.",
|
||||||
"timestamp": "2025-03-02T19:12:44.209Z"
|
"timestamp": "2025-03-02T19:12:44.209Z"
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { VoteOption } from '@/lib/survey';
|
import { VoteOption, Poll } from '@/lib/survey';
|
||||||
import {
|
import {
|
||||||
LoginForm,
|
LoginForm,
|
||||||
TextEditor,
|
TextEditor,
|
||||||
@ -20,8 +20,14 @@ interface Stats {
|
|||||||
[key: string]: number; // Allow dynamic keys for vote options
|
[key: string]: number; // Allow dynamic keys for vote options
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PollStats {
|
||||||
|
all: Stats;
|
||||||
|
byPoll: Record<string, Stats>;
|
||||||
|
}
|
||||||
|
|
||||||
interface Comment {
|
interface Comment {
|
||||||
vote: VoteOption;
|
vote: VoteOption;
|
||||||
|
pollId?: string;
|
||||||
comment: string;
|
comment: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
}
|
}
|
||||||
@ -33,7 +39,7 @@ interface EditableText {
|
|||||||
|
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
const [showStats, setShowStats] = useState(false);
|
const [showStats, setShowStats] = useState(false);
|
||||||
const [stats, setStats] = useState<Stats | null>(null);
|
const [stats, setStats] = useState<Stats | PollStats | null>(null);
|
||||||
const [comments, setComments] = useState<Comment[]>([]);
|
const [comments, setComments] = useState<Comment[]>([]);
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
const [showEditor, setShowEditor] = useState(false);
|
const [showEditor, setShowEditor] = useState(false);
|
||||||
@ -42,6 +48,20 @@ export default function AdminPage() {
|
|||||||
{ id: 'current-vote-text', content: '<p>Derzeit läuft eine Abstimmung zur Änderung der Vereinssatzung.</p><p>Bitte nutzen Sie den Ihnen zugesandten Link, um an der Abstimmung teilzunehmen.</p><p>Bei Fragen wenden Sie sich bitte an den Vorstand.</p>' },
|
{ id: 'current-vote-text', content: '<p>Derzeit läuft eine Abstimmung zur Änderung der Vereinssatzung.</p><p>Bitte nutzen Sie den Ihnen zugesandten Link, um an der Abstimmung teilzunehmen.</p><p>Bei Fragen wenden Sie sich bitte an den Vorstand.</p>' },
|
||||||
{ id: 'vote-question', content: '<p>Stimmen Sie der vorgeschlagenen Änderung der Vereinssatzung zu?</p>' }
|
{ id: 'vote-question', content: '<p>Stimmen Sie der vorgeschlagenen Änderung der Vereinssatzung zu?</p>' }
|
||||||
]);
|
]);
|
||||||
|
const [polls, setPolls] = useState<Poll[]>([
|
||||||
|
{
|
||||||
|
id: 'default-poll',
|
||||||
|
title: 'Default Poll',
|
||||||
|
question: '<p>Stimmen Sie der vorgeschlagenen Änderung der Vereinssatzung zu?</p>',
|
||||||
|
options: [
|
||||||
|
{ id: 'yes', label: 'Ja, ich stimme zu' },
|
||||||
|
{ id: 'no', label: 'Nein, ich stimme nicht zu' },
|
||||||
|
{ id: 'abstain', label: 'Ich enthalte mich' }
|
||||||
|
],
|
||||||
|
active: true,
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
}
|
||||||
|
]);
|
||||||
const [voteOptions, setVoteOptions] = useState<{ id: string; label: string }[]>([
|
const [voteOptions, setVoteOptions] = useState<{ id: string; label: string }[]>([
|
||||||
{ id: 'yes', label: 'Ja, ich stimme zu' },
|
{ id: 'yes', label: 'Ja, ich stimme zu' },
|
||||||
{ id: 'no', label: 'Nein, ich stimme nicht zu' },
|
{ id: 'no', label: 'Nein, ich stimme nicht zu' },
|
||||||
@ -99,13 +119,33 @@ export default function AdminPage() {
|
|||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.texts && Array.isArray(data.texts)) {
|
if (data.texts && Array.isArray(data.texts)) {
|
||||||
setEditableTexts(data.texts);
|
// Filter out polls entry for the regular editable texts
|
||||||
|
const regularTexts = data.texts.filter((text: { id: string }) => text.id !== 'polls');
|
||||||
|
setEditableTexts(regularTexts);
|
||||||
|
|
||||||
// Find and set vote options
|
// Find and set vote options (for backward compatibility)
|
||||||
const voteOptionsEntry = data.texts.find((text: { id: string }) => text.id === 'vote-options');
|
const voteOptionsEntry = data.texts.find((text: { id: string }) => text.id === 'vote-options');
|
||||||
if (voteOptionsEntry && Array.isArray(voteOptionsEntry.content)) {
|
if (voteOptionsEntry && Array.isArray(voteOptionsEntry.content)) {
|
||||||
setVoteOptions(voteOptionsEntry.content);
|
setVoteOptions(voteOptionsEntry.content);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Find and set polls
|
||||||
|
const pollsEntry = data.texts.find((text: { id: string }) => text.id === 'polls');
|
||||||
|
if (pollsEntry && Array.isArray(pollsEntry.content)) {
|
||||||
|
setPolls(pollsEntry.content);
|
||||||
|
} else if (voteOptionsEntry) {
|
||||||
|
// If no polls entry exists but vote options do, create a default poll
|
||||||
|
const voteQuestionEntry = data.texts.find((text: { id: string }) => text.id === 'vote-question');
|
||||||
|
const defaultPoll: Poll = {
|
||||||
|
id: 'default-poll',
|
||||||
|
title: 'Abstimmung',
|
||||||
|
question: voteQuestionEntry ? voteQuestionEntry.content : '<p>Stimmen Sie dem Vorschlag zu?</p>',
|
||||||
|
options: voteOptionsEntry.content,
|
||||||
|
active: true,
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
setPolls([defaultPoll]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -155,12 +195,25 @@ export default function AdminPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStatsLoaded = (loadedStats: Stats, loadedComments: Comment[]) => {
|
const handleStatsLoaded = (loadedStats: Stats | PollStats, loadedComments: Comment[], loadedPolls?: Poll[]) => {
|
||||||
setStats(loadedStats);
|
setStats(loadedStats);
|
||||||
setComments(loadedComments);
|
setComments(loadedComments);
|
||||||
|
if (loadedPolls) {
|
||||||
|
setPolls(loadedPolls);
|
||||||
|
}
|
||||||
setShowStats(true);
|
setShowStats(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePollsChange = (newPolls: Poll[]) => {
|
||||||
|
setPolls(newPolls);
|
||||||
|
|
||||||
|
// For backward compatibility, update voteOptions with the first active poll's options
|
||||||
|
const activePoll = newPolls.find(poll => poll.active);
|
||||||
|
if (activePoll) {
|
||||||
|
setVoteOptions(activePoll.options);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleVoteOptionsChange = (newOptions: { id: string; label: string }[]) => {
|
const handleVoteOptionsChange = (newOptions: { id: string; label: string }[]) => {
|
||||||
setVoteOptions(newOptions);
|
setVoteOptions(newOptions);
|
||||||
};
|
};
|
||||||
@ -232,8 +285,8 @@ export default function AdminPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<VoteOptionsManager
|
<VoteOptionsManager
|
||||||
voteOptions={voteOptions}
|
polls={polls}
|
||||||
onVoteOptionsChange={handleVoteOptionsChange}
|
onPollsChange={handlePollsChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MemberAuthManager
|
<MemberAuthManager
|
||||||
@ -250,6 +303,7 @@ export default function AdminPage() {
|
|||||||
stats={stats!}
|
stats={stats!}
|
||||||
comments={comments}
|
comments={comments}
|
||||||
voteOptions={voteOptions}
|
voteOptions={voteOptions}
|
||||||
|
polls={polls}
|
||||||
onBack={() => setShowStats(false)}
|
onBack={() => setShowStats(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { checkAdminAuth } from '@/lib/auth';
|
import { comparePassword } from '@/lib/auth';
|
||||||
|
import { getMemberCredentials } from '@/lib/server-auth';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@ -61,7 +62,6 @@ function getEditableTexts() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Save editable texts
|
// Save editable texts
|
||||||
|
|
||||||
function saveEditableTexts(texts: EditableText[]) {
|
function saveEditableTexts(texts: EditableText[]) {
|
||||||
ensureDataDirectory();
|
ensureDataDirectory();
|
||||||
fs.writeFileSync(TEXT_FILE, JSON.stringify(texts, null, 2));
|
fs.writeFileSync(TEXT_FILE, JSON.stringify(texts, null, 2));
|
||||||
@ -86,8 +86,9 @@ export async function POST(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
// Check for admin auth
|
// Check for admin auth
|
||||||
const { password, } = body;
|
const { password } = body;
|
||||||
const isAuthenticated = await checkAdminAuth(password);
|
const members = getMemberCredentials();
|
||||||
|
const isAuthenticated = members.some(member => comparePassword(password, member.password));
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Unauthorized' },
|
{ error: 'Unauthorized' },
|
||||||
@ -108,9 +109,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const texts = getEditableTexts();
|
const texts = getEditableTexts();
|
||||||
|
|
||||||
// Find and update the text with the given ID
|
// Find and update the text with the given ID
|
||||||
const textIndex = texts.findIndex((text: EditableText
|
const textIndex = texts.findIndex((text: EditableText) => text.id === id);
|
||||||
|
|
||||||
) => text.id === id);
|
|
||||||
|
|
||||||
if (textIndex === -1) {
|
if (textIndex === -1) {
|
||||||
// Text not found, add new
|
// Text not found, add new
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
import { checkAdminAuth, generateRandomToken } from '@/lib/auth';
|
import { generateRandomToken } from '@/lib/auth';
|
||||||
|
import { getMemberCredentials } from '@/lib/server-auth';
|
||||||
|
import { comparePassword } from '@/lib/auth';
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@ -6,7 +8,8 @@ export async function POST(request: NextRequest) {
|
|||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
// Check for admin auth
|
// Check for admin auth
|
||||||
const { password } = body;
|
const { password } = body;
|
||||||
const isAuthenticated = await checkAdminAuth(password);
|
const members = getMemberCredentials();
|
||||||
|
const isAuthenticated = members.some(member => comparePassword(password, member.password));
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Unauthorized' },
|
{ error: 'Unauthorized' },
|
||||||
|
@ -1,21 +1,55 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { verifyToken } from '@/lib/server-auth';
|
import { verifyToken } from '@/lib/server-auth';
|
||||||
import { saveResponse, VoteOption, getVoteOptions } from '@/lib/survey';
|
import { generateRandomToken } from '@/lib/auth';
|
||||||
|
import {
|
||||||
|
saveResponse,
|
||||||
|
VoteOption,
|
||||||
|
getVoteOptions,
|
||||||
|
getActivePolls,
|
||||||
|
getPoll,
|
||||||
|
} from '@/lib/survey';
|
||||||
|
import { markMemberAsVoted } from '@/lib/server-auth';
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
// Get the token from the request
|
// Get the token, vote data, and poll ID from the request
|
||||||
const { token, vote, comment } = await request.json();
|
const { token, vote, comment, pollId } = await request.json();
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
|
return NextResponse.json({ error: 'Token is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine which poll to use
|
||||||
|
let targetPollId = pollId;
|
||||||
|
|
||||||
|
// If no pollId is provided, use the first active poll (for backward compatibility)
|
||||||
|
if (!targetPollId) {
|
||||||
|
const activePolls = getActivePolls();
|
||||||
|
if (activePolls.length === 0) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Token is required' },
|
{ error: 'No active polls available' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
targetPollId = activePolls[0].id;
|
||||||
|
} else {
|
||||||
|
// Verify the poll exists
|
||||||
|
const poll = getPoll(targetPollId);
|
||||||
|
if (!poll) {
|
||||||
|
return NextResponse.json({ error: 'Invalid poll ID' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
// Get available vote options
|
// Check if the poll is active
|
||||||
const voteOptions = getVoteOptions();
|
if (!poll.active) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'This poll is no longer active' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get available vote options for this poll
|
||||||
|
const voteOptions = getVoteOptions(targetPollId);
|
||||||
const validOptionIds = voteOptions.map(option => option.id);
|
const validOptionIds = voteOptions.map(option => option.id);
|
||||||
|
|
||||||
if (!vote || !validOptionIds.includes(vote)) {
|
if (!vote || !validOptionIds.includes(vote)) {
|
||||||
@ -25,25 +59,34 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the token
|
// Verify the token and check if already voted in this poll
|
||||||
const { valid } = await verifyToken(token);
|
const { valid, memberNumber, votedPolls } = await verifyToken(token);
|
||||||
|
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
|
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (votedPolls?.includes(targetPollId)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Invalid or already used token' },
|
{ error: 'Already voted in this poll' },
|
||||||
{ status: 401 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save the response
|
// Save the response with the poll ID
|
||||||
const response = saveResponse(vote as VoteOption, comment);
|
const response = saveResponse(targetPollId, vote as VoteOption, comment);
|
||||||
|
|
||||||
return NextResponse.json({ success: true, response });
|
// Mark the member as voted
|
||||||
|
if (memberNumber) {
|
||||||
|
markMemberAsVoted(memberNumber, targetPollId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If token is valid, generate a new token with pollId and memberNumber
|
||||||
|
const newToken = await generateRandomToken(memberNumber, targetPollId);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, response, token: newToken }); // Return new token
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error submitting vote:', error);
|
console.error('Error submitting vote:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: 'Failed to submit vote' }, { status: 500 });
|
||||||
{ error: 'Failed to submit vote' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,8 +4,7 @@ import { useState, useEffect, Suspense } from 'react';
|
|||||||
import { useSearchParams, useRouter } from 'next/navigation';
|
import { useSearchParams, useRouter } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { EditableText } from '@/components/EditableText';
|
import { EditableText } from '@/components/EditableText';
|
||||||
import { VoteOption, VoteOptionConfig } from '@/lib/survey';
|
import { VoteOption, VoteOptionConfig, Poll } from '@/lib/survey';
|
||||||
|
|
||||||
|
|
||||||
export default function VotePage() {
|
export default function VotePage() {
|
||||||
return (
|
return (
|
||||||
@ -23,40 +22,90 @@ function VotePageContent() {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const token = searchParams.get('token');
|
const token = searchParams.get('token');
|
||||||
|
const pollIdParam = searchParams.get('pollId');
|
||||||
|
|
||||||
const [voteOptions, setVoteOptions] = useState<VoteOptionConfig[]>([]);
|
const [polls, setPolls] = useState<Poll[]>([]);
|
||||||
|
const [selectedPollId, setSelectedPollId] = useState<string | null>(null);
|
||||||
const [selectedOption, setSelectedOption] = useState<VoteOption | null>(null);
|
const [selectedOption, setSelectedOption] = useState<VoteOption | null>(null);
|
||||||
const [comment, setComment] = useState('');
|
const [comment, setComment] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||||
// Fetch vote options
|
const [submittedPollIds, setSubmittedPollIds] = useState<string[]>(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const storedPollIds = sessionStorage.getItem('submittedPollIds');
|
||||||
|
return storedPollIds ? JSON.parse(storedPollIds) : [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch polls
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchVoteOptions = async () => {
|
const fetchPolls = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/editable-text');
|
const response = await fetch('/api/editable-text');
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.texts && Array.isArray(data.texts)) {
|
if (data.texts && Array.isArray(data.texts)) {
|
||||||
|
const pollsEntry = data.texts.find((text: { id: string }) => text.id === 'polls');
|
||||||
|
|
||||||
|
if (pollsEntry && Array.isArray(pollsEntry.content)) {
|
||||||
|
// Filter only active polls
|
||||||
|
const activePolls = pollsEntry.content.filter((poll: Poll) => poll.active);
|
||||||
|
setPolls(activePolls);
|
||||||
|
|
||||||
|
// If a poll ID was specified in the URL, select it
|
||||||
|
if (pollIdParam && activePolls.some((poll: Poll) => poll.id === pollIdParam)) {
|
||||||
|
setSelectedPollId(pollIdParam);
|
||||||
|
}
|
||||||
|
// Otherwise select the first active poll
|
||||||
|
else if (activePolls.length > 0) {
|
||||||
|
setSelectedPollId(activePolls[0].id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback to legacy format
|
||||||
const voteOptionsEntry = data.texts.find((text: { id: string }) => text.id === 'vote-options');
|
const voteOptionsEntry = data.texts.find((text: { id: string }) => text.id === 'vote-options');
|
||||||
if (voteOptionsEntry && Array.isArray(voteOptionsEntry.content)) {
|
const voteQuestionEntry = data.texts.find((text: { id: string }) => text.id === 'vote-question');
|
||||||
setVoteOptions(voteOptionsEntry.content);
|
|
||||||
|
if (voteOptionsEntry && Array.isArray(voteOptionsEntry.content) && voteQuestionEntry) {
|
||||||
|
const legacyPoll: Poll = {
|
||||||
|
id: 'default-poll',
|
||||||
|
title: 'Abstimmung',
|
||||||
|
question: voteQuestionEntry.content,
|
||||||
|
options: voteOptionsEntry.content,
|
||||||
|
active: true,
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
setPolls([legacyPoll]);
|
||||||
|
setSelectedPollId('default-poll');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching vote options:', error);
|
console.error('Error fetching polls:', error);
|
||||||
// Use default options if fetch fails
|
// Use default poll if fetch fails
|
||||||
setVoteOptions([
|
const defaultPoll: Poll = {
|
||||||
|
id: 'default-poll',
|
||||||
|
title: 'Abstimmung',
|
||||||
|
question: '<p>Stimmen Sie der vorgeschlagenen Änderung der Vereinssatzung zu?</p>',
|
||||||
|
options: [
|
||||||
{ id: 'yes', label: 'Ja, ich stimme zu' },
|
{ id: 'yes', label: 'Ja, ich stimme zu' },
|
||||||
{ id: 'no', label: 'Nein, ich stimme nicht zu' },
|
{ id: 'no', label: 'Nein, ich stimme nicht zu' },
|
||||||
{ id: 'abstain', label: 'Ich enthalte mich' }
|
{ id: 'abstain', label: 'Ich enthalte mich' }
|
||||||
]);
|
],
|
||||||
|
active: true,
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
setPolls([defaultPoll]);
|
||||||
|
setSelectedPollId('default-poll');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchVoteOptions();
|
fetchPolls();
|
||||||
}, []);
|
}, [pollIdParam]);
|
||||||
|
|
||||||
// Redirect if no token is provided
|
// Redirect if no token is provided
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -65,9 +114,19 @@ function VotePageContent() {
|
|||||||
}
|
}
|
||||||
}, [token, router]);
|
}, [token, router]);
|
||||||
|
|
||||||
|
const handlePollChange = (pollId: string) => {
|
||||||
|
setSelectedPollId(pollId);
|
||||||
|
setSelectedOption(null);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!selectedPollId) {
|
||||||
|
setError('Keine aktive Abstimmung verfügbar');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!selectedOption) {
|
if (!selectedOption) {
|
||||||
setError('Bitte wähle eine Option');
|
setError('Bitte wähle eine Option');
|
||||||
return;
|
return;
|
||||||
@ -84,6 +143,7 @@ function VotePageContent() {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
token,
|
token,
|
||||||
|
pollId: selectedPollId,
|
||||||
vote: selectedOption,
|
vote: selectedOption,
|
||||||
comment: comment.trim() || undefined,
|
comment: comment.trim() || undefined,
|
||||||
}),
|
}),
|
||||||
@ -95,7 +155,24 @@ function VotePageContent() {
|
|||||||
throw new Error(data.error || 'Fehler beim Übermitteln der Stimme');
|
throw new Error(data.error || 'Fehler beim Übermitteln der Stimme');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add this poll to the list of submitted polls
|
||||||
|
setSubmittedPollIds(prev => {
|
||||||
|
const updatedPollIds = [...prev, selectedPollId];
|
||||||
|
sessionStorage.setItem('submittedPollIds', JSON.stringify(updatedPollIds));
|
||||||
|
return updatedPollIds;
|
||||||
|
});
|
||||||
|
|
||||||
|
// If there are more polls to vote on, select the next one
|
||||||
|
const remainingPolls = polls.filter(poll => !submittedPollIds.includes(poll.id) && poll.id !== selectedPollId);
|
||||||
|
|
||||||
|
if (remainingPolls.length > 0) {
|
||||||
|
setSelectedPollId(remainingPolls[0].id);
|
||||||
|
setSelectedOption(null);
|
||||||
|
setComment('');
|
||||||
|
} else {
|
||||||
|
// All polls have been voted on
|
||||||
setIsSubmitted(true);
|
setIsSubmitted(true);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
|
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
|
||||||
} finally {
|
} finally {
|
||||||
@ -103,6 +180,9 @@ function VotePageContent() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Get the current poll
|
||||||
|
const currentPoll = polls.find(poll => poll.id === selectedPollId);
|
||||||
|
|
||||||
if (isSubmitted) {
|
if (isSubmitted) {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-8">
|
<div className="container mx-auto px-4 py-8">
|
||||||
@ -130,20 +210,66 @@ function VotePageContent() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!currentPoll) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<div className="ssvc-main-content">
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<h2 className="text-xl font-bold text-[#0057a6] mb-4">Keine aktive Abstimmung verfügbar</h2>
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
Derzeit sind keine aktiven Abstimmungen verfügbar.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="ssvc-button inline-block"
|
||||||
|
>
|
||||||
|
Zurück zur Startseite
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-8">
|
<div className="container mx-auto px-4 py-8">
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-2xl font-bold text-[#0057a6] mb-4">ABSTIMMUNG</h1>
|
<h1 className="text-2xl font-bold text-[#0057a6] mb-4">ABSTIMMUNG</h1>
|
||||||
|
|
||||||
|
{polls.length > 1 && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="text-lg font-medium text-[#0057a6] mb-2">Wähle eine Abstimmung:</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{polls.map(poll => (
|
||||||
|
<button
|
||||||
|
key={poll.id}
|
||||||
|
onClick={() => handlePollChange(poll.id)}
|
||||||
|
className={`px-4 py-2 rounded-md transition-colors ${selectedPollId === poll.id
|
||||||
|
? 'bg-[#0057a6] text-white'
|
||||||
|
: submittedPollIds.includes(poll.id)
|
||||||
|
? 'bg-[#e6f0fa] text-[#0057a6] border border-[#0057a6]'
|
||||||
|
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{poll.title}
|
||||||
|
{submittedPollIds.includes(poll.id) && (
|
||||||
|
<span className="ml-2">✓</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="ssvc-main-content">
|
<div className="ssvc-main-content">
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xl font-bold text-[#0057a6] mb-4">
|
<div className="text-xl font-bold text-[#0057a6] mb-4">
|
||||||
<EditableText id="vote-question" />
|
<div dangerouslySetInnerHTML={{ __html: currentPoll.question }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{voteOptions.map((option) => (
|
{currentPoll.options.map((option) => (
|
||||||
<label
|
<label
|
||||||
key={option.id}
|
key={option.id}
|
||||||
className="flex items-center p-3 border border-gray-200 cursor-pointer hover:bg-[#e6f0fa] transition-colors"
|
className="flex items-center p-3 border border-gray-200 cursor-pointer hover:bg-[#e6f0fa] transition-colors"
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { VoteOption } from '@/lib/survey';
|
import { VoteOption, Poll } from '@/lib/survey';
|
||||||
|
|
||||||
// Define types based on the data structure
|
// Define types based on the data structure
|
||||||
interface Stats {
|
interface Stats {
|
||||||
@ -9,14 +9,20 @@ interface Stats {
|
|||||||
[key: string]: number; // Allow dynamic keys for vote options
|
[key: string]: number; // Allow dynamic keys for vote options
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PollStats {
|
||||||
|
all: Stats;
|
||||||
|
byPoll: Record<string, Stats>;
|
||||||
|
}
|
||||||
|
|
||||||
interface Comment {
|
interface Comment {
|
||||||
vote: VoteOption;
|
vote: VoteOption;
|
||||||
|
pollId?: string;
|
||||||
comment: string;
|
comment: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StatsButtonProps {
|
interface StatsButtonProps {
|
||||||
onStatsLoaded: (stats: Stats, comments: Comment[]) => void;
|
onStatsLoaded: (stats: Stats | PollStats, comments: Comment[], polls?: Poll[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function StatsButton({ onStatsLoaded }: StatsButtonProps) {
|
export default function StatsButton({ onStatsLoaded }: StatsButtonProps) {
|
||||||
@ -42,7 +48,14 @@ export default function StatsButton({ onStatsLoaded }: StatsButtonProps) {
|
|||||||
throw new Error(data.error || 'Statistiken konnten nicht abgerufen werden');
|
throw new Error(data.error || 'Statistiken konnten nicht abgerufen werden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if we have the new stats format with byPoll
|
||||||
|
const hasMultiplePolls = data.stats && 'byPoll' in data.stats;
|
||||||
|
|
||||||
|
if (hasMultiplePolls) {
|
||||||
|
onStatsLoaded(data.stats, data.comments || [], data.polls || []);
|
||||||
|
} else {
|
||||||
onStatsLoaded(data.stats, data.comments || []);
|
onStatsLoaded(data.stats, data.comments || []);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
|
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
|
||||||
} finally {
|
} finally {
|
||||||
|
@ -1,53 +1,134 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { VoteOption } from '@/lib/survey';
|
import { useState } from 'react';
|
||||||
|
import { VoteOption, Poll } from '@/lib/survey';
|
||||||
|
|
||||||
interface Stats {
|
interface Stats {
|
||||||
total: number;
|
total: number;
|
||||||
[key: string]: number; // Allow dynamic keys for vote options
|
[key: string]: number; // Allow dynamic keys for vote options
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PollStats {
|
||||||
|
all: Stats;
|
||||||
|
byPoll: Record<string, Stats>;
|
||||||
|
}
|
||||||
|
|
||||||
interface Comment {
|
interface Comment {
|
||||||
vote: VoteOption;
|
vote: VoteOption;
|
||||||
|
pollId?: string;
|
||||||
comment: string;
|
comment: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StatsDisplayProps {
|
interface StatsDisplayProps {
|
||||||
stats: Stats;
|
stats: Stats | PollStats;
|
||||||
comments: Comment[];
|
comments: Comment[];
|
||||||
voteOptions: { id: string; label: string }[];
|
voteOptions: { id: string; label: string }[];
|
||||||
|
polls?: Poll[];
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function StatsDisplay({ stats, comments, voteOptions, onBack }: StatsDisplayProps) {
|
export default function StatsDisplay({ stats, comments, voteOptions, polls, onBack }: StatsDisplayProps) {
|
||||||
|
const [selectedPollId, setSelectedPollId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Check if we have the new stats format with byPoll
|
||||||
|
const hasMultiplePolls = stats && 'byPoll' in stats;
|
||||||
|
|
||||||
|
// Get the stats to display based on selection
|
||||||
|
const getDisplayStats = (): Stats => {
|
||||||
|
if (!hasMultiplePolls) {
|
||||||
|
return stats as Stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pollStats = stats as PollStats;
|
||||||
|
|
||||||
|
if (selectedPollId && pollStats.byPoll[selectedPollId]) {
|
||||||
|
return pollStats.byPoll[selectedPollId];
|
||||||
|
}
|
||||||
|
|
||||||
|
return pollStats.all;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get the comments to display based on selection
|
||||||
|
const getDisplayComments = (): Comment[] => {
|
||||||
|
if (!selectedPollId || !hasMultiplePolls) {
|
||||||
|
return comments;
|
||||||
|
}
|
||||||
|
|
||||||
|
return comments.filter(comment => comment.pollId === selectedPollId);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get the options to display based on selection
|
||||||
|
const getDisplayOptions = (): { id: string; label: string }[] => {
|
||||||
|
if (!selectedPollId || !polls) {
|
||||||
|
return voteOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedPoll = polls.find(poll => poll.id === selectedPollId);
|
||||||
|
return selectedPoll ? selectedPoll.options : voteOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayStats = getDisplayStats();
|
||||||
|
const displayComments = getDisplayComments();
|
||||||
|
const displayOptions = getDisplayOptions();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ssvc-main-content">
|
<div className="ssvc-main-content">
|
||||||
<h2 className="text-xl font-bold text-[#0057a6] mb-4">Abstimmungsstatistiken</h2>
|
<h2 className="text-xl font-bold text-[#0057a6] mb-4">Abstimmungsstatistiken</h2>
|
||||||
|
|
||||||
{stats && voteOptions.length > 0 && (
|
{/* Poll selector if multiple polls are available */}
|
||||||
|
{hasMultiplePolls && polls && polls.length > 0 && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="text-lg font-medium text-[#0057a6] mb-2">Abstimmung auswählen:</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedPollId(null)}
|
||||||
|
className={`px-4 py-2 rounded-md transition-colors ${selectedPollId === null
|
||||||
|
? 'bg-[#0057a6] text-white'
|
||||||
|
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Alle Abstimmungen
|
||||||
|
</button>
|
||||||
|
{polls.map(poll => (
|
||||||
|
<button
|
||||||
|
key={poll.id}
|
||||||
|
onClick={() => setSelectedPollId(poll.id)}
|
||||||
|
className={`px-4 py-2 rounded-md transition-colors ${selectedPollId === poll.id
|
||||||
|
? 'bg-[#0057a6] text-white'
|
||||||
|
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{poll.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{displayStats && displayOptions.length > 0 && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className={`grid ${voteOptions.length === 1 ? 'grid-cols-1' : voteOptions.length === 2 ? 'grid-cols-2' : 'grid-cols-3'} gap-4`}>
|
<div className={`grid ${displayOptions.length === 1 ? 'grid-cols-1' : displayOptions.length === 2 ? 'grid-cols-2' : 'grid-cols-3'} gap-4`}>
|
||||||
{voteOptions.map((option) => (
|
{displayOptions.map((option) => (
|
||||||
<div key={option.id} className="bg-[#e6f0fa] p-4 text-center">
|
<div key={option.id} className="bg-[#e6f0fa] p-4 text-center">
|
||||||
<div className="text-2xl font-bold text-[#0057a6]">{stats[option.id] || 0}</div>
|
<div className="text-2xl font-bold text-[#0057a6]">{displayStats[option.id] || 0}</div>
|
||||||
<div className="text-sm text-[#0057a6]">{option.label}</div>
|
<div className="text-sm text-[#0057a6]">{option.label}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-[#0057a6] p-4 text-center">
|
<div className="bg-[#0057a6] p-4 text-center">
|
||||||
<div className="text-2xl font-bold text-white">{stats.total}</div>
|
<div className="text-2xl font-bold text-white">{displayStats.total}</div>
|
||||||
<div className="text-sm text-white">Gesamtstimmen</div>
|
<div className="text-sm text-white">Gesamtstimmen</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{stats.total > 0 && (
|
{displayStats.total > 0 && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<h3 className="text-lg font-medium text-[#0057a6] mb-2">Ergebnisübersicht</h3>
|
<h3 className="text-lg font-medium text-[#0057a6] mb-2">Ergebnisübersicht</h3>
|
||||||
<div className="h-6 bg-gray-200 overflow-hidden">
|
<div className="h-6 bg-gray-200 overflow-hidden">
|
||||||
<div className="flex h-full">
|
<div className="flex h-full">
|
||||||
{voteOptions.map((option, index) => {
|
{displayOptions.map((option, index) => {
|
||||||
const percentage = ((stats[option.id] || 0) / stats.total) * 100;
|
const percentage = ((displayStats[option.id] || 0) / displayStats.total) * 100;
|
||||||
// Define colors for different options
|
// Define colors for different options
|
||||||
const colors = [
|
const colors = [
|
||||||
'bg-[#0057a6]', // Blue for first option (usually yes)
|
'bg-[#0057a6]', // Blue for first option (usually yes)
|
||||||
@ -68,8 +149,8 @@ export default function StatsDisplay({ stats, comments, voteOptions, onBack }: S
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-xs mt-1">
|
<div className="flex justify-between text-xs mt-1">
|
||||||
{voteOptions.map((option, index) => {
|
{displayOptions.map((option, index) => {
|
||||||
const percentage = Math.round(((stats[option.id] || 0) / stats.total) * 100);
|
const percentage = Math.round(((displayStats[option.id] || 0) / displayStats.total) * 100);
|
||||||
// Define text colors for different options
|
// Define text colors for different options
|
||||||
const textColors = [
|
const textColors = [
|
||||||
'text-[#0057a6]', // Blue for first option (usually yes)
|
'text-[#0057a6]', // Blue for first option (usually yes)
|
||||||
@ -91,15 +172,15 @@ export default function StatsDisplay({ stats, comments, voteOptions, onBack }: S
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{comments.length > 0 && (
|
{displayComments.length > 0 && (
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<h3 className="text-lg font-medium text-[#0057a6] mb-4">Kommentare der Teilnehmer</h3>
|
<h3 className="text-lg font-medium text-[#0057a6] mb-4">Kommentare der Teilnehmer</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{comments.map((comment, index) => (
|
{displayComments.map((comment, index) => (
|
||||||
<div key={index} className="p-4 bg-[#e6f0fa] rounded">
|
<div key={index} className="p-4 bg-[#e6f0fa] rounded">
|
||||||
<div className="flex justify-between items-center mb-2">
|
<div className="flex justify-between items-center mb-2">
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{voteOptions.find(option => option.id === comment.vote)?.label || comment.vote}
|
{displayOptions.find(option => option.id === comment.vote)?.label || comment.vote}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-gray-500">
|
<span className="text-xs text-gray-500">
|
||||||
{new Date(comment.timestamp).toLocaleDateString('de-DE')}
|
{new Date(comment.timestamp).toLocaleDateString('de-DE')}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { Poll } from '@/lib/survey';
|
||||||
|
|
||||||
// Function to generate a random ID
|
// Function to generate a random ID
|
||||||
function generateRandomId(existingIds: string[]): string {
|
function generateRandomId(existingIds: string[]): string {
|
||||||
@ -19,39 +20,52 @@ function generateRandomId(existingIds: string[]): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface VoteOptionsManagerProps {
|
interface VoteOptionsManagerProps {
|
||||||
voteOptions: { id: string; label: string }[];
|
polls: Poll[];
|
||||||
onVoteOptionsChange: (newOptions: { id: string; label: string }[]) => void;
|
onPollsChange: (newPolls: Poll[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VoteOptionsManager({ voteOptions, onVoteOptionsChange }: VoteOptionsManagerProps) {
|
export default function VoteOptionsManager({ polls, onPollsChange }: VoteOptionsManagerProps) {
|
||||||
const [showVoteOptions, setShowVoteOptions] = useState(false);
|
const [showVoteOptions, setShowVoteOptions] = useState(false);
|
||||||
|
const [selectedPollId, setSelectedPollId] = useState<string | null>(null);
|
||||||
|
const [newPollTitle, setNewPollTitle] = useState('');
|
||||||
|
const [newPollQuestion, setNewPollQuestion] = useState('<p>Stimmen Sie dem Vorschlag zu?</p>');
|
||||||
const [newOptionId, setNewOptionId] = useState('');
|
const [newOptionId, setNewOptionId] = useState('');
|
||||||
const [newOptionLabel, setNewOptionLabel] = useState('');
|
const [newOptionLabel, setNewOptionLabel] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [editingOptionId, setEditingOptionId] = useState<string | null>(null);
|
const [editingOptionId, setEditingOptionId] = useState<string | null>(null);
|
||||||
const [editedLabel, setEditedLabel] = useState('');
|
const [editedLabel, setEditedLabel] = useState('');
|
||||||
|
const [editingPollId, setEditingPollId] = useState<string | null>(null);
|
||||||
|
const [editedPollTitle, setEditedPollTitle] = useState('');
|
||||||
|
const [editedPollQuestion, setEditedPollQuestion] = useState('');
|
||||||
const editInputRef = useRef<HTMLInputElement>(null);
|
const editInputRef = useRef<HTMLInputElement>(null);
|
||||||
const labelInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
// Select the first poll by default
|
||||||
|
useEffect(() => {
|
||||||
|
if (polls.length > 0 && !selectedPollId) {
|
||||||
|
setSelectedPollId(polls[0].id);
|
||||||
|
}
|
||||||
|
}, [polls, selectedPollId]);
|
||||||
|
|
||||||
// Generate a random ID when the component mounts or when the form is reset
|
// Generate a random ID when the component mounts or when the form is reset
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!newOptionId && voteOptions) {
|
if (!newOptionId && selectedPollId) {
|
||||||
const existingIds = voteOptions.map(option => option.id);
|
const currentPoll = polls.find(poll => poll.id === selectedPollId);
|
||||||
|
if (currentPoll) {
|
||||||
|
const existingIds = currentPoll.options.map(option => option.id);
|
||||||
setNewOptionId(generateRandomId(existingIds));
|
setNewOptionId(generateRandomId(existingIds));
|
||||||
}
|
}
|
||||||
}, [newOptionId, voteOptions]);
|
|
||||||
|
|
||||||
// Add a new vote option
|
|
||||||
const handleAddVoteOption = async () => {
|
|
||||||
if (!newOptionId || !newOptionLabel) {
|
|
||||||
setError('Bitte geben Sie sowohl eine ID als auch ein Label für die neue Option ein');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
}, [newOptionId, selectedPollId, polls]);
|
||||||
|
|
||||||
// Check if ID already exists
|
// Get the current poll
|
||||||
if (voteOptions.some(option => option.id === newOptionId)) {
|
const currentPoll = polls.find(poll => poll.id === selectedPollId);
|
||||||
setError('Eine Option mit dieser ID existiert bereits');
|
const voteOptions = currentPoll?.options || [];
|
||||||
|
|
||||||
|
// Create a new poll
|
||||||
|
const handleCreatePoll = async () => {
|
||||||
|
if (!newPollTitle.trim()) {
|
||||||
|
setError('Bitte geben Sie einen Titel für die neue Abstimmung ein');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -59,8 +73,26 @@ export default function VoteOptionsManager({ voteOptions, onVoteOptionsChange }:
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Add to local state
|
// Generate a unique ID for the new poll
|
||||||
const updatedOptions = [...voteOptions, { id: newOptionId, label: newOptionLabel }];
|
const existingPollIds = polls.map(poll => poll.id);
|
||||||
|
const newPollId = generateRandomId(existingPollIds);
|
||||||
|
|
||||||
|
// Create a new poll with default options
|
||||||
|
const newPoll: Poll = {
|
||||||
|
id: newPollId,
|
||||||
|
title: newPollTitle.trim(),
|
||||||
|
question: newPollQuestion,
|
||||||
|
options: [
|
||||||
|
{ id: 'yes', label: 'Ja, ich stimme zu' },
|
||||||
|
{ id: 'no', label: 'Nein, ich stimme nicht zu' },
|
||||||
|
{ id: 'abstain', label: 'Ich enthalte mich' }
|
||||||
|
],
|
||||||
|
active: true,
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add to polls array
|
||||||
|
const updatedPolls = [...polls, newPoll];
|
||||||
|
|
||||||
// Save to server
|
// Save to server
|
||||||
const response = await fetch('/api/editable-text', {
|
const response = await fetch('/api/editable-text', {
|
||||||
@ -69,22 +101,24 @@ export default function VoteOptionsManager({ voteOptions, onVoteOptionsChange }:
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
id: 'vote-options',
|
id: 'polls',
|
||||||
content: updatedOptions
|
content: updatedPolls
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Fehler beim Speichern der Abstimmungsoptionen');
|
throw new Error('Fehler beim Speichern der neuen Abstimmung');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update parent component state
|
// Update parent component state
|
||||||
onVoteOptionsChange(updatedOptions);
|
onPollsChange(updatedPolls);
|
||||||
|
|
||||||
// Reset form and generate a new random ID
|
// Select the new poll
|
||||||
setNewOptionLabel('');
|
setSelectedPollId(newPollId);
|
||||||
const existingIds = updatedOptions.map(option => option.id);
|
|
||||||
setNewOptionId(generateRandomId(existingIds));
|
// Reset form
|
||||||
|
setNewPollTitle('');
|
||||||
|
setNewPollQuestion('<p>Stimmen Sie dem Vorschlag zu?</p>');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
|
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
|
||||||
} finally {
|
} finally {
|
||||||
@ -92,34 +126,17 @@ export default function VoteOptionsManager({ voteOptions, onVoteOptionsChange }:
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Start editing a vote option
|
// Toggle poll active status
|
||||||
const handleEditVoteOption = (option: { id: string; label: string }) => {
|
const handleTogglePollActive = async (pollId: string) => {
|
||||||
setEditingOptionId(option.id);
|
|
||||||
setEditedLabel(option.label);
|
|
||||||
// Focus the input field after it's rendered
|
|
||||||
setTimeout(() => {
|
|
||||||
if (editInputRef.current) {
|
|
||||||
editInputRef.current.focus();
|
|
||||||
}
|
|
||||||
}, 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Save the edited vote option
|
|
||||||
const handleSaveVoteOption = async () => {
|
|
||||||
if (!editingOptionId || !editedLabel.trim()) {
|
|
||||||
setError('Das Label darf nicht leer sein');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Update in local state
|
// Update in local state
|
||||||
const updatedOptions = voteOptions.map(option =>
|
const updatedPolls = polls.map(poll =>
|
||||||
option.id === editingOptionId
|
poll.id === pollId
|
||||||
? { ...option, label: editedLabel.trim() }
|
? { ...poll, active: !poll.active }
|
||||||
: option
|
: poll
|
||||||
);
|
);
|
||||||
|
|
||||||
// Save to server
|
// Save to server
|
||||||
@ -129,20 +146,17 @@ export default function VoteOptionsManager({ voteOptions, onVoteOptionsChange }:
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
id: 'vote-options',
|
id: 'polls',
|
||||||
content: updatedOptions
|
content: updatedPolls
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Fehler beim Speichern der Abstimmungsoptionen');
|
throw new Error('Fehler beim Aktualisieren des Abstimmungsstatus');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update parent component state
|
// Update parent component state
|
||||||
onVoteOptionsChange(updatedOptions);
|
onPollsChange(updatedPolls);
|
||||||
|
|
||||||
// Exit edit mode
|
|
||||||
setEditingOptionId(null);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
|
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
|
||||||
} finally {
|
} finally {
|
||||||
@ -150,22 +164,20 @@ export default function VoteOptionsManager({ voteOptions, onVoteOptionsChange }:
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cancel editing
|
// Delete a poll
|
||||||
const handleCancelEdit = () => {
|
const handleDeletePoll = async (pollId: string) => {
|
||||||
setEditingOptionId(null);
|
// Show confirmation dialog
|
||||||
};
|
const pollToDelete = polls.find(poll => poll.id === pollId);
|
||||||
|
if (!confirm(`Sind Sie sicher, dass Sie die Abstimmung "${pollToDelete?.title}" löschen möchten? Alle zugehörigen Abstimmungsdaten werden gelöscht.`)) {
|
||||||
// Move option up in the list
|
return;
|
||||||
const handleMoveOptionUp = async (index: number) => {
|
}
|
||||||
if (index <= 0) return; // Already at the top
|
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create a new array with the option moved up
|
// Remove from local state
|
||||||
const updatedOptions = [...voteOptions];
|
const updatedPolls = polls.filter(poll => poll.id !== pollId);
|
||||||
[updatedOptions[index - 1], updatedOptions[index]] = [updatedOptions[index], updatedOptions[index - 1]];
|
|
||||||
|
|
||||||
// Save to server
|
// Save to server
|
||||||
const response = await fetch('/api/editable-text', {
|
const response = await fetch('/api/editable-text', {
|
||||||
@ -174,17 +186,22 @@ export default function VoteOptionsManager({ voteOptions, onVoteOptionsChange }:
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
id: 'vote-options',
|
id: 'polls',
|
||||||
content: updatedOptions
|
content: updatedPolls
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Fehler beim Speichern der Abstimmungsoptionen');
|
throw new Error('Fehler beim Löschen der Abstimmung');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update parent component state
|
// Update parent component state
|
||||||
onVoteOptionsChange(updatedOptions);
|
onPollsChange(updatedPolls);
|
||||||
|
|
||||||
|
// If the deleted poll was selected, select another one
|
||||||
|
if (selectedPollId === pollId) {
|
||||||
|
setSelectedPollId(updatedPolls.length > 0 ? updatedPolls[0].id : null);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
|
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
|
||||||
} finally {
|
} finally {
|
||||||
@ -192,17 +209,38 @@ export default function VoteOptionsManager({ voteOptions, onVoteOptionsChange }:
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Move option down in the list
|
// Add a new vote option to the current poll
|
||||||
const handleMoveOptionDown = async (index: number) => {
|
const handleAddVoteOption = async () => {
|
||||||
if (index >= voteOptions.length - 1) return; // Already at the bottom
|
if (!selectedPollId) {
|
||||||
|
setError('Bitte wählen Sie zuerst eine Abstimmung aus');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!newOptionId || !newOptionLabel) {
|
||||||
|
setError('Bitte geben Sie sowohl eine ID als auch ein Label für die neue Option ein');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if ID already exists in the current poll
|
||||||
|
if (currentPoll && currentPoll.options.some(option => option.id === newOptionId)) {
|
||||||
|
setError('Eine Option mit dieser ID existiert bereits in dieser Abstimmung');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create a new array with the option moved down
|
// Update the current poll with the new option
|
||||||
const updatedOptions = [...voteOptions];
|
const updatedPolls = polls.map(poll => {
|
||||||
[updatedOptions[index], updatedOptions[index + 1]] = [updatedOptions[index + 1], updatedOptions[index]];
|
if (poll.id === selectedPollId) {
|
||||||
|
return {
|
||||||
|
...poll,
|
||||||
|
options: [...poll.options, { id: newOptionId, label: newOptionLabel }]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return poll;
|
||||||
|
});
|
||||||
|
|
||||||
// Save to server
|
// Save to server
|
||||||
const response = await fetch('/api/editable-text', {
|
const response = await fetch('/api/editable-text', {
|
||||||
@ -211,8 +249,8 @@ export default function VoteOptionsManager({ voteOptions, onVoteOptionsChange }:
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
id: 'vote-options',
|
id: 'polls',
|
||||||
content: updatedOptions
|
content: updatedPolls
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -221,7 +259,14 @@ export default function VoteOptionsManager({ voteOptions, onVoteOptionsChange }:
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update parent component state
|
// Update parent component state
|
||||||
onVoteOptionsChange(updatedOptions);
|
onPollsChange(updatedPolls);
|
||||||
|
|
||||||
|
// Reset form and generate a new random ID
|
||||||
|
setNewOptionLabel('');
|
||||||
|
if (currentPoll) {
|
||||||
|
const existingIds = currentPoll.options.map(option => option.id);
|
||||||
|
setNewOptionId(generateRandomId(existingIds));
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
|
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
|
||||||
} finally {
|
} finally {
|
||||||
@ -231,6 +276,8 @@ export default function VoteOptionsManager({ voteOptions, onVoteOptionsChange }:
|
|||||||
|
|
||||||
// Remove a vote option
|
// Remove a vote option
|
||||||
const handleRemoveVoteOption = async (optionId: string) => {
|
const handleRemoveVoteOption = async (optionId: string) => {
|
||||||
|
if (!selectedPollId) return;
|
||||||
|
|
||||||
// Don't allow removing if there are less than 2 options
|
// Don't allow removing if there are less than 2 options
|
||||||
if (voteOptions.length <= 1) {
|
if (voteOptions.length <= 1) {
|
||||||
setError('Es muss mindestens eine Abstimmungsoption vorhanden sein');
|
setError('Es muss mindestens eine Abstimmungsoption vorhanden sein');
|
||||||
@ -247,8 +294,16 @@ export default function VoteOptionsManager({ voteOptions, onVoteOptionsChange }:
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Remove from local state
|
// Update the current poll with the option removed
|
||||||
const updatedOptions = voteOptions.filter(option => option.id !== optionId);
|
const updatedPolls = polls.map(poll => {
|
||||||
|
if (poll.id === selectedPollId) {
|
||||||
|
return {
|
||||||
|
...poll,
|
||||||
|
options: poll.options.filter(option => option.id !== optionId)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return poll;
|
||||||
|
});
|
||||||
|
|
||||||
// Save to server
|
// Save to server
|
||||||
const response = await fetch('/api/editable-text', {
|
const response = await fetch('/api/editable-text', {
|
||||||
@ -257,8 +312,8 @@ export default function VoteOptionsManager({ voteOptions, onVoteOptionsChange }:
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
id: 'vote-options',
|
id: 'polls',
|
||||||
content: updatedOptions
|
content: updatedPolls
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -267,7 +322,7 @@ export default function VoteOptionsManager({ voteOptions, onVoteOptionsChange }:
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update parent component state
|
// Update parent component state
|
||||||
onVoteOptionsChange(updatedOptions);
|
onPollsChange(updatedPolls);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
|
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
|
||||||
} finally {
|
} finally {
|
||||||
@ -278,7 +333,7 @@ export default function VoteOptionsManager({ voteOptions, onVoteOptionsChange }:
|
|||||||
return (
|
return (
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
<h2 className="text-xl font-bold text-[#0057a6]">Abstimmungsoptionen</h2>
|
<h2 className="text-xl font-bold text-[#0057a6]">Abstimmungen verwalten</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowVoteOptions(!showVoteOptions)}
|
onClick={() => setShowVoteOptions(!showVoteOptions)}
|
||||||
className="text-sm text-[#0057a6] hover:underline"
|
className="text-sm text-[#0057a6] hover:underline"
|
||||||
@ -289,81 +344,45 @@ export default function VoteOptionsManager({ voteOptions, onVoteOptionsChange }:
|
|||||||
|
|
||||||
{showVoteOptions && (
|
{showVoteOptions && (
|
||||||
<div className="bg-[#e6f0fa] p-4 mb-4">
|
<div className="bg-[#e6f0fa] p-4 mb-4">
|
||||||
<h3 className="font-medium text-[#0057a6] mb-3">Aktuelle Optionen</h3>
|
{/* Poll selection */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="font-medium text-[#0057a6] mb-3">Abstimmungen</h3>
|
||||||
|
|
||||||
<div className="space-y-2 mb-4">
|
<div className="space-y-2 mb-4">
|
||||||
{voteOptions.map((option, index) => (
|
{polls.map(poll => (
|
||||||
<div key={option.id} className="flex items-center justify-between p-2 bg-white">
|
<div key={poll.id} className="flex items-center justify-between p-3 bg-white">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex-1">
|
||||||
<div className="flex flex-col">
|
<div className="font-medium">{poll.title}</div>
|
||||||
<button
|
<div className="text-sm text-gray-500">
|
||||||
onClick={() => handleMoveOptionUp(index)}
|
ID: {poll.id} | Status: {poll.active ? 'Aktiv' : 'Inaktiv'} |
|
||||||
className="text-gray-600 hover:text-gray-800 disabled:opacity-30"
|
Erstellt: {new Date(poll.createdAt).toLocaleDateString()}
|
||||||
disabled={index === 0}
|
|
||||||
title="Nach oben verschieben"
|
|
||||||
>
|
|
||||||
▲
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleMoveOptionDown(index)}
|
|
||||||
className="text-gray-600 hover:text-gray-800 disabled:opacity-30"
|
|
||||||
disabled={index === voteOptions.length - 1}
|
|
||||||
title="Nach unten verschieben"
|
|
||||||
>
|
|
||||||
▼
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{editingOptionId === option.id ? (
|
|
||||||
<div className="flex items-center">
|
|
||||||
<span className="font-medium mr-2">{option.id}:</span>
|
|
||||||
<input
|
|
||||||
ref={editInputRef}
|
|
||||||
type="text"
|
|
||||||
value={editedLabel}
|
|
||||||
onChange={(e) => setEditedLabel(e.target.value)}
|
|
||||||
className="px-2 py-1 border border-gray-300 focus:outline-none focus:border-[#0057a6]"
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') handleSaveVoteOption();
|
|
||||||
if (e.key === 'Escape') handleCancelEdit();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleSaveVoteOption}
|
|
||||||
className="ml-2 text-green-600 hover:text-green-800"
|
|
||||||
title="Speichern"
|
|
||||||
>
|
|
||||||
✓
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleCancelEdit}
|
|
||||||
className="ml-1 text-gray-600 hover:text-gray-800"
|
|
||||||
title="Abbrechen"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
<span className="font-medium">{option.id}:</span> {option.label}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
{editingOptionId !== option.id && (
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handleEditVoteOption(option)}
|
onClick={() => setSelectedPollId(poll.id)}
|
||||||
className="text-blue-600 hover:text-blue-800"
|
className={`px-2 py-1 text-sm rounded ${selectedPollId === poll.id
|
||||||
title="Option bearbeiten"
|
? 'bg-[#0057a6] text-white'
|
||||||
|
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
title="Diese Abstimmung bearbeiten"
|
||||||
>
|
>
|
||||||
✎
|
Bearbeiten
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handleRemoveVoteOption(option.id)}
|
onClick={() => handleTogglePollActive(poll.id)}
|
||||||
|
className={`px-2 py-1 text-sm rounded ${poll.active
|
||||||
|
? 'bg-green-100 text-green-800 hover:bg-green-200'
|
||||||
|
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
title={poll.active ? 'Abstimmung deaktivieren' : 'Abstimmung aktivieren'}
|
||||||
|
>
|
||||||
|
{poll.active ? 'Aktiv' : 'Inaktiv'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeletePoll(poll.id)}
|
||||||
className="text-red-600 hover:text-red-800"
|
className="text-red-600 hover:text-red-800"
|
||||||
title="Option entfernen"
|
title="Abstimmung löschen"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
@ -372,11 +391,80 @@ export default function VoteOptionsManager({ voteOptions, onVoteOptionsChange }:
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 className="font-medium text-[#0057a6] mb-2">Neue Option hinzufügen</h3>
|
{/* Create new poll form */}
|
||||||
|
<div className="p-3 bg-white mb-4">
|
||||||
|
<h4 className="font-medium text-[#0057a6] mb-2">Neue Abstimmung erstellen</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="newPollTitle" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Titel
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="newPollTitle"
|
||||||
|
value={newPollTitle}
|
||||||
|
onChange={(e) => setNewPollTitle(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 focus:outline-none focus:border-[#0057a6]"
|
||||||
|
placeholder="Titel der Abstimmung"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="newPollQuestion" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Frage
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="newPollQuestion"
|
||||||
|
value={newPollQuestion}
|
||||||
|
onChange={(e) => setNewPollQuestion(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 focus:outline-none focus:border-[#0057a6]"
|
||||||
|
placeholder="Frage der Abstimmung"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
HTML-Tags wie <p> sind erlaubt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleCreatePoll}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="ssvc-button w-full disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Wird erstellt...' : 'Abstimmung erstellen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Vote options section */}
|
||||||
|
{selectedPollId && currentPoll && (
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-[#0057a6] mb-3">
|
||||||
|
Abstimmungsoptionen für "{currentPoll.title}"
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-2 mb-4">
|
||||||
|
{voteOptions.map((option) => (
|
||||||
|
<div key={option.id} className="flex items-center justify-between p-2 bg-white">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">{option.id}:</span> {option.label}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveVoteOption(option.id)}
|
||||||
|
className="text-red-600 hover:text-red-800"
|
||||||
|
title="Option entfernen"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 bg-white">
|
||||||
|
<h4 className="font-medium text-[#0057a6] mb-2">Neue Option hinzufügen</h4>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="newOptionId" className="block text-sm font-medium text-gray-700 mb-1">
|
<label htmlFor="newOptionId" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Option ID (z.B. "yes", "no")
|
Option ID
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@ -386,11 +474,7 @@ export default function VoteOptionsManager({ voteOptions, onVoteOptionsChange }:
|
|||||||
className="w-full px-3 py-2 border border-gray-300 focus:outline-none focus:border-[#0057a6]"
|
className="w-full px-3 py-2 border border-gray-300 focus:outline-none focus:border-[#0057a6]"
|
||||||
placeholder="option-id"
|
placeholder="option-id"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
Die ID wird intern verwendet und sollte kurz und eindeutig sein.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="newOptionLabel" className="block text-sm font-medium text-gray-700 mb-1">
|
<label htmlFor="newOptionLabel" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Anzeigename
|
Anzeigename
|
||||||
@ -398,28 +482,15 @@ export default function VoteOptionsManager({ voteOptions, onVoteOptionsChange }:
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="newOptionLabel"
|
id="newOptionLabel"
|
||||||
ref={labelInputRef}
|
|
||||||
value={newOptionLabel}
|
value={newOptionLabel}
|
||||||
onChange={(e) => setNewOptionLabel(e.target.value)}
|
onChange={(e) => setNewOptionLabel(e.target.value)}
|
||||||
className="w-full px-3 py-2 border border-gray-300 focus:outline-none focus:border-[#0057a6]"
|
className="w-full px-3 py-2 border border-gray-300 focus:outline-none focus:border-[#0057a6]"
|
||||||
placeholder="Anzeigename der Option"
|
placeholder="Anzeigename der Option"
|
||||||
onFocus={() => {
|
|
||||||
// Generate a random ID if the field is empty
|
|
||||||
if (!newOptionId) {
|
|
||||||
const existingIds = voteOptions.map(option => option.id);
|
|
||||||
setNewOptionId(generateRandomId(existingIds));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
Der Anzeigename wird den Abstimmenden angezeigt.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="text-red-500 text-sm">{error}</div>
|
<div className="text-red-500 text-sm">{error}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleAddVoteOption}
|
onClick={handleAddVoteOption}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
@ -428,13 +499,11 @@ export default function VoteOptionsManager({ voteOptions, onVoteOptionsChange }:
|
|||||||
{isLoading ? 'Wird hinzugefügt...' : 'Option hinzufügen'}
|
{isLoading ? 'Wird hinzugefügt...' : 'Option hinzufügen'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 text-sm text-gray-600">
|
|
||||||
<p className="font-medium">Hinweis:</p>
|
|
||||||
<p>Das Ändern der Abstimmungsoptionen wirkt sich nur auf neue Abstimmungen aus. Bestehende Abstimmungsdaten bleiben unverändert.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,33 +1,48 @@
|
|||||||
import { SignJWT, jwtVerify } from 'jose';
|
import { SignJWT } from 'jose';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import { cookies } from 'next/headers';
|
import { getMemberCredentials } from './server-auth';
|
||||||
|
|
||||||
// Interface for member credentials
|
// Interface for member credentials
|
||||||
export interface MemberCredential {
|
export interface MemberCredential {
|
||||||
memberNumber: string;
|
memberNumber: string;
|
||||||
password: string; // Hashed password
|
password: string;
|
||||||
hasVoted: boolean;
|
votedPolls: string[]; // Track polls the member has voted in
|
||||||
lastLogin?: string; // ISO date string
|
lastLogin?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// These functions are now only used server-side in API routes
|
|
||||||
// Client components should use the API routes instead
|
|
||||||
|
|
||||||
// Generate a one-time use token for voting
|
// Generate a one-time use token for voting
|
||||||
export async function generateRandomToken(memberNumber?: string): Promise<string> {
|
export async function generateRandomToken(memberNumber?: string, pollId?: string): Promise<string> {
|
||||||
// This function is used in API routes
|
// This function is used in API routes
|
||||||
const tokenId = uuidv4();
|
const tokenId = uuidv4();
|
||||||
type Payload = { tokenId: string; memberNumber?: string };
|
type Payload = { tokenId: string; memberNumber?: string; pollId?: string; votedPolls?: string[] };
|
||||||
const payload: Payload = { tokenId };
|
const payload: Payload = { tokenId };
|
||||||
|
|
||||||
// If memberNumber is provided, include it in the token
|
// If memberNumber is provided, include it in the token
|
||||||
if (memberNumber) {
|
if (memberNumber) {
|
||||||
payload.memberNumber = memberNumber;
|
payload.memberNumber = memberNumber;
|
||||||
|
|
||||||
|
// Fetch the member's votedPolls from the member_credentials.json file
|
||||||
|
const members = getMemberCredentials();
|
||||||
|
const member = members.find(m => m.memberNumber === memberNumber);
|
||||||
|
if (member) {
|
||||||
|
// Add the pollId to the member's votedPolls if not already present
|
||||||
|
if (pollId && !member.votedPolls.includes(pollId)) {
|
||||||
|
member.votedPolls.push(pollId);
|
||||||
|
}
|
||||||
|
payload.votedPolls = member.votedPolls;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include pollId in the payload
|
||||||
|
if (pollId) {
|
||||||
|
payload.pollId = pollId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use a secret key from environment variable or a default one
|
// Use a secret key from environment variable or a default one
|
||||||
const secretKey = process.env.JWT_SECRET_KEY || 'schafwaschener-segelverein-secret-key-for-jwt-signing';
|
const secretKey =
|
||||||
|
process.env.JWT_SECRET_KEY ||
|
||||||
|
'schafwaschener-segelverein-secret-key-for-jwt-signing';
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const key = encoder.encode(secretKey);
|
const key = encoder.encode(secretKey);
|
||||||
|
|
||||||
@ -40,58 +55,6 @@ export async function generateRandomToken(memberNumber?: string): Promise<string
|
|||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate an admin token
|
|
||||||
async function generateAdminToken(): Promise<string> {
|
|
||||||
// Use a secret key from environment variable or a default one
|
|
||||||
const secretKey = process.env.JWT_SECRET_KEY || 'schafwaschener-segelverein-secret-key-for-jwt-signing';
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
const key = encoder.encode(secretKey);
|
|
||||||
|
|
||||||
const token = await new SignJWT({ role: 'admin' })
|
|
||||||
.setProtectedHeader({ alg: 'HS256' })
|
|
||||||
.setIssuedAt()
|
|
||||||
.setExpirationTime('24h') // Admin token expires in 24 hours
|
|
||||||
.sign(key);
|
|
||||||
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify an admin token
|
|
||||||
async function verifyAdminToken(): Promise<boolean> {
|
|
||||||
const token = (await cookies()).get('admin-token')?.value;
|
|
||||||
if (!token) return false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Use a secret key from environment variable or a default one
|
|
||||||
const secretKey = process.env.JWT_SECRET_KEY || 'schafwaschener-segelverein-secret-key-for-jwt-signing';
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
const key = encoder.encode(secretKey);
|
|
||||||
|
|
||||||
const { payload } = await jwtVerify(token, key);
|
|
||||||
return payload.role === 'admin';
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Admin token verification failed:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function checkAdminAuth(password?: string): Promise<boolean> {
|
|
||||||
// Get admin password from environment variable or use default for development
|
|
||||||
const isAuthenticated = await verifyAdminToken();
|
|
||||||
if (isAuthenticated) return true;
|
|
||||||
if (password) {
|
|
||||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'schafwaschener-segelverein-admin';
|
|
||||||
if (password === ADMIN_PASSWORD) {
|
|
||||||
// Password is correct, generate and set admin token
|
|
||||||
const newAdminToken = await generateAdminToken();
|
|
||||||
(await cookies()).set('admin-token', newAdminToken);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Hash a password
|
// Hash a password
|
||||||
export function hashPassword(password: string): string {
|
export function hashPassword(password: string): string {
|
||||||
return bcrypt.hashSync(password, 10);
|
return bcrypt.hashSync(password, 10);
|
||||||
|
@ -107,34 +107,13 @@ export function addMember(memberNumber: string, password: string): boolean {
|
|||||||
members.push({
|
members.push({
|
||||||
memberNumber,
|
memberNumber,
|
||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
hasVoted: false
|
votedPolls: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
saveMemberCredentials(members);
|
saveMemberCredentials(members);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update a member
|
|
||||||
export function updateMember(memberNumber: string, data: { password?: string, hasVoted?: boolean }): boolean {
|
|
||||||
const members = getMemberCredentials();
|
|
||||||
const memberIndex = members.findIndex(m => m.memberNumber === memberNumber);
|
|
||||||
|
|
||||||
if (memberIndex === -1) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.password) {
|
|
||||||
members[memberIndex].password = hashPassword(data.password);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.hasVoted !== undefined) {
|
|
||||||
members[memberIndex].hasVoted = data.hasVoted;
|
|
||||||
}
|
|
||||||
|
|
||||||
saveMemberCredentials(members);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete a member
|
// Delete a member
|
||||||
export function deleteMember(memberNumber: string): boolean {
|
export function deleteMember(memberNumber: string): boolean {
|
||||||
const members = getMemberCredentials();
|
const members = getMemberCredentials();
|
||||||
@ -151,7 +130,7 @@ export function deleteMember(memberNumber: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Import members from CSV content
|
// Import members from CSV content
|
||||||
export function importMembersFromCSV(csvContent: string): { added: number, skipped: number } {
|
export function importMembersFromCSV(csvContent: string): { added: number; skipped: number } {
|
||||||
const members = getMemberCredentials();
|
const members = getMemberCredentials();
|
||||||
const existingMemberNumbers = new Set(members.map(m => m.memberNumber));
|
const existingMemberNumbers = new Set(members.map(m => m.memberNumber));
|
||||||
|
|
||||||
@ -190,7 +169,7 @@ export function importMembersFromCSV(csvContent: string): { added: number, skipp
|
|||||||
members.push({
|
members.push({
|
||||||
memberNumber,
|
memberNumber,
|
||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
hasVoted: false
|
votedPolls: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
existingMemberNumbers.add(memberNumber);
|
existingMemberNumbers.add(memberNumber);
|
||||||
@ -202,29 +181,17 @@ export function importMembersFromCSV(csvContent: string): { added: number, skipp
|
|||||||
return { added, skipped };
|
return { added, skipped };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify member credentials
|
// Update member's votedPolls
|
||||||
export function verifyMemberCredentials(memberNumber: string, password: string): { valid: boolean, hasVoted: boolean } {
|
export function markMemberAsVoted(memberNumber: string, pollId: string): void {
|
||||||
const members = getMemberCredentials();
|
const members = getMemberCredentials();
|
||||||
const member = members.find(m => m.memberNumber === memberNumber);
|
const memberIndex = members.findIndex(m => m.memberNumber === memberNumber);
|
||||||
|
|
||||||
if (!member) {
|
if (memberIndex !== -1) {
|
||||||
return { valid: false, hasVoted: false };
|
if (!members[memberIndex].votedPolls.includes(pollId)) {
|
||||||
}
|
members[memberIndex].votedPolls.push(pollId);
|
||||||
|
|
||||||
const passwordValid = comparePassword(password, member.password);
|
|
||||||
|
|
||||||
if (passwordValid) {
|
|
||||||
// Update last login time
|
|
||||||
member.lastLogin = new Date().toISOString();
|
|
||||||
saveMemberCredentials(members);
|
saveMemberCredentials(members);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { valid: passwordValid, hasVoted: member.hasVoted };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark member as voted
|
|
||||||
export function markMemberAsVoted(memberNumber: string): boolean {
|
|
||||||
return updateMember(memberNumber, { hasVoted: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset all member voting status
|
// Reset all member voting status
|
||||||
@ -232,7 +199,7 @@ export function resetMemberVotingStatus(): void {
|
|||||||
const members = getMemberCredentials();
|
const members = getMemberCredentials();
|
||||||
|
|
||||||
for (const member of members) {
|
for (const member of members) {
|
||||||
member.hasVoted = false;
|
member.votedPolls = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
saveMemberCredentials(members);
|
saveMemberCredentials(members);
|
||||||
@ -284,7 +251,7 @@ export async function ensureKeys() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify a token and mark it as used
|
// Verify a token and mark it as used
|
||||||
export async function verifyToken(token: string): Promise<{ valid: boolean, memberNumber?: string }> {
|
export async function verifyToken(token: string): Promise<{ valid: boolean, memberNumber?: string, votedPolls?: string[] }> {
|
||||||
try {
|
try {
|
||||||
// Use a secret key from environment variable or a default one
|
// Use a secret key from environment variable or a default one
|
||||||
const secretKey = process.env.JWT_SECRET_KEY || 'schafwaschener-segelverein-secret-key-for-jwt-signing';
|
const secretKey = process.env.JWT_SECRET_KEY || 'schafwaschener-segelverein-secret-key-for-jwt-signing';
|
||||||
@ -294,6 +261,8 @@ export async function verifyToken(token: string): Promise<{ valid: boolean, memb
|
|||||||
const { payload } = await jwtVerify(token, key);
|
const { payload } = await jwtVerify(token, key);
|
||||||
const tokenId = payload.tokenId as string;
|
const tokenId = payload.tokenId as string;
|
||||||
const memberNumber = payload.memberNumber as string | undefined;
|
const memberNumber = payload.memberNumber as string | undefined;
|
||||||
|
const pollId = payload.pollId as string; // Get pollId from the payload
|
||||||
|
const votedPolls = payload.votedPolls as string[] | undefined; // Get votedPolls from the payload
|
||||||
|
|
||||||
// Check if token has been used before
|
// Check if token has been used before
|
||||||
const usedTokens = getUsedTokens();
|
const usedTokens = getUsedTokens();
|
||||||
@ -304,12 +273,24 @@ export async function verifyToken(token: string): Promise<{ valid: boolean, memb
|
|||||||
// Mark token as used by adding to blacklist
|
// Mark token as used by adding to blacklist
|
||||||
addToBlacklist(tokenId);
|
addToBlacklist(tokenId);
|
||||||
|
|
||||||
// If token contains a member number, mark that member as voted
|
// If token contains a member number, add the poll to their votedPolls
|
||||||
if (memberNumber) {
|
if (memberNumber) {
|
||||||
markMemberAsVoted(memberNumber);
|
const members = getMemberCredentials();
|
||||||
|
const memberIndex = members.findIndex(m => m.memberNumber === memberNumber);
|
||||||
|
|
||||||
|
if (memberIndex !== -1) {
|
||||||
|
// Get the poll ID from the payload
|
||||||
|
const pollId = payload.pollId as string;
|
||||||
|
|
||||||
|
// Add the poll ID to the member's votedPolls if not already present
|
||||||
|
if (pollId && !members[memberIndex].votedPolls.includes(pollId)) {
|
||||||
|
members[memberIndex].votedPolls.push(pollId);
|
||||||
|
saveMemberCredentials(members); // Save updated member data
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { valid: true, memberNumber };
|
return { valid: true, memberNumber, votedPolls: votedPolls || [] };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Token verification failed:', error);
|
console.error('Token verification failed:', error);
|
||||||
return { valid: false };
|
return { valid: false };
|
||||||
|
@ -8,11 +8,22 @@ export interface VoteOptionConfig {
|
|||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Define the poll interface
|
||||||
|
export interface Poll {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
question: string;
|
||||||
|
options: VoteOptionConfig[];
|
||||||
|
active: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Define the survey response type
|
// Define the survey response type
|
||||||
export type VoteOption = string; // Now a string that matches the option id
|
export type VoteOption = string; // Now a string that matches the option id
|
||||||
|
|
||||||
export interface SurveyResponse {
|
export interface SurveyResponse {
|
||||||
id: string; // Used only for internal tracking, not associated with the voter
|
id: string; // Used only for internal tracking, not associated with the voter
|
||||||
|
pollId: string; // ID of the poll this response is for
|
||||||
vote: VoteOption;
|
vote: VoteOption;
|
||||||
comment?: string;
|
comment?: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
@ -30,8 +41,118 @@ function ensureDataDirectory() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get vote options from editable_text.json
|
// Get all polls from editable_text.json
|
||||||
export function getVoteOptions(): VoteOptionConfig[] {
|
export function getPolls(): Poll[] {
|
||||||
|
ensureDataDirectory();
|
||||||
|
|
||||||
|
if (!fs.existsSync(TEXT_FILE)) {
|
||||||
|
// Default poll if file doesn't exist
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'default-poll',
|
||||||
|
title: 'Default Poll',
|
||||||
|
question: '<p>Stimmen Sie der vorgeschlagenen Änderung der Vereinssatzung zu?</p>',
|
||||||
|
options: [
|
||||||
|
{ id: 'yes', label: 'Ja, ich stimme zu' },
|
||||||
|
{ id: 'no', label: 'Nein, ich stimme nicht zu' },
|
||||||
|
{ id: 'abstain', label: 'Ich enthalte mich' }
|
||||||
|
],
|
||||||
|
active: true,
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = fs.readFileSync(TEXT_FILE, 'utf-8');
|
||||||
|
const texts = JSON.parse(data);
|
||||||
|
|
||||||
|
const pollsEntry = texts.find((text: { id: string }) => text.id === 'polls');
|
||||||
|
|
||||||
|
if (pollsEntry && Array.isArray(pollsEntry.content)) {
|
||||||
|
return pollsEntry.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If polls entry doesn't exist, create a default poll from the legacy format
|
||||||
|
const voteOptionsEntry = texts.find((text: { id: string }) => text.id === 'vote-options');
|
||||||
|
const voteQuestionEntry = texts.find((text: { id: string }) => text.id === 'vote-question');
|
||||||
|
|
||||||
|
if (voteOptionsEntry && Array.isArray(voteOptionsEntry.content) && voteQuestionEntry) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'default-poll',
|
||||||
|
title: 'Default Poll',
|
||||||
|
question: voteQuestionEntry.content,
|
||||||
|
options: voteOptionsEntry.content,
|
||||||
|
active: true,
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return default poll if not found or invalid
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'default-poll',
|
||||||
|
title: 'Default Poll',
|
||||||
|
question: '<p>Stimmen Sie der vorgeschlagenen Änderung der Vereinssatzung zu?</p>',
|
||||||
|
options: [
|
||||||
|
{ id: 'yes', label: 'Ja, ich stimme zu' },
|
||||||
|
{ id: 'no', label: 'Nein, ich stimme nicht zu' },
|
||||||
|
{ id: 'abstain', label: 'Ich enthalte mich' }
|
||||||
|
],
|
||||||
|
active: true,
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
}
|
||||||
|
];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reading polls:', error);
|
||||||
|
// Return default poll on error
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'default-poll',
|
||||||
|
title: 'Default Poll',
|
||||||
|
question: '<p>Stimmen Sie der vorgeschlagenen Änderung der Vereinssatzung zu?</p>',
|
||||||
|
options: [
|
||||||
|
{ id: 'yes', label: 'Ja, ich stimme zu' },
|
||||||
|
{ id: 'no', label: 'Nein, ich stimme nicht zu' },
|
||||||
|
{ id: 'abstain', label: 'Ich enthalte mich' }
|
||||||
|
],
|
||||||
|
active: true,
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get a specific poll by ID
|
||||||
|
export function getPoll(pollId: string): Poll | null {
|
||||||
|
const polls = getPolls();
|
||||||
|
return polls.find(poll => poll.id === pollId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get active polls
|
||||||
|
export function getActivePolls(): Poll[] {
|
||||||
|
const polls = getPolls();
|
||||||
|
return polls.filter(poll => poll.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get vote options for a specific poll
|
||||||
|
export function getVoteOptions(pollId?: string): VoteOptionConfig[] {
|
||||||
|
if (pollId) {
|
||||||
|
const poll = getPoll(pollId);
|
||||||
|
if (poll) {
|
||||||
|
return poll.options;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For backward compatibility, get the first active poll's options
|
||||||
|
const activePolls = getActivePolls();
|
||||||
|
if (activePolls.length > 0) {
|
||||||
|
return activePolls[0].options;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to legacy format
|
||||||
ensureDataDirectory();
|
ensureDataDirectory();
|
||||||
|
|
||||||
if (!fs.existsSync(TEXT_FILE)) {
|
if (!fs.existsSync(TEXT_FILE)) {
|
||||||
@ -82,13 +203,20 @@ export function getAllResponses(): SurveyResponse[] {
|
|||||||
return JSON.parse(data);
|
return JSON.parse(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get responses for a specific poll
|
||||||
|
export function getPollResponses(pollId: string): SurveyResponse[] {
|
||||||
|
const responses = getAllResponses();
|
||||||
|
return responses.filter(response => response.pollId === pollId);
|
||||||
|
}
|
||||||
|
|
||||||
// Save a new survey response
|
// Save a new survey response
|
||||||
export function saveResponse(vote: VoteOption, comment?: string): SurveyResponse {
|
export function saveResponse(pollId: string, vote: VoteOption, comment?: string): SurveyResponse {
|
||||||
const responses = getAllResponses();
|
const responses = getAllResponses();
|
||||||
|
|
||||||
// Create a new response with a random ID (not associated with the voter)
|
// Create a new response with a random ID (not associated with the voter)
|
||||||
const newResponse: SurveyResponse = {
|
const newResponse: SurveyResponse = {
|
||||||
id: uuidv4(), // Random ID only for internal tracking
|
id: uuidv4(), // Random ID only for internal tracking
|
||||||
|
pollId,
|
||||||
vote,
|
vote,
|
||||||
comment,
|
comment,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
@ -102,10 +230,77 @@ export function saveResponse(vote: VoteOption, comment?: string): SurveyResponse
|
|||||||
return newResponse;
|
return newResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get survey statistics
|
// Get survey statistics for all polls
|
||||||
export function getSurveyStats() {
|
export function getSurveyStats() {
|
||||||
const responses = getAllResponses();
|
const responses = getAllResponses();
|
||||||
const voteOptions = getVoteOptions();
|
const polls = getPolls();
|
||||||
|
|
||||||
|
// Create stats for each poll
|
||||||
|
const pollStats: Record<string, Record<string, number>> = {};
|
||||||
|
|
||||||
|
// Initialize stats for each poll
|
||||||
|
polls.forEach(poll => {
|
||||||
|
const pollResponses = responses.filter(response => response.pollId === poll.id);
|
||||||
|
|
||||||
|
// Initialize stats object with total count
|
||||||
|
const stats: Record<string, number> = {
|
||||||
|
total: pollResponses.length
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize count for each vote option
|
||||||
|
poll.options.forEach(option => {
|
||||||
|
stats[option.id] = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Count votes for each option
|
||||||
|
pollResponses.forEach(response => {
|
||||||
|
if (stats[response.vote] !== undefined) {
|
||||||
|
stats[response.vote]++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pollStats[poll.id] = stats;
|
||||||
|
});
|
||||||
|
|
||||||
|
// For backward compatibility, also return stats for all responses combined
|
||||||
|
const allStats: Record<string, number> = {
|
||||||
|
total: responses.length
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get all unique option IDs across all polls
|
||||||
|
const allOptionIds = new Set<string>();
|
||||||
|
polls.forEach(poll => {
|
||||||
|
poll.options.forEach(option => {
|
||||||
|
allOptionIds.add(option.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize count for each vote option
|
||||||
|
Array.from(allOptionIds).forEach(optionId => {
|
||||||
|
allStats[optionId] = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Count votes for each option
|
||||||
|
responses.forEach(response => {
|
||||||
|
if (allStats[response.vote] !== undefined) {
|
||||||
|
allStats[response.vote]++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
all: allStats,
|
||||||
|
byPoll: pollStats
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get survey statistics for a specific poll
|
||||||
|
export function getPollStats(pollId: string) {
|
||||||
|
const responses = getPollResponses(pollId);
|
||||||
|
const poll = getPoll(pollId);
|
||||||
|
|
||||||
|
if (!poll) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize stats object with total count
|
// Initialize stats object with total count
|
||||||
const stats: Record<string, number> = {
|
const stats: Record<string, number> = {
|
||||||
@ -113,7 +308,7 @@ export function getSurveyStats() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Initialize count for each vote option
|
// Initialize count for each vote option
|
||||||
voteOptions.forEach(option => {
|
poll.options.forEach(option => {
|
||||||
stats[option.id] = 0;
|
stats[option.id] = 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user