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", | ||||
|     "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", | ||||
|  | ||||
| @ -1,6 +1,7 @@ | ||||
| [ | ||||
|   { | ||||
|     "id": "0dfdb724-b6f2-47db-917b-0d233bef5e93", | ||||
|     "pollId": "satzung-2025", | ||||
|     "vote": "abstain", | ||||
|     "comment": "ich was.", | ||||
|     "timestamp": "2025-03-02T19:12:44.209Z" | ||||
|  | ||||
| @ -1,7 +1,7 @@ | ||||
| 'use client'; | ||||
| 
 | ||||
| import { useState, useEffect } from 'react'; | ||||
| import { VoteOption } from '@/lib/survey'; | ||||
| import { VoteOption, Poll } from '@/lib/survey'; | ||||
| import { | ||||
|   LoginForm, | ||||
|   TextEditor, | ||||
| @ -20,8 +20,14 @@ interface Stats { | ||||
|   [key: string]: number; // Allow dynamic keys for vote options
 | ||||
| } | ||||
| 
 | ||||
| interface PollStats { | ||||
|   all: Stats; | ||||
|   byPoll: Record<string, Stats>; | ||||
| } | ||||
| 
 | ||||
| interface Comment { | ||||
|   vote: VoteOption; | ||||
|   pollId?: string; | ||||
|   comment: string; | ||||
|   timestamp: string; | ||||
| } | ||||
| @ -33,7 +39,7 @@ interface EditableText { | ||||
| 
 | ||||
| export default function AdminPage() { | ||||
|   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 [isAuthenticated, setIsAuthenticated] = 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: '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 }[]>([ | ||||
|     { id: 'yes', label: 'Ja, ich stimme zu' }, | ||||
|     { id: 'no', label: 'Nein, ich stimme nicht zu' }, | ||||
| @ -99,13 +119,33 @@ export default function AdminPage() { | ||||
|         if (response.ok) { | ||||
|           const data = await response.json(); | ||||
|           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'); | ||||
|             if (voteOptionsEntry && Array.isArray(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) { | ||||
| @ -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); | ||||
|     setComments(loadedComments); | ||||
|     if (loadedPolls) { | ||||
|       setPolls(loadedPolls); | ||||
|     } | ||||
|     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 }[]) => { | ||||
|     setVoteOptions(newOptions); | ||||
|   }; | ||||
| @ -232,8 +285,8 @@ export default function AdminPage() { | ||||
|             /> | ||||
| 
 | ||||
|             <VoteOptionsManager | ||||
|               voteOptions={voteOptions} | ||||
|               onVoteOptionsChange={handleVoteOptionsChange} | ||||
|               polls={polls} | ||||
|               onPollsChange={handlePollsChange} | ||||
|             /> | ||||
| 
 | ||||
|             <MemberAuthManager | ||||
| @ -250,6 +303,7 @@ export default function AdminPage() { | ||||
|             stats={stats!} | ||||
|             comments={comments} | ||||
|             voteOptions={voteOptions} | ||||
|             polls={polls} | ||||
|             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 { NextRequest, NextResponse } from 'next/server'; | ||||
| import path from 'path'; | ||||
| @ -8,127 +9,125 @@ const TEXT_FILE = path.join(process.cwd(), 'data', 'editable_text.json'); | ||||
| export type EditableText = { id: string; content: string }; | ||||
| // Ensure the data directory exists
 | ||||
| function ensureDataDirectory() { | ||||
|   const dataDir = path.join(process.cwd(), 'data'); | ||||
|   if (!fs.existsSync(dataDir)) { | ||||
|     fs.mkdirSync(dataDir, { recursive: true }); | ||||
|   } | ||||
|     const dataDir = path.join(process.cwd(), 'data'); | ||||
|     if (!fs.existsSync(dataDir)) { | ||||
|         fs.mkdirSync(dataDir, { recursive: true }); | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| // Get all editable texts
 | ||||
| function getEditableTexts() { | ||||
|   ensureDataDirectory(); | ||||
|     ensureDataDirectory(); | ||||
| 
 | ||||
|   if (!fs.existsSync(TEXT_FILE)) { | ||||
|     // Default texts
 | ||||
|     const defaultTexts = [ | ||||
|       { | ||||
|         id: 'welcome-text', | ||||
|         content: '<p>Herzlich willkommen bei der Online-Abstimmungsplattform des SCHAFWASCHENER SEGELVEREIN CHIEMSEE E.V. RIMSTING.</p><p>Diese Plattform ermöglicht es Mitgliedern, sicher und bequem über Vereinsangelegenheiten abzustimmen.</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-options', | ||||
|         content: [ | ||||
|           { | ||||
|             id: 'yes', | ||||
|             label: 'Ja, ich stimme zu' | ||||
|           }, | ||||
|           { | ||||
|             id: 'no', | ||||
|             label: 'Nein, ich stimme nicht zu' | ||||
|           }, | ||||
|           { | ||||
|             id: 'abstain', | ||||
|             label: 'Ich enthalte mich' | ||||
|           } | ||||
|         ] | ||||
|       } | ||||
|     ]; | ||||
|     if (!fs.existsSync(TEXT_FILE)) { | ||||
|         // Default texts
 | ||||
|         const defaultTexts = [ | ||||
|             { | ||||
|                 id: 'welcome-text', | ||||
|                 content: '<p>Herzlich willkommen bei der Online-Abstimmungsplattform des SCHAFWASCHENER SEGELVEREIN CHIEMSEE E.V. RIMSTING.</p><p>Diese Plattform ermöglicht es Mitgliedern, sicher und bequem über Vereinsangelegenheiten abzustimmen.</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-options', | ||||
|                 content: [ | ||||
|                     { | ||||
|                         id: 'yes', | ||||
|                         label: 'Ja, ich stimme zu' | ||||
|                     }, | ||||
|                     { | ||||
|                         id: 'no', | ||||
|                         label: 'Nein, ich stimme nicht zu' | ||||
|                     }, | ||||
|                     { | ||||
|                         id: 'abstain', | ||||
|                         label: 'Ich enthalte mich' | ||||
|                     } | ||||
|                 ] | ||||
|             } | ||||
|         ]; | ||||
| 
 | ||||
|     fs.writeFileSync(TEXT_FILE, JSON.stringify(defaultTexts, null, 2)); | ||||
|     return defaultTexts; | ||||
|   } | ||||
|         fs.writeFileSync(TEXT_FILE, JSON.stringify(defaultTexts, null, 2)); | ||||
|         return defaultTexts; | ||||
|     } | ||||
| 
 | ||||
|   const data = fs.readFileSync(TEXT_FILE, 'utf-8'); | ||||
|   return JSON.parse(data); | ||||
|     const data = fs.readFileSync(TEXT_FILE, 'utf-8'); | ||||
|     return JSON.parse(data); | ||||
| } | ||||
| 
 | ||||
| // Save editable texts
 | ||||
| 
 | ||||
| function saveEditableTexts(texts: EditableText[]) { | ||||
|   ensureDataDirectory(); | ||||
|   fs.writeFileSync(TEXT_FILE, JSON.stringify(texts, null, 2)); | ||||
|     ensureDataDirectory(); | ||||
|     fs.writeFileSync(TEXT_FILE, JSON.stringify(texts, null, 2)); | ||||
| } | ||||
| 
 | ||||
| // GET handler to retrieve all editable texts
 | ||||
| export async function GET() { | ||||
|   try { | ||||
|     const texts = getEditableTexts(); | ||||
|     return NextResponse.json({ texts }); | ||||
|   } catch (error) { | ||||
|     console.error('Error getting editable texts:', error); | ||||
|     return NextResponse.json( | ||||
|       { error: 'Failed to get editable texts' }, | ||||
|       { status: 500 } | ||||
|     ); | ||||
|   } | ||||
|     try { | ||||
|         const texts = getEditableTexts(); | ||||
|         return NextResponse.json({ texts }); | ||||
|     } catch (error) { | ||||
|         console.error('Error getting editable texts:', error); | ||||
|         return NextResponse.json( | ||||
|             { error: 'Failed to get editable texts' }, | ||||
|             { status: 500 } | ||||
|         ); | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| // POST handler to update an editable text (requires admin authentication)
 | ||||
| export async function POST(request: NextRequest) { | ||||
|   try { | ||||
|     const body = await request.json(); | ||||
|     // Check for admin auth
 | ||||
|     const { password, } = body; | ||||
|     const isAuthenticated = await checkAdminAuth(password); | ||||
|     if (!isAuthenticated) { | ||||
|       return NextResponse.json( | ||||
|         { error: 'Unauthorized' }, | ||||
|         { status: 401 } | ||||
|       ); | ||||
|     try { | ||||
|         const body = await request.json(); | ||||
|         // Check for admin auth
 | ||||
|         const { password } = body; | ||||
|         const members = getMemberCredentials(); | ||||
|         const isAuthenticated = members.some(member => comparePassword(password, member.password)); | ||||
|         if (!isAuthenticated) { | ||||
|             return NextResponse.json( | ||||
|                 { error: 'Unauthorized' }, | ||||
|                 { status: 401 } | ||||
|             ); | ||||
|         } | ||||
| 
 | ||||
|         const { id, content } = body; | ||||
| 
 | ||||
|         if (!id || !content) { | ||||
|             return NextResponse.json( | ||||
|                 { error: 'ID and content are required' }, | ||||
|                 { status: 400 } | ||||
|             ); | ||||
|         } | ||||
| 
 | ||||
|         // Get current texts
 | ||||
|         const texts = getEditableTexts(); | ||||
| 
 | ||||
|         // Find and update the text with the given ID
 | ||||
|         const textIndex = texts.findIndex((text: EditableText) => text.id === id); | ||||
| 
 | ||||
|         if (textIndex === -1) { | ||||
|             // Text not found, add new
 | ||||
|             texts.push({ id, content }); | ||||
|         } else { | ||||
|             // Update existing text
 | ||||
|             texts[textIndex].content = content; | ||||
|         } | ||||
| 
 | ||||
|         // Save updated texts
 | ||||
|         saveEditableTexts(texts); | ||||
| 
 | ||||
|         return NextResponse.json({ success: true }); | ||||
|     } catch (error) { | ||||
|         console.error('Error updating editable text:', error); | ||||
|         return NextResponse.json( | ||||
|             { error: 'Failed to update editable text' }, | ||||
|             { status: 500 } | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     const { id, content } = body; | ||||
| 
 | ||||
|     if (!id || !content) { | ||||
|       return NextResponse.json( | ||||
|         { error: 'ID and content are required' }, | ||||
|         { status: 400 } | ||||
|       ); | ||||
|     } | ||||
| 
 | ||||
|     // Get current texts
 | ||||
|     const texts = getEditableTexts(); | ||||
| 
 | ||||
|     // Find and update the text with the given ID
 | ||||
|     const textIndex = texts.findIndex((text: EditableText | ||||
| 
 | ||||
|     ) => text.id === id); | ||||
| 
 | ||||
|     if (textIndex === -1) { | ||||
|       // Text not found, add new
 | ||||
|       texts.push({ id, content }); | ||||
|     } else { | ||||
|       // Update existing text
 | ||||
|       texts[textIndex].content = content; | ||||
|     } | ||||
| 
 | ||||
|     // Save updated texts
 | ||||
|     saveEditableTexts(texts); | ||||
| 
 | ||||
|     return NextResponse.json({ success: true }); | ||||
|   } catch (error) { | ||||
|     console.error('Error updating editable text:', error); | ||||
|     return NextResponse.json( | ||||
|       { error: 'Failed to update editable text' }, | ||||
|       { status: 500 } | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -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'; | ||||
| 
 | ||||
| export async function POST(request: NextRequest) { | ||||
| @ -6,7 +8,8 @@ export async function POST(request: NextRequest) { | ||||
|     const body = await request.json(); | ||||
|     // Check for admin auth
 | ||||
|     const { password } = body; | ||||
|     const isAuthenticated = await checkAdminAuth(password); | ||||
|     const members = getMemberCredentials(); | ||||
|     const isAuthenticated = members.some(member => comparePassword(password, member.password)); | ||||
|     if (!isAuthenticated) { | ||||
|       return NextResponse.json( | ||||
|         { error: 'Unauthorized' }, | ||||
|  | ||||
| @ -1,49 +1,92 @@ | ||||
| import { NextRequest, NextResponse } from 'next/server'; | ||||
| 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) { | ||||
|   try { | ||||
|     // Get the token from the request
 | ||||
|     const { token, vote, comment } = await request.json(); | ||||
|      | ||||
|     // Get the token, vote data, and poll ID from the request
 | ||||
|     const { token, vote, comment, pollId } = await request.json(); | ||||
| 
 | ||||
|     if (!token) { | ||||
|       return NextResponse.json( | ||||
|         { error: 'Token is required' }, | ||||
|         { status: 400 } | ||||
|       ); | ||||
|       return NextResponse.json({ error: 'Token is required' }, { status: 400 }); | ||||
|     } | ||||
|      | ||||
|     // Get available vote options
 | ||||
|     const voteOptions = getVoteOptions(); | ||||
| 
 | ||||
|     // 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( | ||||
|           { error: 'No active polls available' }, | ||||
|           { 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 }); | ||||
|       } | ||||
| 
 | ||||
|       // Check if the poll is active
 | ||||
|       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); | ||||
|      | ||||
| 
 | ||||
|     if (!vote || !validOptionIds.includes(vote)) { | ||||
|       return NextResponse.json( | ||||
|         { error: 'Valid vote option is required' }, | ||||
|         { status: 400 } | ||||
|       ); | ||||
|     } | ||||
|      | ||||
|     // Verify the token
 | ||||
|     const { valid } = await verifyToken(token); | ||||
|      | ||||
| 
 | ||||
|     // Verify the token and check if already voted in this poll
 | ||||
|     const { valid, memberNumber, votedPolls } = await verifyToken(token); | ||||
| 
 | ||||
|     if (!valid) { | ||||
|       return NextResponse.json({ error: 'Invalid token' }, { status: 401 }); | ||||
|     } | ||||
| 
 | ||||
|     if (votedPolls?.includes(targetPollId)) { | ||||
|       return NextResponse.json( | ||||
|         { error: 'Invalid or already used token' }, | ||||
|         { status: 401 } | ||||
|         { error: 'Already voted in this poll' }, | ||||
|         { status: 400 } | ||||
|       ); | ||||
|     } | ||||
|      | ||||
|     // Save the response
 | ||||
|     const response = saveResponse(vote as VoteOption, comment); | ||||
|      | ||||
|     return NextResponse.json({ success: true, response }); | ||||
| 
 | ||||
|     // Save the response with the poll ID
 | ||||
|     const response = saveResponse(targetPollId, vote as VoteOption, comment); | ||||
| 
 | ||||
|     // 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) { | ||||
|     console.error('Error submitting vote:', error); | ||||
|     return NextResponse.json( | ||||
|       { error: 'Failed to submit vote' }, | ||||
|       { status: 500 } | ||||
|     ); | ||||
|     return NextResponse.json({ error: 'Failed to submit vote' }, { status: 500 }); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -4,8 +4,7 @@ import { useState, useEffect, Suspense } from 'react'; | ||||
| import { useSearchParams, useRouter } from 'next/navigation'; | ||||
| import Link from 'next/link'; | ||||
| import { EditableText } from '@/components/EditableText'; | ||||
| import { VoteOption, VoteOptionConfig } from '@/lib/survey'; | ||||
| 
 | ||||
| import { VoteOption, VoteOptionConfig, Poll } from '@/lib/survey'; | ||||
| 
 | ||||
| export default function VotePage() { | ||||
|   return ( | ||||
| @ -23,40 +22,90 @@ function VotePageContent() { | ||||
|   const searchParams = useSearchParams(); | ||||
|   const router = useRouter(); | ||||
|   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 [comment, setComment] = useState(''); | ||||
|   const [isLoading, setIsLoading] = useState(false); | ||||
|   const [error, setError] = useState<string | null>(null); | ||||
|   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(() => { | ||||
|     const fetchVoteOptions = async () => { | ||||
|     const fetchPolls = async () => { | ||||
|       try { | ||||
|         const response = await fetch('/api/editable-text'); | ||||
|         if (response.ok) { | ||||
|           const data = await response.json(); | ||||
|           if (data.texts && Array.isArray(data.texts)) { | ||||
|             const voteOptionsEntry = data.texts.find((text: { id: string }) => text.id === 'vote-options'); | ||||
|             if (voteOptionsEntry && Array.isArray(voteOptionsEntry.content)) { | ||||
|               setVoteOptions(voteOptionsEntry.content); | ||||
|             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 voteQuestionEntry = data.texts.find((text: { id: string }) => text.id === 'vote-question'); | ||||
| 
 | ||||
|               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) { | ||||
|         console.error('Error fetching vote options:', error); | ||||
|         // Use default options if fetch fails
 | ||||
|         setVoteOptions([ | ||||
|           { id: 'yes', label: 'Ja, ich stimme zu' }, | ||||
|           { id: 'no', label: 'Nein, ich stimme nicht zu' }, | ||||
|           { id: 'abstain', label: 'Ich enthalte mich' } | ||||
|         ]); | ||||
|         console.error('Error fetching polls:', error); | ||||
|         // Use default poll if fetch fails
 | ||||
|         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: 'no', label: 'Nein, ich stimme nicht zu' }, | ||||
|             { 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
 | ||||
|   useEffect(() => { | ||||
| @ -65,9 +114,19 @@ function VotePageContent() { | ||||
|     } | ||||
|   }, [token, router]); | ||||
| 
 | ||||
|   const handlePollChange = (pollId: string) => { | ||||
|     setSelectedPollId(pollId); | ||||
|     setSelectedOption(null); | ||||
|   }; | ||||
| 
 | ||||
|   const handleSubmit = async (e: React.FormEvent) => { | ||||
|     e.preventDefault(); | ||||
| 
 | ||||
|     if (!selectedPollId) { | ||||
|       setError('Keine aktive Abstimmung verfügbar'); | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     if (!selectedOption) { | ||||
|       setError('Bitte wähle eine Option'); | ||||
|       return; | ||||
| @ -84,6 +143,7 @@ function VotePageContent() { | ||||
|         }, | ||||
|         body: JSON.stringify({ | ||||
|           token, | ||||
|           pollId: selectedPollId, | ||||
|           vote: selectedOption, | ||||
|           comment: comment.trim() || undefined, | ||||
|         }), | ||||
| @ -95,7 +155,24 @@ function VotePageContent() { | ||||
|         throw new Error(data.error || 'Fehler beim Übermitteln der Stimme'); | ||||
|       } | ||||
| 
 | ||||
|       setIsSubmitted(true); | ||||
|       // 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); | ||||
|       } | ||||
|     } catch (err) { | ||||
|       setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten'); | ||||
|     } finally { | ||||
| @ -103,6 +180,9 @@ function VotePageContent() { | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   // Get the current poll
 | ||||
|   const currentPoll = polls.find(poll => poll.id === selectedPollId); | ||||
| 
 | ||||
|   if (isSubmitted) { | ||||
|     return ( | ||||
|       <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 ( | ||||
|     <div className="container mx-auto px-4 py-8"> | ||||
|       <div className="mb-8"> | ||||
|         <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"> | ||||
|           <form onSubmit={handleSubmit} className="space-y-6"> | ||||
|             <div> | ||||
|               <div className="text-xl font-bold text-[#0057a6] mb-4"> | ||||
|                 <EditableText id="vote-question" /> | ||||
|                 <div dangerouslySetInnerHTML={{ __html: currentPoll.question }} /> | ||||
|               </div> | ||||
| 
 | ||||
|               <div className="space-y-3"> | ||||
|                 {voteOptions.map((option) => ( | ||||
|                 {currentPoll.options.map((option) => ( | ||||
|                   <label | ||||
|                     key={option.id} | ||||
|                     className="flex items-center p-3 border border-gray-200 cursor-pointer hover:bg-[#e6f0fa] transition-colors" | ||||
|  | ||||
| @ -1,7 +1,7 @@ | ||||
| 'use client'; | ||||
| 
 | ||||
| import { useState } from 'react'; | ||||
| import { VoteOption } from '@/lib/survey'; | ||||
| import { VoteOption, Poll } from '@/lib/survey'; | ||||
| 
 | ||||
| // Define types based on the data structure
 | ||||
| interface Stats { | ||||
| @ -9,14 +9,20 @@ interface Stats { | ||||
|     [key: string]: number; // Allow dynamic keys for vote options
 | ||||
| } | ||||
| 
 | ||||
| interface PollStats { | ||||
|     all: Stats; | ||||
|     byPoll: Record<string, Stats>; | ||||
| } | ||||
| 
 | ||||
| interface Comment { | ||||
|     vote: VoteOption; | ||||
|     pollId?: string; | ||||
|     comment: string; | ||||
|     timestamp: string; | ||||
| } | ||||
| 
 | ||||
| interface StatsButtonProps { | ||||
|     onStatsLoaded: (stats: Stats, comments: Comment[]) => void; | ||||
|     onStatsLoaded: (stats: Stats | PollStats, comments: Comment[], polls?: Poll[]) => void; | ||||
| } | ||||
| 
 | ||||
| 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'); | ||||
|             } | ||||
| 
 | ||||
|             onStatsLoaded(data.stats, data.comments || []); | ||||
|             // 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 || []); | ||||
|             } | ||||
|         } catch (err) { | ||||
|             setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten'); | ||||
|         } finally { | ||||
|  | ||||
| @ -1,53 +1,134 @@ | ||||
| 'use client'; | ||||
| 
 | ||||
| import { VoteOption } from '@/lib/survey'; | ||||
| import { useState } from 'react'; | ||||
| import { VoteOption, Poll } from '@/lib/survey'; | ||||
| 
 | ||||
| interface Stats { | ||||
|     total: number; | ||||
|     [key: string]: number; // Allow dynamic keys for vote options
 | ||||
| } | ||||
| 
 | ||||
| interface PollStats { | ||||
|     all: Stats; | ||||
|     byPoll: Record<string, Stats>; | ||||
| } | ||||
| 
 | ||||
| interface Comment { | ||||
|     vote: VoteOption; | ||||
|     pollId?: string; | ||||
|     comment: string; | ||||
|     timestamp: string; | ||||
| } | ||||
| 
 | ||||
| interface StatsDisplayProps { | ||||
|     stats: Stats; | ||||
|     stats: Stats | PollStats; | ||||
|     comments: Comment[]; | ||||
|     voteOptions: { id: string; label: string }[]; | ||||
|     polls?: Poll[]; | ||||
|     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 ( | ||||
|         <div className="ssvc-main-content"> | ||||
|             <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={`grid ${voteOptions.length === 1 ? 'grid-cols-1' : voteOptions.length === 2 ? 'grid-cols-2' : 'grid-cols-3'} gap-4`}> | ||||
|                         {voteOptions.map((option) => ( | ||||
|                     <div className={`grid ${displayOptions.length === 1 ? 'grid-cols-1' : displayOptions.length === 2 ? 'grid-cols-2' : 'grid-cols-3'} gap-4`}> | ||||
|                         {displayOptions.map((option) => ( | ||||
|                             <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> | ||||
|                         ))} | ||||
|                     </div> | ||||
| 
 | ||||
|                     <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> | ||||
| 
 | ||||
|                     {stats.total > 0 && ( | ||||
|                     {displayStats.total > 0 && ( | ||||
|                         <div className="mt-4"> | ||||
|                             <h3 className="text-lg font-medium text-[#0057a6] mb-2">Ergebnisübersicht</h3> | ||||
|                             <div className="h-6 bg-gray-200 overflow-hidden"> | ||||
|                                 <div className="flex h-full"> | ||||
|                                     {voteOptions.map((option, index) => { | ||||
|                                         const percentage = ((stats[option.id] || 0) / stats.total) * 100; | ||||
|                                     {displayOptions.map((option, index) => { | ||||
|                                         const percentage = ((displayStats[option.id] || 0) / displayStats.total) * 100; | ||||
|                                         // Define colors for different options
 | ||||
|                                         const colors = [ | ||||
|                                             'bg-[#0057a6]', // Blue for first option (usually yes)
 | ||||
| @ -68,8 +149,8 @@ export default function StatsDisplay({ stats, comments, voteOptions, onBack }: S | ||||
|                                 </div> | ||||
|                             </div> | ||||
|                             <div className="flex justify-between text-xs mt-1"> | ||||
|                                 {voteOptions.map((option, index) => { | ||||
|                                     const percentage = Math.round(((stats[option.id] || 0) / stats.total) * 100); | ||||
|                                 {displayOptions.map((option, index) => { | ||||
|                                     const percentage = Math.round(((displayStats[option.id] || 0) / displayStats.total) * 100); | ||||
|                                     // Define text colors for different options
 | ||||
|                                     const textColors = [ | ||||
|                                         'text-[#0057a6]', // Blue for first option (usually yes)
 | ||||
| @ -91,15 +172,15 @@ export default function StatsDisplay({ stats, comments, voteOptions, onBack }: S | ||||
|                 </div> | ||||
|             )} | ||||
| 
 | ||||
|             {comments.length > 0 && ( | ||||
|             {displayComments.length > 0 && ( | ||||
|                 <div className="mt-8"> | ||||
|                     <h3 className="text-lg font-medium text-[#0057a6] mb-4">Kommentare der Teilnehmer</h3> | ||||
|                     <div className="space-y-4"> | ||||
|                         {comments.map((comment, index) => ( | ||||
|                         {displayComments.map((comment, index) => ( | ||||
|                             <div key={index} className="p-4 bg-[#e6f0fa] rounded"> | ||||
|                                 <div className="flex justify-between items-center mb-2"> | ||||
|                                     <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 className="text-xs text-gray-500"> | ||||
|                                         {new Date(comment.timestamp).toLocaleDateString('de-DE')} | ||||
|  | ||||
| @ -1,6 +1,7 @@ | ||||
| 'use client'; | ||||
| 
 | ||||
| import { useState, useRef, useEffect } from 'react'; | ||||
| import { Poll } from '@/lib/survey'; | ||||
| 
 | ||||
| // Function to generate a random ID
 | ||||
| function generateRandomId(existingIds: string[]): string { | ||||
| @ -19,39 +20,52 @@ function generateRandomId(existingIds: string[]): string { | ||||
| } | ||||
| 
 | ||||
| interface VoteOptionsManagerProps { | ||||
|     voteOptions: { id: string; label: string }[]; | ||||
|     onVoteOptionsChange: (newOptions: { id: string; label: string }[]) => void; | ||||
|     polls: Poll[]; | ||||
|     onPollsChange: (newPolls: Poll[]) => void; | ||||
| } | ||||
| 
 | ||||
| export default function VoteOptionsManager({ voteOptions, onVoteOptionsChange }: VoteOptionsManagerProps) { | ||||
| export default function VoteOptionsManager({ polls, onPollsChange }: VoteOptionsManagerProps) { | ||||
|     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 [newOptionLabel, setNewOptionLabel] = useState(''); | ||||
|     const [isLoading, setIsLoading] = useState(false); | ||||
|     const [error, setError] = useState<string | null>(null); | ||||
|     const [editingOptionId, setEditingOptionId] = useState<string | null>(null); | ||||
|     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 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
 | ||||
|     useEffect(() => { | ||||
|         if (!newOptionId && voteOptions) { | ||||
|             const existingIds = voteOptions.map(option => option.id); | ||||
|             setNewOptionId(generateRandomId(existingIds)); | ||||
|         if (!newOptionId && selectedPollId) { | ||||
|             const currentPoll = polls.find(poll => poll.id === selectedPollId); | ||||
|             if (currentPoll) { | ||||
|                 const existingIds = currentPoll.options.map(option => option.id); | ||||
|                 setNewOptionId(generateRandomId(existingIds)); | ||||
|             } | ||||
|         } | ||||
|     }, [newOptionId, voteOptions]); | ||||
|     }, [newOptionId, selectedPollId, polls]); | ||||
| 
 | ||||
|     // 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; | ||||
|         } | ||||
|     // Get the current poll
 | ||||
|     const currentPoll = polls.find(poll => poll.id === selectedPollId); | ||||
|     const voteOptions = currentPoll?.options || []; | ||||
| 
 | ||||
|         // Check if ID already exists
 | ||||
|         if (voteOptions.some(option => option.id === newOptionId)) { | ||||
|             setError('Eine Option mit dieser ID existiert bereits'); | ||||
|     // Create a new poll
 | ||||
|     const handleCreatePoll = async () => { | ||||
|         if (!newPollTitle.trim()) { | ||||
|             setError('Bitte geben Sie einen Titel für die neue Abstimmung ein'); | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
| @ -59,8 +73,26 @@ export default function VoteOptionsManager({ voteOptions, onVoteOptionsChange }: | ||||
|         setError(null); | ||||
| 
 | ||||
|         try { | ||||
|             // Add to local state
 | ||||
|             const updatedOptions = [...voteOptions, { id: newOptionId, label: newOptionLabel }]; | ||||
|             // Generate a unique ID for the new poll
 | ||||
|             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
 | ||||
|             const response = await fetch('/api/editable-text', { | ||||
| @ -69,22 +101,24 @@ export default function VoteOptionsManager({ voteOptions, onVoteOptionsChange }: | ||||
|                     'Content-Type': 'application/json', | ||||
|                 }, | ||||
|                 body: JSON.stringify({ | ||||
|                     id: 'vote-options', | ||||
|                     content: updatedOptions | ||||
|                     id: 'polls', | ||||
|                     content: updatedPolls | ||||
|                 }), | ||||
|             }); | ||||
| 
 | ||||
|             if (!response.ok) { | ||||
|                 throw new Error('Fehler beim Speichern der Abstimmungsoptionen'); | ||||
|                 throw new Error('Fehler beim Speichern der neuen Abstimmung'); | ||||
|             } | ||||
| 
 | ||||
|             // Update parent component state
 | ||||
|             onVoteOptionsChange(updatedOptions); | ||||
|             onPollsChange(updatedPolls); | ||||
| 
 | ||||
|             // Reset form and generate a new random ID
 | ||||
|             setNewOptionLabel(''); | ||||
|             const existingIds = updatedOptions.map(option => option.id); | ||||
|             setNewOptionId(generateRandomId(existingIds)); | ||||
|             // Select the new poll
 | ||||
|             setSelectedPollId(newPollId); | ||||
| 
 | ||||
|             // Reset form
 | ||||
|             setNewPollTitle(''); | ||||
|             setNewPollQuestion('<p>Stimmen Sie dem Vorschlag zu?</p>'); | ||||
|         } catch (err) { | ||||
|             setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten'); | ||||
|         } finally { | ||||
| @ -92,34 +126,17 @@ export default function VoteOptionsManager({ voteOptions, onVoteOptionsChange }: | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
|     // Start editing a vote option
 | ||||
|     const handleEditVoteOption = (option: { id: string; label: 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; | ||||
|         } | ||||
| 
 | ||||
|     // Toggle poll active status
 | ||||
|     const handleTogglePollActive = async (pollId: string) => { | ||||
|         setIsLoading(true); | ||||
|         setError(null); | ||||
| 
 | ||||
|         try { | ||||
|             // Update in local state
 | ||||
|             const updatedOptions = voteOptions.map(option => | ||||
|                 option.id === editingOptionId | ||||
|                     ? { ...option, label: editedLabel.trim() } | ||||
|                     : option | ||||
|             const updatedPolls = polls.map(poll => | ||||
|                 poll.id === pollId | ||||
|                     ? { ...poll, active: !poll.active } | ||||
|                     : poll | ||||
|             ); | ||||
| 
 | ||||
|             // Save to server
 | ||||
| @ -129,20 +146,17 @@ export default function VoteOptionsManager({ voteOptions, onVoteOptionsChange }: | ||||
|                     'Content-Type': 'application/json', | ||||
|                 }, | ||||
|                 body: JSON.stringify({ | ||||
|                     id: 'vote-options', | ||||
|                     content: updatedOptions | ||||
|                     id: 'polls', | ||||
|                     content: updatedPolls | ||||
|                 }), | ||||
|             }); | ||||
| 
 | ||||
|             if (!response.ok) { | ||||
|                 throw new Error('Fehler beim Speichern der Abstimmungsoptionen'); | ||||
|                 throw new Error('Fehler beim Aktualisieren des Abstimmungsstatus'); | ||||
|             } | ||||
| 
 | ||||
|             // Update parent component state
 | ||||
|             onVoteOptionsChange(updatedOptions); | ||||
| 
 | ||||
|             // Exit edit mode
 | ||||
|             setEditingOptionId(null); | ||||
|             onPollsChange(updatedPolls); | ||||
|         } catch (err) { | ||||
|             setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten'); | ||||
|         } finally { | ||||
| @ -150,22 +164,20 @@ export default function VoteOptionsManager({ voteOptions, onVoteOptionsChange }: | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
|     // Cancel editing
 | ||||
|     const handleCancelEdit = () => { | ||||
|         setEditingOptionId(null); | ||||
|     }; | ||||
| 
 | ||||
|     // Move option up in the list
 | ||||
|     const handleMoveOptionUp = async (index: number) => { | ||||
|         if (index <= 0) return; // Already at the top
 | ||||
|     // Delete a poll
 | ||||
|     const handleDeletePoll = async (pollId: string) => { | ||||
|         // 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.`)) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         setIsLoading(true); | ||||
|         setError(null); | ||||
| 
 | ||||
|         try { | ||||
|             // Create a new array with the option moved up
 | ||||
|             const updatedOptions = [...voteOptions]; | ||||
|             [updatedOptions[index - 1], updatedOptions[index]] = [updatedOptions[index], updatedOptions[index - 1]]; | ||||
|             // Remove from local state
 | ||||
|             const updatedPolls = polls.filter(poll => poll.id !== pollId); | ||||
| 
 | ||||
|             // Save to server
 | ||||
|             const response = await fetch('/api/editable-text', { | ||||
| @ -174,17 +186,22 @@ export default function VoteOptionsManager({ voteOptions, onVoteOptionsChange }: | ||||
|                     'Content-Type': 'application/json', | ||||
|                 }, | ||||
|                 body: JSON.stringify({ | ||||
|                     id: 'vote-options', | ||||
|                     content: updatedOptions | ||||
|                     id: 'polls', | ||||
|                     content: updatedPolls | ||||
|                 }), | ||||
|             }); | ||||
| 
 | ||||
|             if (!response.ok) { | ||||
|                 throw new Error('Fehler beim Speichern der Abstimmungsoptionen'); | ||||
|                 throw new Error('Fehler beim Löschen der Abstimmung'); | ||||
|             } | ||||
| 
 | ||||
|             // 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) { | ||||
|             setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten'); | ||||
|         } finally { | ||||
| @ -192,17 +209,38 @@ export default function VoteOptionsManager({ voteOptions, onVoteOptionsChange }: | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
|     // Move option down in the list
 | ||||
|     const handleMoveOptionDown = async (index: number) => { | ||||
|         if (index >= voteOptions.length - 1) return; // Already at the bottom
 | ||||
|     // Add a new vote option to the current poll
 | ||||
|     const handleAddVoteOption = async () => { | ||||
|         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); | ||||
|         setError(null); | ||||
| 
 | ||||
|         try { | ||||
|             // Create a new array with the option moved down
 | ||||
|             const updatedOptions = [...voteOptions]; | ||||
|             [updatedOptions[index], updatedOptions[index + 1]] = [updatedOptions[index + 1], updatedOptions[index]]; | ||||
|             // Update the current poll with the new option
 | ||||
|             const updatedPolls = polls.map(poll => { | ||||
|                 if (poll.id === selectedPollId) { | ||||
|                     return { | ||||
|                         ...poll, | ||||
|                         options: [...poll.options, { id: newOptionId, label: newOptionLabel }] | ||||
|                     }; | ||||
|                 } | ||||
|                 return poll; | ||||
|             }); | ||||
| 
 | ||||
|             // Save to server
 | ||||
|             const response = await fetch('/api/editable-text', { | ||||
| @ -211,8 +249,8 @@ export default function VoteOptionsManager({ voteOptions, onVoteOptionsChange }: | ||||
|                     'Content-Type': 'application/json', | ||||
|                 }, | ||||
|                 body: JSON.stringify({ | ||||
|                     id: 'vote-options', | ||||
|                     content: updatedOptions | ||||
|                     id: 'polls', | ||||
|                     content: updatedPolls | ||||
|                 }), | ||||
|             }); | ||||
| 
 | ||||
| @ -221,7 +259,14 @@ export default function VoteOptionsManager({ voteOptions, onVoteOptionsChange }: | ||||
|             } | ||||
| 
 | ||||
|             // 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) { | ||||
|             setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten'); | ||||
|         } finally { | ||||
| @ -231,6 +276,8 @@ export default function VoteOptionsManager({ voteOptions, onVoteOptionsChange }: | ||||
| 
 | ||||
|     // Remove a vote option
 | ||||
|     const handleRemoveVoteOption = async (optionId: string) => { | ||||
|         if (!selectedPollId) return; | ||||
| 
 | ||||
|         // Don't allow removing if there are less than 2 options
 | ||||
|         if (voteOptions.length <= 1) { | ||||
|             setError('Es muss mindestens eine Abstimmungsoption vorhanden sein'); | ||||
| @ -247,8 +294,16 @@ export default function VoteOptionsManager({ voteOptions, onVoteOptionsChange }: | ||||
|         setError(null); | ||||
| 
 | ||||
|         try { | ||||
|             // Remove from local state
 | ||||
|             const updatedOptions = voteOptions.filter(option => option.id !== optionId); | ||||
|             // Update the current poll with the option removed
 | ||||
|             const updatedPolls = polls.map(poll => { | ||||
|                 if (poll.id === selectedPollId) { | ||||
|                     return { | ||||
|                         ...poll, | ||||
|                         options: poll.options.filter(option => option.id !== optionId) | ||||
|                     }; | ||||
|                 } | ||||
|                 return poll; | ||||
|             }); | ||||
| 
 | ||||
|             // Save to server
 | ||||
|             const response = await fetch('/api/editable-text', { | ||||
| @ -257,8 +312,8 @@ export default function VoteOptionsManager({ voteOptions, onVoteOptionsChange }: | ||||
|                     'Content-Type': 'application/json', | ||||
|                 }, | ||||
|                 body: JSON.stringify({ | ||||
|                     id: 'vote-options', | ||||
|                     content: updatedOptions | ||||
|                     id: 'polls', | ||||
|                     content: updatedPolls | ||||
|                 }), | ||||
|             }); | ||||
| 
 | ||||
| @ -267,7 +322,7 @@ export default function VoteOptionsManager({ voteOptions, onVoteOptionsChange }: | ||||
|             } | ||||
| 
 | ||||
|             // Update parent component state
 | ||||
|             onVoteOptionsChange(updatedOptions); | ||||
|             onPollsChange(updatedPolls); | ||||
|         } catch (err) { | ||||
|             setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten'); | ||||
|         } finally { | ||||
| @ -278,7 +333,7 @@ export default function VoteOptionsManager({ voteOptions, onVoteOptionsChange }: | ||||
|     return ( | ||||
|         <div className="mb-6"> | ||||
|             <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 | ||||
|                     onClick={() => setShowVoteOptions(!showVoteOptions)} | ||||
|                     className="text-sm text-[#0057a6] hover:underline" | ||||
| @ -289,150 +344,164 @@ export default function VoteOptionsManager({ voteOptions, onVoteOptionsChange }: | ||||
| 
 | ||||
|             {showVoteOptions && ( | ||||
|                 <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"> | ||||
|                         {voteOptions.map((option, index) => ( | ||||
|                             <div key={option.id} className="flex items-center justify-between p-2 bg-white"> | ||||
|                                 <div className="flex items-center space-x-2"> | ||||
|                                     <div className="flex flex-col"> | ||||
|                                         <button | ||||
|                                             onClick={() => handleMoveOptionUp(index)} | ||||
|                                             className="text-gray-600 hover:text-gray-800 disabled:opacity-30" | ||||
|                                             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 className="space-y-2 mb-4"> | ||||
|                             {polls.map(poll => ( | ||||
|                                 <div key={poll.id} className="flex items-center justify-between p-3 bg-white"> | ||||
|                                     <div className="flex-1"> | ||||
|                                         <div className="font-medium">{poll.title}</div> | ||||
|                                         <div className="text-sm text-gray-500"> | ||||
|                                             ID: {poll.id} | Status: {poll.active ? 'Aktiv' : 'Inaktiv'} | | ||||
|                                             Erstellt: {new Date(poll.createdAt).toLocaleDateString()} | ||||
|                                         </div> | ||||
|                                     </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 className="flex space-x-2"> | ||||
|                                         <button | ||||
|                                             onClick={() => setSelectedPollId(poll.id)} | ||||
|                                             className={`px-2 py-1 text-sm rounded ${selectedPollId === poll.id | ||||
|                                                 ? 'bg-[#0057a6] text-white' | ||||
|                                                 : 'bg-gray-100 text-gray-800 hover:bg-gray-200' | ||||
|                                                 }`}
 | ||||
|                                             title="Diese Abstimmung bearbeiten" | ||||
|                                         > | ||||
|                                             Bearbeiten | ||||
|                                         </button> | ||||
|                                         <button | ||||
|                                             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" | ||||
|                                             title="Abstimmung löschen" | ||||
|                                         > | ||||
|                                             ✕ | ||||
|                                         </button> | ||||
|                                     </div> | ||||
|                                 </div> | ||||
|                                 <div className="flex space-x-2"> | ||||
|                                     {editingOptionId !== option.id && ( | ||||
|                             ))} | ||||
|                         </div> | ||||
| 
 | ||||
|                         {/* 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={() => handleEditVoteOption(option)} | ||||
|                                             className="text-blue-600 hover:text-blue-800" | ||||
|                                             title="Option bearbeiten" | ||||
|                                             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> | ||||
|                                         <label htmlFor="newOptionId" className="block text-sm font-medium text-gray-700 mb-1"> | ||||
|                                             Option ID | ||||
|                                         </label> | ||||
|                                         <input | ||||
|                                             type="text" | ||||
|                                             id="newOptionId" | ||||
|                                             value={newOptionId} | ||||
|                                             onChange={(e) => setNewOptionId(e.target.value)} | ||||
|                                             className="w-full px-3 py-2 border border-gray-300 focus:outline-none focus:border-[#0057a6]" | ||||
|                                             placeholder="option-id" | ||||
|                                         /> | ||||
|                                     </div> | ||||
|                                     <div> | ||||
|                                         <label htmlFor="newOptionLabel" className="block text-sm font-medium text-gray-700 mb-1"> | ||||
|                                             Anzeigename | ||||
|                                         </label> | ||||
|                                         <input | ||||
|                                             type="text" | ||||
|                                             id="newOptionLabel" | ||||
|                                             value={newOptionLabel} | ||||
|                                             onChange={(e) => setNewOptionLabel(e.target.value)} | ||||
|                                             className="w-full px-3 py-2 border border-gray-300 focus:outline-none focus:border-[#0057a6]" | ||||
|                                             placeholder="Anzeigename der Option" | ||||
|                                         /> | ||||
|                                     </div> | ||||
|                                     {error && ( | ||||
|                                         <div className="text-red-500 text-sm">{error}</div> | ||||
|                                     )} | ||||
|                                     <button | ||||
|                                         onClick={() => handleRemoveVoteOption(option.id)} | ||||
|                                         className="text-red-600 hover:text-red-800" | ||||
|                                         title="Option entfernen" | ||||
|                                         onClick={handleAddVoteOption} | ||||
|                                         disabled={isLoading} | ||||
|                                         className="ssvc-button w-full disabled:opacity-50" | ||||
|                                     > | ||||
|                                         ✕ | ||||
|                                         {isLoading ? 'Wird hinzugefügt...' : 'Option hinzufügen'} | ||||
|                                     </button> | ||||
|                                 </div> | ||||
|                             </div> | ||||
|                         ))} | ||||
|                     </div> | ||||
| 
 | ||||
|                     <h3 className="font-medium text-[#0057a6] mb-2">Neue Option hinzufügen</h3> | ||||
|                     <div className="space-y-3"> | ||||
|                         <div> | ||||
|                             <label htmlFor="newOptionId" className="block text-sm font-medium text-gray-700 mb-1"> | ||||
|                                 Option ID (z.B. "yes", "no") | ||||
|                             </label> | ||||
|                             <input | ||||
|                                 type="text" | ||||
|                                 id="newOptionId" | ||||
|                                 value={newOptionId} | ||||
|                                 onChange={(e) => setNewOptionId(e.target.value)} | ||||
|                                 className="w-full px-3 py-2 border border-gray-300 focus:outline-none focus:border-[#0057a6]" | ||||
|                                 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> | ||||
|                             <label htmlFor="newOptionLabel" className="block text-sm font-medium text-gray-700 mb-1"> | ||||
|                                 Anzeigename | ||||
|                             </label> | ||||
|                             <input | ||||
|                                 type="text" | ||||
|                                 id="newOptionLabel" | ||||
|                                 ref={labelInputRef} | ||||
|                                 value={newOptionLabel} | ||||
|                                 onChange={(e) => setNewOptionLabel(e.target.value)} | ||||
|                                 className="w-full px-3 py-2 border border-gray-300 focus:outline-none focus:border-[#0057a6]" | ||||
|                                 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> | ||||
| 
 | ||||
|                         {error && ( | ||||
|                             <div className="text-red-500 text-sm">{error}</div> | ||||
|                         )} | ||||
| 
 | ||||
|                         <button | ||||
|                             onClick={handleAddVoteOption} | ||||
|                             disabled={isLoading} | ||||
|                             className="ssvc-button w-full disabled:opacity-50" | ||||
|                         > | ||||
|                             {isLoading ? 'Wird hinzugefügt...' : 'Option hinzufügen'} | ||||
|                         </button> | ||||
|                     </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> | ||||
|  | ||||
| @ -1,33 +1,48 @@ | ||||
| import { SignJWT, jwtVerify } from 'jose'; | ||||
| import { SignJWT } from 'jose'; | ||||
| import { v4 as uuidv4 } from 'uuid'; | ||||
| import bcrypt from 'bcryptjs'; | ||||
| import { cookies } from 'next/headers'; | ||||
| import { getMemberCredentials } from './server-auth'; | ||||
| 
 | ||||
| // Interface for member credentials
 | ||||
| export interface MemberCredential { | ||||
|   memberNumber: string; | ||||
|   password: string; // Hashed password
 | ||||
|   hasVoted: boolean; | ||||
|   lastLogin?: string; // ISO date string
 | ||||
|   password: string; | ||||
|   votedPolls: string[]; // Track polls the member has voted in
 | ||||
|   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
 | ||||
| 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
 | ||||
|   const tokenId = uuidv4(); | ||||
|   type Payload = { tokenId: string; memberNumber?: string }; | ||||
|   type Payload = { tokenId: string; memberNumber?: string; pollId?: string; votedPolls?: string[] }; | ||||
|   const payload: Payload = { tokenId }; | ||||
| 
 | ||||
|   // If memberNumber is provided, include it in the token
 | ||||
|   if (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
 | ||||
|   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 key = encoder.encode(secretKey); | ||||
| 
 | ||||
| @ -40,58 +55,6 @@ export async function generateRandomToken(memberNumber?: string): Promise<string | ||||
|   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
 | ||||
| export function hashPassword(password: string): string { | ||||
|   return bcrypt.hashSync(password, 10); | ||||
|  | ||||
| @ -107,34 +107,13 @@ export function addMember(memberNumber: string, password: string): boolean { | ||||
|   members.push({ | ||||
|     memberNumber, | ||||
|     password: hashedPassword, | ||||
|     hasVoted: false | ||||
|     votedPolls: [], | ||||
|   }); | ||||
| 
 | ||||
|   saveMemberCredentials(members); | ||||
|   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
 | ||||
| export function deleteMember(memberNumber: string): boolean { | ||||
|   const members = getMemberCredentials(); | ||||
| @ -151,7 +130,7 @@ export function deleteMember(memberNumber: string): boolean { | ||||
| } | ||||
| 
 | ||||
| // 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 existingMemberNumbers = new Set(members.map(m => m.memberNumber)); | ||||
| 
 | ||||
| @ -190,7 +169,7 @@ export function importMembersFromCSV(csvContent: string): { added: number, skipp | ||||
|       members.push({ | ||||
|         memberNumber, | ||||
|         password: hashedPassword, | ||||
|         hasVoted: false | ||||
|         votedPolls: [], | ||||
|       }); | ||||
| 
 | ||||
|       existingMemberNumbers.add(memberNumber); | ||||
| @ -202,29 +181,17 @@ export function importMembersFromCSV(csvContent: string): { added: number, skipp | ||||
|   return { added, skipped }; | ||||
| } | ||||
| 
 | ||||
| // Verify member credentials
 | ||||
| export function verifyMemberCredentials(memberNumber: string, password: string): { valid: boolean, hasVoted: boolean } { | ||||
| // Update member's votedPolls
 | ||||
| export function markMemberAsVoted(memberNumber: string, pollId: string): void { | ||||
|   const members = getMemberCredentials(); | ||||
|   const member = members.find(m => m.memberNumber === memberNumber); | ||||
|   const memberIndex = members.findIndex(m => m.memberNumber === memberNumber); | ||||
| 
 | ||||
|   if (!member) { | ||||
|     return { valid: false, hasVoted: false }; | ||||
|   if (memberIndex !== -1) { | ||||
|     if (!members[memberIndex].votedPolls.includes(pollId)) { | ||||
|       members[memberIndex].votedPolls.push(pollId); | ||||
|       saveMemberCredentials(members); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   const passwordValid = comparePassword(password, member.password); | ||||
| 
 | ||||
|   if (passwordValid) { | ||||
|     // Update last login time
 | ||||
|     member.lastLogin = new Date().toISOString(); | ||||
|     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
 | ||||
| @ -232,7 +199,7 @@ export function resetMemberVotingStatus(): void { | ||||
|   const members = getMemberCredentials(); | ||||
| 
 | ||||
|   for (const member of members) { | ||||
|     member.hasVoted = false; | ||||
|     member.votedPolls = []; | ||||
|   } | ||||
| 
 | ||||
|   saveMemberCredentials(members); | ||||
| @ -284,7 +251,7 @@ export async function ensureKeys() { | ||||
| } | ||||
| 
 | ||||
| // 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 { | ||||
|     // 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'; | ||||
| @ -294,6 +261,8 @@ export async function verifyToken(token: string): Promise<{ valid: boolean, memb | ||||
|     const { payload } = await jwtVerify(token, key); | ||||
|     const tokenId = payload.tokenId as string; | ||||
|     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
 | ||||
|     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
 | ||||
|     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) { | ||||
|       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) { | ||||
|     console.error('Token verification failed:', error); | ||||
|     return { valid: false }; | ||||
|  | ||||
| @ -8,11 +8,22 @@ export interface VoteOptionConfig { | ||||
|   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
 | ||||
| export type VoteOption = string; // Now a string that matches the option id
 | ||||
| 
 | ||||
| export interface SurveyResponse { | ||||
|   id: string; // Used only for internal tracking, not associated with the voter
 | ||||
|   pollId: string; // ID of the poll this response is for
 | ||||
|   vote: VoteOption; | ||||
|   comment?: string; | ||||
|   timestamp: string; | ||||
| @ -30,8 +41,118 @@ function ensureDataDirectory() { | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| // Get vote options from editable_text.json
 | ||||
| export function getVoteOptions(): VoteOptionConfig[] { | ||||
| // Get all polls from editable_text.json
 | ||||
| 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(); | ||||
| 
 | ||||
|   if (!fs.existsSync(TEXT_FILE)) { | ||||
| @ -82,13 +203,20 @@ export function getAllResponses(): SurveyResponse[] { | ||||
|   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
 | ||||
| export function saveResponse(vote: VoteOption, comment?: string): SurveyResponse { | ||||
| export function saveResponse(pollId: string, vote: VoteOption, comment?: string): SurveyResponse { | ||||
|   const responses = getAllResponses(); | ||||
| 
 | ||||
|   // Create a new response with a random ID (not associated with the voter)
 | ||||
|   const newResponse: SurveyResponse = { | ||||
|     id: uuidv4(), // Random ID only for internal tracking
 | ||||
|     pollId, | ||||
|     vote, | ||||
|     comment, | ||||
|     timestamp: new Date().toISOString(), | ||||
| @ -102,10 +230,77 @@ export function saveResponse(vote: VoteOption, comment?: string): SurveyResponse | ||||
|   return newResponse; | ||||
| } | ||||
| 
 | ||||
| // Get survey statistics
 | ||||
| // Get survey statistics for all polls
 | ||||
| export function getSurveyStats() { | ||||
|   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
 | ||||
|   const stats: Record<string, number> = { | ||||
| @ -113,7 +308,7 @@ export function getSurveyStats() { | ||||
|   }; | ||||
| 
 | ||||
|   // Initialize count for each vote option
 | ||||
|   voteOptions.forEach(option => { | ||||
|   poll.options.forEach(option => { | ||||
|     stats[option.id] = 0; | ||||
|   }); | ||||
| 
 | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user