fix: les 6

This commit is contained in:
2026-03-11 14:07:00 +01:00
parent d5066021ab
commit 9ffdecf2c4
117 changed files with 13198 additions and 5194 deletions

View File

@@ -0,0 +1,6 @@
You are a Next.js 15 expert using App Router with TypeScript.
Use server components by default.
Use "use client" only when needed for interactivity.
Always define TypeScript interfaces for props, params, and API bodies.
Use Tailwind CSS for styling.
Use the @/ import alias for all local imports.

View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,23 @@
{
"name": "quickpoll",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"tailwindcss": "^4",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { getPollById } from "@/lib/data";
interface RouteParams {
params: Promise<{ id: string }>;
}
// GET /api/polls/[id] — enkele poll ophalen
export async function GET(
request: Request,
{ params }: RouteParams
): Promise<NextResponse> {
const { id } = await params;
const poll = getPollById(id);
if (!poll) {
return NextResponse.json(
{ error: "Poll niet gevonden" },
{ status: 404 }
);
}
return NextResponse.json(poll);
}

View File

@@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import { votePoll } from "@/lib/data";
interface RouteParams {
params: Promise<{ id: string }>;
}
interface VoteBody {
optionIndex: number;
}
// POST /api/polls/[id]/vote — stem uitbrengen
export async function POST(
request: Request,
{ params }: RouteParams
): Promise<NextResponse> {
const { id } = await params;
const body: VoteBody = await request.json();
if (typeof body.optionIndex !== "number") {
return NextResponse.json(
{ error: "optionIndex is verplicht" },
{ status: 400 }
);
}
const updatedPoll = votePoll(id, body.optionIndex);
if (!updatedPoll) {
return NextResponse.json(
{ error: "Poll niet gevonden of ongeldige optie" },
{ status: 404 }
);
}
return NextResponse.json(updatedPoll);
}

View File

@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { getPolls, createPoll } from "@/lib/data";
import type { Poll, CreatePollBody } from "@/types";
// GET /api/polls — alle polls ophalen
export async function GET(): Promise<NextResponse<Poll[]>> {
const polls = getPolls();
return NextResponse.json(polls);
}
// POST /api/polls — nieuwe poll aanmaken
export async function POST(request: Request): Promise<NextResponse> {
const body: CreatePollBody = await request.json();
if (!body.question || !body.options || body.options.length < 2) {
return NextResponse.json(
{ error: "Vraag en minstens 2 opties zijn verplicht" },
{ status: 400 }
);
}
const newPoll = createPoll(body.question, body.options);
return NextResponse.json(newPoll, { status: 201 });
}

View File

@@ -0,0 +1,144 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function CreatePollPage() {
const [question, setQuestion] = useState<string>("");
const [options, setOptions] = useState<string[]>(["", ""]);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
function addOption(): void {
if (options.length < 6) {
setOptions([...options, ""]);
}
}
function removeOption(index: number): void {
if (options.length > 2) {
setOptions(options.filter((_, i) => i !== index));
}
}
function updateOption(index: number, value: string): void {
const newOptions = [...options];
newOptions[index] = value;
setOptions(newOptions);
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>): Promise<void> {
e.preventDefault();
setError(null);
const filledOptions = options.filter((opt) => opt.trim() !== "");
if (!question.trim() || filledOptions.length < 2) {
setError("Vul een vraag in en minstens 2 opties");
return;
}
setIsSubmitting(true);
const response = await fetch("/api/polls", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
question: question.trim(),
options: filledOptions,
}),
});
if (response.ok) {
router.push("/");
} else {
setError("Er ging iets mis bij het aanmaken van de poll");
}
setIsSubmitting(false);
}
return (
<div className="max-w-2xl mx-auto">
<h1 className="text-2xl font-bold text-gray-900 mb-6">
Nieuwe Poll Aanmaken
</h1>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label
htmlFor="question"
className="block text-sm font-medium text-gray-700 mb-2"
>
Vraag
</label>
<input
id="question"
type="text"
value={question}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setQuestion(e.target.value)
}
placeholder="Stel je vraag..."
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent outline-none"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Opties (minimaal 2, maximaal 6)
</label>
<div className="space-y-3">
{options.map((option, index) => (
<div key={index} className="flex gap-2">
<input
type="text"
value={option}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
updateOption(index, e.target.value)
}
placeholder={`Optie ${index + 1}`}
className="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent outline-none"
/>
{options.length > 2 && (
<button
type="button"
onClick={() => removeOption(index)}
className="px-3 text-red-500 hover:bg-red-50 rounded-lg transition-colors"
>
</button>
)}
</div>
))}
</div>
{options.length < 6 && (
<button
type="button"
onClick={addOption}
className="mt-3 text-sm text-purple-600 hover:text-purple-800 font-medium"
>
+ Optie toevoegen
</button>
)}
</div>
{error && (
<p className="text-red-600 text-sm bg-red-50 p-3 rounded-lg">
{error}
</p>
)}
<button
type="submit"
disabled={isSubmitting}
className="w-full bg-purple-600 text-white py-3 rounded-lg font-medium hover:bg-purple-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors"
>
{isSubmitting ? "Bezig met aanmaken..." : "Poll Aanmaken"}
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,24 @@
"use client";
export default function Error({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<div className="text-center py-16">
<h2 className="text-2xl font-bold text-red-600 mb-4">
Er ging iets mis!
</h2>
<p className="text-gray-600 mb-6">{error.message}</p>
<button
onClick={() => reset()}
className="bg-purple-600 text-white px-6 py-3 rounded-lg hover:bg-purple-700 transition-colors"
>
Probeer opnieuw
</button>
</div>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -0,0 +1,46 @@
import type { Metadata } from "next";
import Link from "next/link";
import "./globals.css";
export const metadata: Metadata = {
title: "QuickPoll — Stem op alles",
description: "Maak en deel polls met je vrienden",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="nl">
<body className="min-h-screen bg-gray-50 text-gray-900">
<nav className="bg-white border-b border-gray-200 shadow-sm">
<div className="max-w-4xl mx-auto px-4 py-4 flex items-center justify-between">
<Link href="/" className="text-xl font-bold text-purple-600">
🗳 QuickPoll
</Link>
<div className="flex gap-4 items-center">
<Link
href="/"
className="text-gray-600 hover:text-purple-600 transition-colors"
>
Polls
</Link>
<Link
href="/create"
className="bg-purple-600 text-white px-4 py-2 rounded-lg hover:bg-purple-700 transition-colors text-sm font-medium"
>
Nieuwe Poll
</Link>
</div>
</div>
</nav>
<main className="max-w-4xl mx-auto px-4 py-8">{children}</main>
<footer className="text-center text-gray-400 text-sm py-8">
© 2025 QuickPoll NOVI Hogeschool Les 5
</footer>
</body>
</html>
);
}

View File

@@ -0,0 +1,24 @@
export default function Loading() {
return (
<div className="space-y-4">
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-1/3 mb-2" />
<div className="h-4 bg-gray-200 rounded w-1/2 mb-8" />
</div>
{[1, 2, 3].map((i) => (
<div
key={i}
className="animate-pulse bg-white rounded-xl border border-gray-200 p-6"
>
<div className="h-5 bg-gray-200 rounded w-3/4 mb-3" />
<div className="h-4 bg-gray-200 rounded w-1/4 mb-3" />
<div className="flex gap-2">
<div className="h-6 bg-gray-100 rounded-full w-20" />
<div className="h-6 bg-gray-100 rounded-full w-24" />
<div className="h-6 bg-gray-100 rounded-full w-16" />
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,18 @@
import Link from "next/link";
export default function NotFound() {
return (
<div className="text-center py-16">
<h2 className="text-4xl font-bold text-gray-900 mb-4">404</h2>
<p className="text-gray-600 mb-6">
Deze pagina bestaat niet (meer).
</p>
<Link
href="/"
className="bg-purple-600 text-white px-6 py-3 rounded-lg hover:bg-purple-700 transition-colors inline-block"
>
Terug naar home
</Link>
</div>
);
}

View File

@@ -0,0 +1,57 @@
import Link from "next/link";
import { getPolls } from "@/lib/data";
import type { Poll } from "@/types";
export const dynamic = "force-dynamic";
export default function HomePage() {
const polls: Poll[] = getPolls();
const totalVotes = (poll: Poll): number =>
poll.votes.reduce((sum, v) => sum + v, 0);
return (
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">Actieve Polls</h1>
<p className="text-gray-500 mb-8">Klik op een poll om te stemmen</p>
<div className="grid gap-4">
{polls.map((poll) => (
<Link
key={poll.id}
href={`/poll/${poll.id}`}
className="block bg-white rounded-xl border border-gray-200 p-6 hover:border-purple-300 hover:shadow-md transition-all"
>
<h2 className="text-lg font-semibold text-gray-900 mb-2">
{poll.question}
</h2>
<div className="flex items-center gap-4 text-sm text-gray-500">
<span>{poll.options.length} opties</span>
<span>·</span>
<span>{totalVotes(poll)} stemmen</span>
</div>
<div className="flex flex-wrap gap-2 mt-3">
{poll.options.map((option, index) => (
<span
key={index}
className="bg-gray-100 text-gray-600 px-3 py-1 rounded-full text-sm"
>
{option}
</span>
))}
</div>
</Link>
))}
</div>
{polls.length === 0 && (
<div className="text-center py-16 text-gray-400">
<p className="text-lg">Nog geen polls</p>
<Link href="/create" className="text-purple-600 hover:underline">
Maak de eerste!
</Link>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,20 @@
import Link from "next/link";
export default function PollNotFound() {
return (
<div className="text-center py-16">
<h2 className="text-2xl font-bold text-gray-900 mb-4">
Poll niet gevonden
</h2>
<p className="text-gray-600 mb-6">
Deze poll bestaat niet of is verwijderd.
</p>
<Link
href="/"
className="bg-purple-600 text-white px-6 py-3 rounded-lg hover:bg-purple-700 transition-colors inline-block"
>
Bekijk alle polls
</Link>
</div>
);
}

View File

@@ -0,0 +1,40 @@
import { notFound } from "next/navigation";
import { getPollById } from "@/lib/data";
import VoteForm from "@/components/VoteForm";
import type { Metadata } from "next";
interface PageProps {
params: Promise<{ id: string }>;
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { id } = await params;
const poll = getPollById(id);
if (!poll) {
return { title: "Poll niet gevonden" };
}
return {
title: `${poll.question} — QuickPoll`,
description: `Stem op: ${poll.options.join(", ")}`,
};
}
export default async function PollPage({ params }: PageProps) {
const { id } = await params;
const poll = getPollById(id);
if (!poll) {
notFound();
}
return (
<div className="max-w-2xl mx-auto">
<h1 className="text-2xl font-bold text-gray-900 mb-6">
{poll.question}
</h1>
<VoteForm poll={poll} />
</div>
);
}

View File

@@ -0,0 +1,128 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import type { Poll } from "@/types";
interface VoteFormProps {
poll: Poll;
}
export default function VoteForm({ poll }: VoteFormProps) {
const [selectedOption, setSelectedOption] = useState<number | null>(null);
const [hasVoted, setHasVoted] = useState<boolean>(false);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const [currentPoll, setCurrentPoll] = useState<Poll>(poll);
const router = useRouter();
const totalVotes: number = currentPoll.votes.reduce(
(sum, v) => sum + v,
0
);
async function handleVote(): Promise<void> {
if (selectedOption === null || isSubmitting) return;
setIsSubmitting(true);
const response = await fetch(`/api/polls/${currentPoll.id}/vote`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ optionIndex: selectedOption }),
});
if (response.ok) {
const updatedPoll: Poll = await response.json();
setCurrentPoll(updatedPoll);
setHasVoted(true);
}
setIsSubmitting(false);
}
function getPercentage(votes: number): number {
if (totalVotes === 0) return 0;
return Math.round((votes / totalVotes) * 100);
}
return (
<div className="space-y-3">
{currentPoll.options.map((option, index) => {
const percentage = getPercentage(currentPoll.votes[index]);
const isSelected = selectedOption === index;
return (
<button
key={index}
onClick={() => !hasVoted && setSelectedOption(index)}
disabled={hasVoted}
className={`w-full text-left p-4 rounded-lg border-2 transition-all relative overflow-hidden ${
hasVoted
? "border-gray-200 cursor-default"
: isSelected
? "border-purple-500 bg-purple-50"
: "border-gray-200 hover:border-purple-300 cursor-pointer"
}`}
>
{hasVoted && (
<div
className="absolute inset-0 bg-purple-100 transition-all duration-500"
style={{ width: `${percentage}%` }}
/>
)}
<div className="relative flex justify-between items-center">
<div className="flex items-center gap-3">
{!hasVoted && (
<div
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
isSelected
? "border-purple-500 bg-purple-500"
: "border-gray-300"
}`}
>
{isSelected && (
<div className="w-2 h-2 rounded-full bg-white" />
)}
</div>
)}
<span className="font-medium">{option}</span>
</div>
{hasVoted && (
<span className="text-sm font-semibold text-purple-700">
{percentage}% ({currentPoll.votes[index]} stemmen)
</span>
)}
</div>
</button>
);
})}
{!hasVoted && (
<button
onClick={handleVote}
disabled={selectedOption === null || isSubmitting}
className="w-full bg-purple-600 text-white py-3 rounded-lg font-medium hover:bg-purple-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors mt-4"
>
{isSubmitting ? "Bezig met stemmen..." : "Stem!"}
</button>
)}
{hasVoted && (
<div className="text-center pt-4">
<p className="text-green-600 font-medium mb-2">
Bedankt voor je stem!
</p>
<p className="text-sm text-gray-500">
Totaal: {totalVotes} stemmen
</p>
<button
onClick={() => router.push("/")}
className="mt-4 text-purple-600 hover:underline text-sm"
>
Terug naar alle polls
</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,55 @@
import { Poll } from "@/types";
export const polls: Poll[] = [
{
id: "1",
question: "Wat is de beste code editor?",
options: ["VS Code", "Cursor", "Vim", "WebStorm"],
votes: [12, 25, 5, 3],
},
{
id: "2",
question: "Wat is de beste programmeertaal?",
options: ["TypeScript", "Python", "Rust", "Go"],
votes: [18, 15, 8, 4],
},
{
id: "3",
question: "Welk framework heeft de toekomst?",
options: ["Next.js", "Remix", "Astro", "SvelteKit"],
votes: [22, 6, 10, 7],
},
];
let nextId = 4;
export function getPolls(): Poll[] {
return polls;
}
export function getPollById(id: string): Poll | undefined {
return polls.find((poll) => poll.id === id);
}
export function createPoll(question: string, options: string[]): Poll {
const newPoll: Poll = {
id: String(nextId++),
question,
options,
votes: new Array(options.length).fill(0),
};
polls.push(newPoll);
return newPoll;
}
export function votePoll(
pollId: string,
optionIndex: number
): Poll | undefined {
const poll = polls.find((p) => p.id === pollId);
if (!poll || optionIndex < 0 || optionIndex >= poll.options.length) {
return undefined;
}
poll.votes[optionIndex]++;
return poll;
}

View File

@@ -0,0 +1,17 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest): NextResponse {
const start = Date.now();
console.log(`[${request.method}] ${request.nextUrl.pathname}`);
const response = NextResponse.next();
response.headers.set("x-request-time", String(Date.now() - start));
return response;
}
export const config = {
matcher: ["/api/:path*", "/poll/:path*"],
};

View File

@@ -0,0 +1,11 @@
export interface Poll {
id: string;
question: string;
options: string[];
votes: number[];
}
export interface CreatePollBody {
question: string;
options: string[];
}

View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}