Compare commits

...

5 Commits

Author SHA1 Message Date
jean 33a1745284 might be trash, cause llm messed up the files 2025-03-09 07:26:16 +00:00
jean f079de53e6 allow multiple votes at once (WIP/need to be fixed) 2025-03-09 07:25:03 +00:00
jean 17c099fc3d fix 2025-03-02 21:49:10 +00:00
jean 2561446907 optimized iframe view 2025-03-02 21:43:19 +00:00
jean db195a4ae2 improvements 2025-03-02 21:09:39 +00:00
36 changed files with 3354 additions and 634 deletions
+98 -6
View File
@@ -5,15 +5,49 @@
},
{
"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": "vote-question",
"content": "<p>Stimmen Sie der vorgeschlagenen Änderung der Vereinssatzung zu?</p>"
},
{
"id": "vote-options",
"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"
@@ -26,6 +60,64 @@
"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",
"content": "<p>Stimmen Sie der vorgeschlagenen Änderung der Vereinssatzung zu?</p>"
},
{
"id": "vote-options",
"content": [
{
"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"
}
]
}
]
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -1 +1,9 @@
[]
[
{
"id": "0dfdb724-b6f2-47db-917b-0d233bef5e93",
"pollId": "satzung-2025",
"vote": "abstain",
"comment": "ich was.",
"timestamp": "2025-03-02T19:12:44.209Z"
}
]
+3 -1
View File
@@ -1 +1,3 @@
[]
[
"f149de36-b116-46dc-9fbb-a6e216d1fec1"
]
+97
View File
@@ -0,0 +1,97 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SSVC Rimsting Abstimmung - Iframe Beispiel</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 20px;
background-color: #f5f5f5;
}
.container {
max-width: 800px;
margin: 0 auto;
background-color: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
h1 {
color: #0057a6;
margin-top: 0;
}
.iframe-container {
width: 100%;
margin-top: 20px;
border: 1px solid #ddd;
border-radius: 4px;
overflow: hidden;
}
iframe {
width: 100%;
height: 600px;
border: none;
}
.code-example {
background-color: #f0f7ff;
padding: 15px;
border-radius: 4px;
font-family: monospace;
margin-top: 20px;
overflow-x: auto;
}
.note {
margin-top: 20px;
padding: 10px;
background-color: #fffde7;
border-left: 4px solid #ffd600;
border-radius: 4px;
}
</style>
</head>
<body>
<div class="container">
<h1>SSVC Rimsting Abstimmung - Iframe Beispiel</h1>
<p>Diese Seite zeigt, wie die Abstimmungskomponente als Iframe in eine externe Webseite eingebunden werden kann.
</p>
<h2>Live Beispiel</h2>
<div class="iframe-container">
<iframe src="/iframe" title="SSVC Rimsting Abstimmung" frameborder="0"></iframe>
</div>
<h2>HTML Code zum Einbinden</h2>
<div class="code-example">
&lt;iframe
src="https://ihre-domain.de/iframe"
width="100%"
height="600"
frameborder="0"
style="border:none;"
title="SSVC Rimsting Abstimmung"&gt;
&lt;/iframe&gt;
</div>
<div class="note">
<strong>Hinweis:</strong> Ersetzen Sie "https://ihre-domain.de/iframe" mit der tatsächlichen URL Ihrer
Abstimmungsseite.
<br><br>
Sie können auch einen Token direkt in der URL übergeben, um den Login-Schritt zu überspringen:
<br>
<code>https://ihre-domain.de/iframe?token=IHR_TOKEN</code>
</div>
</div>
</body>
</html>
@@ -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)}
/>
)}
+133
View File
@@ -0,0 +1,133 @@
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';
// Path to the editable text file
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 });
}
}
// Get all editable texts
function getEditableTexts() {
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'
}
]
}
];
fs.writeFileSync(TEXT_FILE, JSON.stringify(defaultTexts, null, 2));
return defaultTexts;
}
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));
}
// 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 }
);
}
}
// 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 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 }
);
}
}
@@ -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' },
+92
View File
@@ -0,0 +1,92 @@
import { NextRequest, NextResponse } from 'next/server';
import { verifyToken } from '@/lib/server-auth';
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, 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 });
}
// 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 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: 'Already voted in this poll' },
{ status: 400 }
);
}
// 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 });
}
}
@@ -1,7 +1,7 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import Link from "next/link";
import "./globals.css";
import "../globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -55,8 +55,17 @@ export default function RootLayout({
<main>
{children}
</main>
<footer className="ssvc-footer">
<p>© {new Date().getFullYear()} SCHAFWASCHENER SEGELVEREIN CHIEMSEE E.V. RIMSTING. Alle Rechte vorbehalten.</p>
<footer className="ssvc-footer border-t border-gray-200 text-gray-700 py-3 mt-8">
<div className="container mx-auto px-4">
<div className="flex flex-col md:flex-row justify-between items-center">
<div className="mb-4 md:mb-0">
<p className="text-center md:text-left">© {new Date().getFullYear()} SCHAFWASCHENER SEGELVEREIN CHIEMSEE E.V. RIMSTING. Alle Rechte vorbehalten.</p>
</div>
<div className="text-center md:text-right">
<p className="whitespace-nowrap">Erstellt von <a href="https://jeanavril.com" target="_blank" rel="noopener noreferrer" className="font-medium text-gray-800 hover:text-blue-600 hover:underline transition-colors no-underline">Jean Jacques Avril</a></p>
</div>
</div>
</div>
</footer>
</body>
</html>
+330
View File
@@ -0,0 +1,330 @@
'use client';
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, Poll } from '@/lib/survey';
export default function VotePage() {
return (
<Suspense fallback={<LoadingComponent />}>
<VotePageContent />
</Suspense>
);
}
function LoadingComponent() {
return <div>Lädt...</div>;
}
function VotePageContent() {
const searchParams = useSearchParams();
const router = useRouter();
const token = searchParams.get('token');
const pollIdParam = searchParams.get('pollId');
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);
const [submittedPollIds, setSubmittedPollIds] = useState<string[]>(() => {
if (typeof window !== 'undefined') {
const storedPollIds = sessionStorage.getItem('submittedPollIds');
return storedPollIds ? JSON.parse(storedPollIds) : [];
}
return [];
});
// Fetch polls
useEffect(() => {
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 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 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');
}
};
fetchPolls();
}, [pollIdParam]);
// Redirect if no token is provided
useEffect(() => {
if (!token) {
router.push('/');
}
}, [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;
}
setIsLoading(true);
setError(null);
try {
const response = await fetch('/api/submit-vote', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
token,
pollId: selectedPollId,
vote: selectedOption,
comment: comment.trim() || undefined,
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Fehler beim Übermitteln der Stimme');
}
// Add this poll to the list of submitted polls
setSubmittedPollIds(prev => {
const updatedPollIds = [...prev, selectedPollId];
sessionStorage.setItem('submittedPollIds', JSON.stringify(updatedPollIds));
return updatedPollIds;
});
// If there are more polls to vote on, select the next one
const remainingPolls = polls.filter(poll => !submittedPollIds.includes(poll.id) && poll.id !== selectedPollId);
if (remainingPolls.length > 0) {
setSelectedPollId(remainingPolls[0].id);
setSelectedOption(null);
setComment('');
} else {
// All polls have been voted on
setIsSubmitted(true);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
} finally {
setIsLoading(false);
}
};
// Get the current poll
const currentPoll = polls.find(poll => poll.id === selectedPollId);
if (isSubmitted) {
return (
<div className="container mx-auto px-4 py-8">
<div className="ssvc-main-content text-center">
<div className="mb-6">
<div className="w-16 h-16 bg-[#e6f0fa] flex items-center justify-center mx-auto mb-4">
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 text-[#0057a6]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 className="text-2xl font-bold text-[#0057a6] mb-2">Vielen Dank!</h2>
<p className="text-gray-600">
Deine Stimme wurde erfolgreich übermittelt.
</p>
</div>
<Link
href="/"
className="ssvc-button inline-block"
>
Zurück zur Startseite
</Link>
</div>
</div>
);
}
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">
<div dangerouslySetInnerHTML={{ __html: currentPoll.question }} />
</div>
<div className="space-y-3">
{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"
>
<input
type="radio"
name="vote"
value={option.id}
checked={selectedOption === option.id}
onChange={() => setSelectedOption(option.id)}
className="h-5 w-5 text-[#0057a6] border-gray-300"
/>
<span className="ml-3 text-gray-800">{option.label}</span>
</label>
))}
</div>
</div>
<div className="p-4 bg-[#f0f7ff] border border-[#cce0ff] rounded-lg">
<label htmlFor="comment" className="flex items-center text-base font-medium text-[#0057a6] mb-2">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
</svg>
Kommentare oder Vorschläge (Optional)
</label>
<div>
<textarea
id="comment"
name="comment"
rows={4}
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Dein Kommentar..."
className="w-full px-4 py-2 border-2 border-gray-300 rounded-md bg-[#e6f0fa] focus:outline-none focus:border-[#0057a6] focus:ring-2 focus:ring-[#0057a6]/20 transition-all duration-200 shadow-sm"
/>
</div>
<p className="mt-1 text-sm text-gray-500">
Bitte kurz fassen und auf konkretes Feedback beschränken.
</p>
</div>
{error && (
<div className="text-red-500 text-sm">{error}</div>
)}
<button
type="submit"
disabled={isLoading}
className="ssvc-button w-full disabled:opacity-50"
>
{isLoading ? 'Wird übermittelt...' : 'Stimme abgeben'}
</button>
</form>
</div>
</div>
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { ReactNode } from 'react';
import '@/app/globals.css';
import type { Metadata } from 'next';
// Define metadata for the iframe page
export const metadata: Metadata = {
title: 'SSVC Rimsting Abstimmung',
description: 'Abstimmungsseite des Schafwaschener Segelverein Rimsting',
};
// This is a minimal layout for iframe embedding without headers or footers
export default function IframeLayout({ children }: { children: ReactNode }) {
return (
<html lang="de">
<body className="bg-white">
<div className="iframe-content">
{children}
</div>
</body>
</html>
);
}
+339
View File
@@ -0,0 +1,339 @@
'use client';
import { EditableText } from '@/components/EditableText';
import { VoteOption, VoteOptionConfig } from '@/lib/survey';
import { useSearchParams } from 'next/navigation';
import { Suspense, useEffect, useState } from 'react';
// Special layout for iframe embedding - no header/footer
export default function IframePage() {
return (
<Suspense fallback={<LoadingComponent />}>
<IframePageContent />
</Suspense>
);
}
function LoadingComponent() {
return <div>Lädt...</div>;
}
function IframePageContent() {
const searchParams = useSearchParams();
const token = searchParams.get('token');
// Login state
const [memberNumber, setMemberNumber] = useState('');
const [password, setPassword] = useState('');
const [isLoginLoading, setIsLoginLoading] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
const [isLoginEnabled, setIsLoginEnabled] = useState(true);
const [isLoggedIn, setIsLoggedIn] = useState(false);
// Vote state
const [voteOptions, setVoteOptions] = useState<VoteOptionConfig[]>([]);
const [selectedOption, setSelectedOption] = useState<VoteOption | null>(null);
const [comment, setComment] = useState('');
const [isVoteLoading, setIsVoteLoading] = useState(false);
const [voteError, setVoteError] = useState<string | null>(null);
const [isSubmitted, setIsSubmitted] = useState(false);
const [voteToken, setVoteToken] = useState<string | null>(token);
// Check if member authentication is enabled
useEffect(() => {
const checkSettings = async () => {
try {
const response = await fetch('/api/member-login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ memberNumber: '', password: '' }),
});
if (response.status === 403) {
setIsLoginEnabled(false);
}
} catch {
// Silently fail, assume enabled
}
};
checkSettings();
}, []);
// Fetch vote options
useEffect(() => {
const fetchVoteOptions = 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);
}
}
}
} 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' }
]);
}
};
fetchVoteOptions();
}, []);
// If token is provided in URL, consider user as logged in
useEffect(() => {
if (token) {
setIsLoggedIn(true);
setVoteToken(token);
}
}, [token]);
const handleLoginSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!memberNumber || !password) {
setLoginError('Bitte geben Sie Ihre Mitgliedsnummer und Ihr Passwort ein');
return;
}
setIsLoginLoading(true);
setLoginError(null);
try {
const response = await fetch('/api/member-login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ memberNumber, password }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Anmeldung fehlgeschlagen');
}
// Set the token and mark as logged in
setVoteToken(data.token);
setIsLoggedIn(true);
} catch (err) {
setLoginError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
} finally {
setIsLoginLoading(false);
}
};
const handleVoteSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedOption) {
setVoteError('Bitte wähle eine Option');
return;
}
setIsVoteLoading(true);
setVoteError(null);
try {
const response = await fetch('/api/submit-vote', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
token: voteToken,
vote: selectedOption,
comment: comment.trim() || undefined,
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Fehler beim Übermitteln der Stimme');
}
setIsSubmitted(true);
} catch (err) {
setVoteError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
} finally {
setIsVoteLoading(false);
}
};
// Login form
if (!isLoggedIn) {
if (!isLoginEnabled) {
return (
<div className="px-4 py-4">
<div className="mb-4">
<div className="ssvc-main-content">
<div className="text-center p-4">
<div className="mb-4 text-red-600">
Die Mitgliederanmeldung ist derzeit deaktiviert.
</div>
</div>
</div>
</div>
</div>
);
}
return (
<div className="px-4 py-4">
<div className="mb-4">
<div className="ssvc-main-content">
<div className="mb-4">
<EditableText id="current-vote-text" />
</div>
<form onSubmit={handleLoginSubmit} className="space-y-3">
<div>
<label htmlFor="memberNumber" className="block text-sm font-medium text-gray-700 mb-1">
Mitgliedsnummer
</label>
<input
type="text"
id="memberNumber"
value={memberNumber}
onChange={(e) => setMemberNumber(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 focus:outline-none focus:border-[#0057a6]"
required
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
Passwort
</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 focus:outline-none focus:border-[#0057a6]"
required
/>
</div>
{loginError && (
<div className="text-red-500 text-sm">{loginError}</div>
)}
<button
type="submit"
disabled={isLoginLoading}
className="ssvc-button w-full disabled:opacity-50"
>
{isLoginLoading ? 'Anmelden...' : 'Anmelden'}
</button>
</form>
</div>
</div>
</div>
);
}
// Vote form
if (isSubmitted) {
return (
<div className="px-4 py-4">
<div className="ssvc-main-content text-center">
<div className="mb-4">
<div className="w-12 h-12 bg-[#e6f0fa] flex items-center justify-center mx-auto mb-2">
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-[#0057a6]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 className="text-xl font-bold text-[#0057a6] mb-2">Vielen Dank!</h2>
<p className="text-gray-600">
Deine Stimme wurde erfolgreich übermittelt.
</p>
</div>
</div>
</div>
);
}
return (
<div className="px-4 py-4">
<div className="mb-4">
<div className="ssvc-main-content">
<form onSubmit={handleVoteSubmit} className="space-y-4">
<div>
<div className="text-lg font-bold text-[#0057a6] mb-3">
<EditableText id="vote-question" />
</div>
<div className="space-y-2">
{voteOptions.map((option) => (
<label
key={option.id}
className="flex items-center p-2 border border-gray-200 cursor-pointer hover:bg-[#e6f0fa] transition-colors"
>
<input
type="radio"
name="vote"
value={option.id}
checked={selectedOption === option.id}
onChange={() => setSelectedOption(option.id)}
className="h-4 w-4 text-[#0057a6] border-gray-300"
/>
<span className="ml-2 text-gray-800">{option.label}</span>
</label>
))}
</div>
</div>
<div className="p-3 bg-[#f0f7ff] border border-[#cce0ff] rounded-lg">
<label htmlFor="comment" className="flex items-center text-sm font-medium text-[#0057a6] mb-2">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
</svg>
Kommentare oder Vorschläge (Optional)
</label>
<div>
<textarea
id="comment"
name="comment"
rows={3}
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Dein Kommentar..."
className="w-full px-3 py-2 border-2 border-gray-300 rounded-md bg-[#e6f0fa] focus:outline-none focus:border-[#0057a6] focus:ring-2 focus:ring-[#0057a6]/20 transition-all duration-200 shadow-sm"
/>
</div>
<p className="mt-1 text-xs text-gray-500">
Bitte kurz fassen und auf konkretes Feedback beschränken.
</p>
</div>
{voteError && (
<div className="text-red-500 text-sm">{voteError}</div>
)}
<button
type="submit"
disabled={isVoteLoading}
className="ssvc-button w-full disabled:opacity-50"
>
{isVoteLoading ? 'Wird übermittelt...' : 'Stimme abgeben'}
</button>
</form>
</div>
</div>
</div>
);
}
-134
View File
@@ -1,134 +0,0 @@
import { checkAdminAuth } from '@/lib/auth';
import fs from 'fs';
import { NextRequest, NextResponse } from 'next/server';
import path from 'path';
// Path to the editable text file
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 });
}
}
// Get all editable texts
function getEditableTexts() {
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'
}
]
}
];
fs.writeFileSync(TEXT_FILE, JSON.stringify(defaultTexts, null, 2));
return defaultTexts;
}
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));
}
// 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 }
);
}
}
// 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 }
);
}
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 }
);
}
}
-49
View File
@@ -1,49 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { verifyToken } from '@/lib/server-auth';
import { saveResponse, VoteOption, getVoteOptions } from '@/lib/survey';
export async function POST(request: NextRequest) {
try {
// Get the token from the request
const { token, vote, comment } = await request.json();
if (!token) {
return NextResponse.json(
{ error: 'Token is required' },
{ status: 400 }
);
}
// Get available vote options
const voteOptions = getVoteOptions();
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);
if (!valid) {
return NextResponse.json(
{ error: 'Invalid or already used token' },
{ status: 401 }
);
}
// Save the response
const response = saveResponse(vote as VoteOption, comment);
return NextResponse.json({ success: true, response });
} catch (error) {
console.error('Error submitting vote:', error);
return NextResponse.json(
{ error: 'Failed to submit vote' },
{ status: 500 }
);
}
}
+6 -4
View File
@@ -80,9 +80,11 @@ body {
}
.ssvc-footer {
margin-top: 2rem;
padding: 1rem;
text-align: center;
font-size: 0.875rem;
color: #666;
}
.ssvc-footer a {
font-weight: 500;
text-decoration: none;
transition: opacity 0.2s;
}
-201
View File
@@ -1,201 +0,0 @@
'use client';
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';
export default function VotePage() {
return (
<Suspense fallback={<LoadingComponent />}>
<VotePageContent />
</Suspense>
);
}
function LoadingComponent() {
return <div>Lädt...</div>;
}
function VotePageContent() {
const searchParams = useSearchParams();
const router = useRouter();
const token = searchParams.get('token');
const [voteOptions, setVoteOptions] = useState<VoteOptionConfig[]>([]);
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
useEffect(() => {
const fetchVoteOptions = 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);
}
}
}
} 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' }
]);
}
};
fetchVoteOptions();
}, []);
// Redirect if no token is provided
useEffect(() => {
if (!token) {
router.push('/');
}
}, [token, router]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedOption) {
setError('Bitte wählen Sie eine Option');
return;
}
setIsLoading(true);
setError(null);
try {
const response = await fetch('/api/submit-vote', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
token,
vote: selectedOption,
comment: comment.trim() || undefined,
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Fehler beim Übermitteln der Stimme');
}
setIsSubmitted(true);
} catch (err) {
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
} finally {
setIsLoading(false);
}
};
if (isSubmitted) {
return (
<div className="container mx-auto px-4 py-8">
<div className="ssvc-main-content text-center">
<div className="mb-6">
<div className="w-16 h-16 bg-[#e6f0fa] flex items-center justify-center mx-auto mb-4">
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 text-[#0057a6]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 className="text-2xl font-bold text-[#0057a6] mb-2">Vielen Dank!</h2>
<p className="text-gray-600">
Ihre Stimme wurde erfolgreich übermittelt.
</p>
</div>
<Link
href="/"
className="ssvc-button inline-block"
>
Zurück zur Startseite
</Link>
</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>
<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>
<div className="space-y-3">
{voteOptions.map((option) => (
<label
key={option.id}
className="flex items-center p-3 border border-gray-200 cursor-pointer hover:bg-[#e6f0fa] transition-colors"
>
<input
type="radio"
name="vote"
value={option.id}
checked={selectedOption === option.id}
onChange={() => setSelectedOption(option.id)}
className="h-5 w-5 text-[#0057a6] border-gray-300"
/>
<span className="ml-3 text-gray-800">{option.label}</span>
</label>
))}
</div>
</div>
<div>
<label htmlFor="comment" className="block text-sm font-medium text-gray-700 mb-1">
Kommentare oder Vorschläge (Optional)
</label>
<div className="mt-1">
<textarea
id="comment"
name="comment"
rows={4}
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Bitte geben Sie keine persönlichen Daten an"
className="w-full px-4 py-2 border border-gray-300 focus:outline-none focus:border-[#0057a6]"
/>
</div>
<p className="mt-1 text-sm text-gray-500">
Bitte geben Sie keine persönlichen Daten in Ihren Kommentaren an.
</p>
</div>
{error && (
<div className="text-red-500 text-sm">{error}</div>
)}
<button
type="submit"
disabled={isLoading}
className="ssvc-button w-full disabled:opacity-50"
>
{isLoading ? 'Wird übermittelt...' : 'Stimme abgeben'}
</button>
</form>
</div>
</div>
</div>
);
}
+15 -2
View File
@@ -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');
}
// 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 {
+97 -16
View File
@@ -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')}
+7
View File
@@ -111,7 +111,14 @@ export default function TokenGenerator() {
<div className="mt-6 p-4 bg-[#e6f0fa] w-full">
<h3 className="font-medium text-[#0057a6] mb-2">Generierter Abstimmungslink:</h3>
<div className="break-all text-sm text-[#0057a6] mb-2">
<a
href={generatedLink}
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
{generatedLink}
</a>
</div>
<button
onClick={copyToClipboard}
+351 -43
View File
@@ -1,29 +1,71 @@
'use client';
import { useState } from 'react';
import { useState, useRef, useEffect } from 'react';
import { Poll } from '@/lib/survey';
interface VoteOptionsManagerProps {
voteOptions: { id: string; label: string }[];
onVoteOptionsChange: (newOptions: { id: string; label: string }[]) => void;
// Function to generate a random ID
function generateRandomId(existingIds: string[]): string {
const characters = 'abcdefghijklmnopqrstuvwxyz0123456789';
const length = 8;
let result: string;
do {
result = '';
for (let i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * characters.length));
}
} while (existingIds.includes(result));
return result;
}
export default function VoteOptionsManager({ voteOptions, onVoteOptionsChange }: VoteOptionsManagerProps) {
interface VoteOptionsManagerProps {
polls: Poll[];
onPollsChange: (newPolls: Poll[]) => void;
}
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);
// 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;
// Select the first poll by default
useEffect(() => {
if (polls.length > 0 && !selectedPollId) {
setSelectedPollId(polls[0].id);
}
}, [polls, selectedPollId]);
// Check if ID already exists
if (voteOptions.some(option => option.id === newOptionId)) {
setError('Eine Option mit dieser ID existiert bereits');
// Generate a random ID when the component mounts or when the form is reset
useEffect(() => {
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, selectedPollId, polls]);
// Get the current poll
const currentPoll = polls.find(poll => poll.id === selectedPollId);
const voteOptions = currentPoll?.options || [];
// Create a new poll
const handleCreatePoll = async () => {
if (!newPollTitle.trim()) {
setError('Bitte geben Sie einen Titel für die neue Abstimmung ein');
return;
}
@@ -31,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', {
@@ -41,8 +101,156 @@ 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 neuen Abstimmung');
}
// Update parent component state
onPollsChange(updatedPolls);
// 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 {
setIsLoading(false);
}
};
// Toggle poll active status
const handleTogglePollActive = async (pollId: string) => {
setIsLoading(true);
setError(null);
try {
// Update in local state
const updatedPolls = polls.map(poll =>
poll.id === pollId
? { ...poll, active: !poll.active }
: poll
);
// Save to server
const response = await fetch('/api/editable-text', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
id: 'polls',
content: updatedPolls
}),
});
if (!response.ok) {
throw new Error('Fehler beim Aktualisieren des Abstimmungsstatus');
}
// Update parent component state
onPollsChange(updatedPolls);
} catch (err) {
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
} finally {
setIsLoading(false);
}
};
// 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 {
// Remove from local state
const updatedPolls = polls.filter(poll => poll.id !== pollId);
// Save to server
const response = await fetch('/api/editable-text', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
id: 'polls',
content: updatedPolls
}),
});
if (!response.ok) {
throw new Error('Fehler beim Löschen der Abstimmung');
}
// Update parent component state
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 {
setIsLoading(false);
}
};
// 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 {
// 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', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
id: 'polls',
content: updatedPolls
}),
});
@@ -51,11 +259,14 @@ export default function VoteOptionsManager({ voteOptions, onVoteOptionsChange }:
}
// Update parent component state
onVoteOptionsChange(updatedOptions);
onPollsChange(updatedPolls);
// Reset form
setNewOptionId('');
// 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 {
@@ -65,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');
@@ -72,7 +285,8 @@ export default function VoteOptionsManager({ voteOptions, onVoteOptionsChange }:
}
// Show confirmation dialog
if (!confirm(`Sind Sie sicher, dass Sie die Option "${optionId}" entfernen möchten? Bestehende Abstimmungen mit dieser Option werden nicht gelöscht, aber in den Statistiken möglicherweise nicht mehr korrekt angezeigt.`)) {
const optionToRemove = voteOptions.find(option => option.id === optionId);
if (!confirm(`Sind Sie sicher, dass Sie die Option "${optionToRemove?.label}" entfernen möchten? Bestehende Abstimmungen mit dieser Option werden nicht gelöscht, aber in den Statistiken möglicherweise nicht mehr korrekt angezeigt.`)) {
return;
}
@@ -80,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', {
@@ -90,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
}),
});
@@ -100,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 {
@@ -111,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"
@@ -122,7 +344,103 @@ 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">
{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 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>
{/* 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 &lt;p&gt; 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) => (
@@ -141,11 +459,12 @@ export default function VoteOptionsManager({ voteOptions, onVoteOptionsChange }:
))}
</div>
<h3 className="font-medium text-[#0057a6] mb-2">Neue Option hinzufügen</h3>
<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 (z.B. &quot;yes&quot;, &quot;no&quot;)
Option ID
</label>
<input
type="text"
@@ -155,11 +474,7 @@ export default function VoteOptionsManager({ voteOptions, onVoteOptionsChange }:
className="w-full px-3 py-2 border border-gray-300 focus:outline-none focus:border-[#0057a6]"
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
@@ -172,15 +487,10 @@ export default function VoteOptionsManager({ voteOptions, onVoteOptionsChange }:
className="w-full px-3 py-2 border border-gray-300 focus:outline-none focus:border-[#0057a6]"
placeholder="Anzeigename der Option"
/>
<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}
@@ -189,13 +499,11 @@ export default function VoteOptionsManager({ voteOptions, onVoteOptionsChange }:
{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>
)}
</div>
);
}
+26 -63
View File
@@ -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);
+28 -47
View File
@@ -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 };
}
const passwordValid = comparePassword(password, member.password);
if (passwordValid) {
// Update last login time
member.lastLogin = new Date().toISOString();
if (memberIndex !== -1) {
if (!members[memberIndex].votedPolls.includes(pollId)) {
members[memberIndex].votedPolls.push(pollId);
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 };
+201 -6
View File
@@ -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;
});