1184 lines
28 KiB
Markdown
1184 lines
28 KiB
Markdown
# Les 6: QuickPoll Live Coding Guide
|
|
|
|
> Dit is je stap-voor-stap spiekbriefje. Lees dit op je eigen scherm terwijl je op de beamer codeert.
|
|
> Alles staat in de volgorde waarin je het typt. Niets wordt overgeslagen.
|
|
|
|
---
|
|
|
|
## STAP 0 — Project aanmaken
|
|
|
|
**Terminal:**
|
|
```bash
|
|
npx create-next-app@latest quickpoll
|
|
```
|
|
|
|
**Opties selecteren:**
|
|
- TypeScript → Yes
|
|
- ESLint → Yes
|
|
- Tailwind CSS → Yes
|
|
- `src/` directory → Yes
|
|
- App Router → Yes
|
|
- Import alias → @/*
|
|
|
|
```bash
|
|
cd quickpoll
|
|
npm run dev
|
|
```
|
|
|
|
**Check:** localhost:3000 → Next.js standaardpagina
|
|
|
|
**Vertel:** "Dit is create-next-app. Geeft ons een compleet project met TypeScript, Tailwind en de App Router. Alles wat we nodig hebben."
|
|
|
|
---
|
|
|
|
## STAP 1 — Types
|
|
|
|
### 1a. Maak `src/types/index.ts`
|
|
|
|
**Vertel:** "Eerst: wat IS een poll? We definiëren dat met een TypeScript interface."
|
|
|
|
Typ:
|
|
```tsx
|
|
export interface Poll {
|
|
```
|
|
|
|
**Vertel:** "Een poll heeft een id…"
|
|
|
|
```tsx
|
|
id: string;
|
|
```
|
|
|
|
"…een vraag…"
|
|
|
|
```tsx
|
|
question: string;
|
|
```
|
|
|
|
"…een lijst opties…"
|
|
|
|
```tsx
|
|
options: string[];
|
|
```
|
|
|
|
"…en stemmen per optie. De index matcht: votes[0] hoort bij options[0]."
|
|
|
|
```tsx
|
|
votes: number[];
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## STAP 1 vervolg — Data
|
|
|
|
### 1b. Maak `src/lib/data.ts`
|
|
|
|
**Vertel:** "Nu onze 'database'. Gewoon een array in het geheugen. Later vervangen we dit door Supabase."
|
|
|
|
Typ de import:
|
|
```tsx
|
|
import type { Poll } from "@/types";
|
|
```
|
|
|
|
**Vertel:** "We maken drie polls aan als testdata."
|
|
|
|
```tsx
|
|
let polls: Poll[] = [
|
|
{
|
|
id: "1",
|
|
question: "Wat is je favoriete programmeer taal?",
|
|
options: ["JavaScript", "Python", "TypeScript", "Rust"],
|
|
votes: [45, 32, 28, 15],
|
|
},
|
|
{
|
|
id: "2",
|
|
question: "Hoe veel uur slaap krijg je per nacht?",
|
|
options: ["< 6 uur", "6-8 uur", "8+ uur"],
|
|
votes: [12, 68, 35],
|
|
},
|
|
{
|
|
id: "3",
|
|
question: "Welke framework gebruik je het meest?",
|
|
options: ["React", "Vue", "Svelte", "Angular"],
|
|
votes: [89, 34, 12, 8],
|
|
},
|
|
];
|
|
```
|
|
|
|
**Vertel:** "Nu helper functies. Eerst: alle polls ophalen."
|
|
|
|
```tsx
|
|
export function getPolls(): Poll[] {
|
|
return polls;
|
|
}
|
|
```
|
|
|
|
"Eén poll ophalen op id:"
|
|
|
|
```tsx
|
|
export function getPollById(id: string): Poll | undefined {
|
|
return polls.find((poll) => poll.id === id);
|
|
}
|
|
```
|
|
|
|
**Vertel:** "Let op het return type: `Poll | undefined`. Misschien bestaat de poll niet — dat moeten we afvangen."
|
|
|
|
"En stemmen:"
|
|
|
|
```tsx
|
|
export function votePoll(pollId: string, optionIndex: number): Poll | undefined {
|
|
const poll = polls.find((p) => p.id === pollId);
|
|
```
|
|
|
|
"Check of de poll bestaat en de index klopt:"
|
|
|
|
```tsx
|
|
if (!poll || optionIndex < 0 || optionIndex >= poll.options.length) {
|
|
return undefined;
|
|
}
|
|
```
|
|
|
|
"Stem ophogen en poll teruggeven:"
|
|
|
|
```tsx
|
|
poll.votes[optionIndex]++;
|
|
return poll;
|
|
}
|
|
```
|
|
|
|
**Checkpoint:** "Heeft iedereen types/index.ts en lib/data.ts?"
|
|
|
|
---
|
|
|
|
## STAP 2 — Layout
|
|
|
|
### 2a. Vervang `src/app/layout.tsx`
|
|
|
|
**Vertel:** "De layout wrapt elke pagina. Navbar en footer staan hier — die veranderen nooit."
|
|
|
|
Wis de inhoud. Typ de imports:
|
|
|
|
```tsx
|
|
import type { Metadata } from "next";
|
|
import Link from "next/link";
|
|
import "./globals.css";
|
|
```
|
|
|
|
**Vertel:** "Link is de Next.js versie van een `<a>` tag. Client-side navigatie, geen page reload."
|
|
|
|
SEO metadata:
|
|
|
|
```tsx
|
|
export const metadata: Metadata = {
|
|
title: "QuickPoll",
|
|
description: "Een snelle polling app met Next.js",
|
|
};
|
|
```
|
|
|
|
**Vertel:** "metadata is voor SEO. Kijk straks in je browser tab."
|
|
|
|
De functie:
|
|
|
|
```tsx
|
|
export default function RootLayout({
|
|
children,
|
|
}: Readonly<{ children: React.ReactNode }>) {
|
|
return (
|
|
<html lang="nl">
|
|
<body className="bg-gray-50 min-h-screen flex flex-col">
|
|
```
|
|
|
|
**Vertel:** "Nu de navbar. Simpel: logo en een home link."
|
|
|
|
```tsx
|
|
<nav className="bg-white shadow-sm border-b border-gray-200">
|
|
<div className="container mx-auto px-4 py-4 flex items-center gap-8">
|
|
<Link
|
|
href="/"
|
|
className="text-2xl font-bold text-purple-600 hover:text-purple-700 transition-colors"
|
|
>
|
|
QuickPoll
|
|
</Link>
|
|
<Link
|
|
href="/"
|
|
className="text-gray-700 hover:text-purple-600 transition-colors font-medium"
|
|
>
|
|
Home
|
|
</Link>
|
|
</div>
|
|
</nav>
|
|
```
|
|
|
|
Main en footer:
|
|
|
|
```tsx
|
|
<main className="flex-1">{children}</main>
|
|
|
|
<footer className="bg-white border-t border-gray-200 mt-12">
|
|
<div className="container mx-auto px-4 py-6 text-center text-gray-600 text-sm">
|
|
© 2026 QuickPoll — Built with Next.js 15
|
|
</div>
|
|
</footer>
|
|
</body>
|
|
</html>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Vertel:** "`{children}` — hier komt de pagina-inhoud. Elke pagina in de app wordt hier ingeladen."
|
|
|
|
**Check:** localhost:3000 → navbar met "QuickPoll" zichtbaar
|
|
|
|
---
|
|
|
|
### 2b. Vervang `src/app/page.tsx`
|
|
|
|
**Vertel:** "Nu de homepage. Dit is een Server Component — geen 'use client', data direct ophalen."
|
|
|
|
Wis de inhoud. Typ:
|
|
|
|
```tsx
|
|
import Link from "next/link";
|
|
import { getPolls } from "@/lib/data";
|
|
```
|
|
|
|
**Vertel:** "We importeren getPolls uit onze data module. Dat `@/` is een shortcut voor de src/ folder."
|
|
|
|
```tsx
|
|
export default function Home() {
|
|
const polls = getPolls();
|
|
```
|
|
|
|
**Vertel:** "Kijk: we roepen getPolls() gewoon aan. Geen useEffect, geen loading state. Dit is een Server Component — data ophalen is direct."
|
|
|
|
De JSX:
|
|
|
|
```tsx
|
|
return (
|
|
<div className="container mx-auto py-12 px-4">
|
|
<div className="mb-12">
|
|
<h1 className="text-4xl font-bold text-gray-900">QuickPoll</h1>
|
|
<p className="text-gray-600 mt-2">
|
|
Kies een poll en stem af in een oogwenk
|
|
</p>
|
|
</div>
|
|
```
|
|
|
|
**Vertel:** "Nu de poll cards. We mappen over de polls array."
|
|
|
|
```tsx
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
{polls.map((poll) => {
|
|
const totalVotes = poll.votes.reduce((sum, v) => sum + v, 0);
|
|
|
|
return (
|
|
<Link key={poll.id} href={`/poll/${poll.id}`}>
|
|
<div className="bg-white rounded-lg border border-gray-200 p-6 hover:shadow-lg transition-shadow cursor-pointer h-full">
|
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
|
{poll.question}
|
|
</h2>
|
|
<div className="flex items-center justify-between text-sm text-gray-600">
|
|
<span>{poll.options.length} opties</span>
|
|
<span>{totalVotes} stemmen</span>
|
|
</div>
|
|
<div className="mt-4 text-purple-600 font-semibold text-sm">
|
|
Stemmen →
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Vertel:** "`.reduce()` telt alle stemmen op. En elke card linkt naar `/poll/1`, `/poll/2` etc. Die pagina maken we straks."
|
|
|
|
**Check:** localhost:3000 → 3 poll cards zichtbaar
|
|
|
|
**Checkpoint:** "Ziet iedereen 3 cards? Mooi."
|
|
|
|
---
|
|
|
|
## STAP 3 — GET API Route
|
|
|
|
### 3a. Maak `src/app/api/polls/[id]/route.ts`
|
|
|
|
**Vertel:** "Nu onze eerste API route. In Next.js is de folder-structuur je URL. Deze folder wordt `/api/polls/1`."
|
|
|
|
"Die `[id]` met vierkante haakjes — dat is een dynamic route. Het getal in de URL wordt de id parameter."
|
|
|
|
Typ:
|
|
|
|
```tsx
|
|
import { NextResponse } from "next/server";
|
|
import { getPollById } from "@/lib/data";
|
|
```
|
|
|
|
**Vertel:** "Een API route exporteert functies met HTTP method namen. GET voor data ophalen."
|
|
|
|
```tsx
|
|
export async function GET(
|
|
request: Request,
|
|
{ params }: { params: Promise<{ id: string }> }
|
|
) {
|
|
```
|
|
|
|
**Vertel:** "Let op: `Promise<{ id: string }>`. In Next.js 15 zijn params een Promise. Vergeet de await niet!"
|
|
|
|
```tsx
|
|
const { id } = await params;
|
|
const poll = getPollById(id);
|
|
```
|
|
|
|
"Als de poll niet bestaat: 404."
|
|
|
|
```tsx
|
|
if (!poll) {
|
|
return NextResponse.json({ error: "Poll not found" }, { status: 404 });
|
|
}
|
|
```
|
|
|
|
"Anders: stuur de poll als JSON."
|
|
|
|
```tsx
|
|
return NextResponse.json(poll);
|
|
}
|
|
```
|
|
|
|
**Test:** Open browser → `localhost:3000/api/polls/1` → JSON!
|
|
|
|
**Vertel:** "Probeer ook `/api/polls/999` — daar krijg je de 404."
|
|
|
|
**Checkpoint:** "Werkt de API? Recap klaar. Nu het nieuwe werk."
|
|
|
|
---
|
|
|
|
## STAP 4 — POST Vote Route
|
|
|
|
### 4a. Maak `src/app/api/polls/[id]/vote/route.ts`
|
|
|
|
**Vertel:** "Tot nu toe hadden we GET — data ophalen. Nu POST — data wijzigen. In dit geval: stemmen."
|
|
|
|
Typ de imports:
|
|
|
|
```tsx
|
|
import { NextResponse } from "next/server";
|
|
import { votePoll } from "@/lib/data";
|
|
```
|
|
|
|
**Vertel:** "Eerst definiëren we types voor de parameters en de request body."
|
|
|
|
```tsx
|
|
interface RouteParams {
|
|
params: Promise<{ id: string }>;
|
|
}
|
|
|
|
interface VoteBody {
|
|
optionIndex: number;
|
|
}
|
|
```
|
|
|
|
**Vertel:** "Een POST route volgt altijd vijf stappen: params, body, validatie, actie, response."
|
|
|
|
```tsx
|
|
export async function POST(
|
|
request: Request,
|
|
{ params }: RouteParams
|
|
): Promise<NextResponse> {
|
|
```
|
|
|
|
"Stap 1: params uitlezen — welke poll?"
|
|
|
|
```tsx
|
|
const { id } = await params;
|
|
```
|
|
|
|
"Stap 2: body uitlezen — welke optie? `request.json()` leest wat de client meestuurt."
|
|
|
|
```tsx
|
|
const body: VoteBody = await request.json();
|
|
```
|
|
|
|
"Stap 3: validatie — is optionIndex een nummer?"
|
|
|
|
```tsx
|
|
if (typeof body.optionIndex !== "number") {
|
|
return NextResponse.json(
|
|
{ error: "optionIndex is verplicht" },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
```
|
|
|
|
**Vertel:** "400 = bad request. Jij stuurde slechte data."
|
|
|
|
"Stap 4: actie uitvoeren."
|
|
|
|
```tsx
|
|
const updatedPoll = votePoll(id, body.optionIndex);
|
|
|
|
if (!updatedPoll) {
|
|
return NextResponse.json(
|
|
{ error: "Poll niet gevonden of ongeldige optie" },
|
|
{ status: 404 }
|
|
);
|
|
}
|
|
```
|
|
|
|
**Vertel:** "404 = niet gevonden. Twee checks: poll bestaat niet, of optie-index ongeldig."
|
|
|
|
"Stap 5: response sturen."
|
|
|
|
```tsx
|
|
return NextResponse.json(updatedPoll);
|
|
}
|
|
```
|
|
|
|
### 4b. Testen in de browser console
|
|
|
|
**Vertel:** "We testen! Open DevTools (F12), ga naar Console, en plak dit:"
|
|
|
|
```javascript
|
|
fetch('/api/polls/1/vote', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ optionIndex: 0 })
|
|
}).then(r => r.json()).then(console.log)
|
|
```
|
|
|
|
**Vertel:** "Zie je? De votes zijn veranderd in de JSON. De API werkt."
|
|
|
|
**Checkpoint:** "Heeft iedereen een JSON resultaat in de console?"
|
|
|
|
---
|
|
|
|
## STAP 5 — Poll Detail Pagina
|
|
|
|
### 5a. Maak `src/app/poll/[id]/page.tsx`
|
|
|
|
**Vertel:** "Nu de pagina waar je op een poll stemt. Weer een dynamic route: `/poll/1`, `/poll/2` etc."
|
|
|
|
Imports:
|
|
|
|
```tsx
|
|
import { notFound } from "next/navigation";
|
|
import { getPollById } from "@/lib/data";
|
|
import VoteForm from "@/components/VoteForm";
|
|
import type { Metadata } from "next";
|
|
```
|
|
|
|
**Vertel:** "We importeren VoteForm — dat component bestaat nog niet, dat bouwen we zo."
|
|
|
|
Type:
|
|
|
|
```tsx
|
|
interface PageProps {
|
|
params: Promise<{ id: string }>;
|
|
}
|
|
```
|
|
|
|
**Vertel:** "Eerste nieuw concept: `generateMetadata`. Hiermee krijgt elke poll z'n eigen titel in de browser tab."
|
|
|
|
```tsx
|
|
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(", ")}`,
|
|
};
|
|
}
|
|
```
|
|
|
|
**Vertel:** "Gratis SEO. Google ziet 'Wat is de beste editor?' in plaats van gewoon 'QuickPoll'."
|
|
|
|
Nu de pagina zelf:
|
|
|
|
```tsx
|
|
export default async function PollPage({ params }: PageProps) {
|
|
const { id } = await params;
|
|
const poll = getPollById(id);
|
|
```
|
|
|
|
**Vertel:** "Tweede concept: `notFound()`. Als de poll niet bestaat, roep je dit aan. Next.js toont dan automatisch een 404 pagina."
|
|
|
|
```tsx
|
|
if (!poll) {
|
|
notFound();
|
|
}
|
|
```
|
|
|
|
"En we renderen de poll titel en het VoteForm component:"
|
|
|
|
```tsx
|
|
return (
|
|
<div className="max-w-2xl mx-auto py-12 px-4">
|
|
<h1 className="text-2xl font-bold text-gray-900 mb-6">
|
|
{poll.question}
|
|
</h1>
|
|
<VoteForm poll={poll} />
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Vertel:** "Dit is een Server Component die een Client Component rendert. Server haalt data, client doet interactie. Dat is de kern van Next.js."
|
|
|
|
**Check:** localhost:3000/poll/1 → error (VoteForm bestaat nog niet, dat klopt!)
|
|
|
|
---
|
|
|
|
## ☕ PAUZE — 15 minuten
|
|
|
|
**Vertel:** "Pauze! Stap 0-5 staan er. Na de pauze bouwen we het interactieve hart: de VoteForm."
|
|
|
|
---
|
|
|
|
## STAP 6 — VoteForm Component
|
|
|
|
### 6a. Maak `src/components/VoteForm.tsx`
|
|
|
|
**Vertel:** "Dit is het leukste stuk. Een Client Component met interactiviteit."
|
|
|
|
"Eerste regel: `'use client'`. Daarmee zeg je tegen Next.js: dit draait in de browser. Alles met useState of onClick MOET dit hebben."
|
|
|
|
```tsx
|
|
"use client";
|
|
```
|
|
|
|
Imports:
|
|
|
|
```tsx
|
|
import { useState } from "react";
|
|
import type { Poll } from "@/types";
|
|
```
|
|
|
|
Props interface:
|
|
|
|
```tsx
|
|
interface VoteFormProps {
|
|
poll: Poll;
|
|
}
|
|
```
|
|
|
|
**Vertel:** "We ontvangen een poll als prop van de Server Component."
|
|
|
|
Functie openen:
|
|
|
|
```tsx
|
|
export default function VoteForm({ poll }: VoteFormProps) {
|
|
```
|
|
|
|
### 6b. State variabelen
|
|
|
|
**Vertel:** "We hebben vier stukken state nodig. Ik leg ze één voor één uit."
|
|
|
|
"Welke optie is geselecteerd? `null` want er kan nog niks geselecteerd zijn:"
|
|
|
|
```tsx
|
|
const [selectedOption, setSelectedOption] = useState<number | null>(null);
|
|
```
|
|
|
|
"Is er al gestemd?"
|
|
|
|
```tsx
|
|
const [hasVoted, setHasVoted] = useState<boolean>(false);
|
|
```
|
|
|
|
"Zijn we bezig met het versturen? Om dubbel klikken te voorkomen:"
|
|
|
|
```tsx
|
|
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
|
```
|
|
|
|
"De huidige poll data — die updaten we na het stemmen:"
|
|
|
|
```tsx
|
|
const [currentPoll, setCurrentPoll] = useState<Poll>(poll);
|
|
```
|
|
|
|
**Checkpoint:** "Heeft iedereen de vier useState regels?"
|
|
|
|
### 6c. Helper functies
|
|
|
|
**Vertel:** "Totaal stemmen berekenen:"
|
|
|
|
```tsx
|
|
const totalVotes: number = currentPoll.votes.reduce(
|
|
(sum, v) => sum + v,
|
|
0
|
|
);
|
|
```
|
|
|
|
"Percentage per optie:"
|
|
|
|
```tsx
|
|
function getPercentage(votes: number): number {
|
|
if (totalVotes === 0) return 0;
|
|
return Math.round((votes / totalVotes) * 100);
|
|
}
|
|
```
|
|
|
|
### 6d. handleVote functie
|
|
|
|
**Vertel:** "Nu de stem-functie. Dit is dezelfde fetch als we net in de console testten — maar nu in een component."
|
|
|
|
```tsx
|
|
async function handleVote(): Promise<void> {
|
|
```
|
|
|
|
"Guard clause: als er niks geselecteerd is of we al bezig zijn, stop."
|
|
|
|
```tsx
|
|
if (selectedOption === null || isSubmitting) return;
|
|
setIsSubmitting(true);
|
|
```
|
|
|
|
"De fetch naar onze POST route:"
|
|
|
|
```tsx
|
|
const response = await fetch(`/api/polls/${currentPoll.id}/vote`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ optionIndex: selectedOption }),
|
|
});
|
|
```
|
|
|
|
"Als het lukt: update de poll data en zet hasVoted op true."
|
|
|
|
```tsx
|
|
if (response.ok) {
|
|
const updatedPoll: Poll = await response.json();
|
|
setCurrentPoll(updatedPoll);
|
|
setHasVoted(true);
|
|
}
|
|
|
|
setIsSubmitting(false);
|
|
}
|
|
```
|
|
|
|
**Checkpoint:** "Tot hier mee? Dan gaan we de UI bouwen."
|
|
|
|
### 6e. UI — De opties buttons
|
|
|
|
**Vertel:** "De return. We mappen over de opties — elke optie wordt een button."
|
|
|
|
```tsx
|
|
return (
|
|
<div className="space-y-3">
|
|
{currentPoll.options.map((option, index) => {
|
|
const percentage = getPercentage(currentPoll.votes[index]);
|
|
const isSelected = selectedOption === index;
|
|
```
|
|
|
|
**Vertel:** "Elke button. onClick selecteert de optie, maar alleen als je nog niet gestemd hebt."
|
|
|
|
```tsx
|
|
return (
|
|
<button
|
|
key={index}
|
|
onClick={() => !hasVoted && setSelectedOption(index)}
|
|
disabled={hasVoted}
|
|
```
|
|
|
|
**Vertel:** "Nu de conditional classes. Dit is hoe je in Tailwind conditioneel stylet — ternary operators."
|
|
|
|
```tsx
|
|
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"
|
|
}`}
|
|
>
|
|
```
|
|
|
|
**Vertel:** "De percentage bar. Een div die de achtergrond paars vult. De breedte is het percentage."
|
|
|
|
```tsx
|
|
{hasVoted && (
|
|
<div
|
|
className="absolute inset-0 bg-purple-100 transition-all duration-500"
|
|
style={{ width: `${percentage}%` }}
|
|
/>
|
|
)}
|
|
```
|
|
|
|
**Vertel:** "`transition-all duration-500` zorgt voor de animatie. Pure CSS via Tailwind."
|
|
|
|
De tekst met percentage:
|
|
|
|
```tsx
|
|
<div className="relative flex justify-between items-center">
|
|
<span className="font-medium">{option}</span>
|
|
{hasVoted && (
|
|
<span className="text-sm font-semibold text-purple-700">
|
|
{percentage}%
|
|
</span>
|
|
)}
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
```
|
|
|
|
### 6f. UI — Stem button en bedankt bericht
|
|
|
|
**Vertel:** "De stem-knop. Alleen zichtbaar als je nog niet gestemd hebt."
|
|
|
|
```tsx
|
|
{!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 transition-colors mt-4"
|
|
>
|
|
{isSubmitting ? "Bezig met stemmen..." : "Stem!"}
|
|
</button>
|
|
)}
|
|
```
|
|
|
|
"En het bedankt bericht na het stemmen:"
|
|
|
|
```tsx
|
|
{hasVoted && (
|
|
<p className="text-center text-green-600 font-medium mt-4">
|
|
Bedankt voor je stem! Totaal: {totalVotes} stemmen
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
### 6g. Testen!
|
|
|
|
**Test:** localhost:3000/poll/1
|
|
|
|
**Vertel:** "Klik op een optie — paarse border. Klik Stem. Zie je de animatie? De bars vullen zich."
|
|
|
|
**Loop rond. Dit is het lastigste stuk. Neem 10 min extra als nodig.**
|
|
|
|
**Veelvoorkomende fouten:**
|
|
- `"use client"` vergeten → crash
|
|
- Fetch URL zonder backticks → werkt niet
|
|
- Bestand niet opgeslagen → niks veranderd
|
|
|
|
---
|
|
|
|
## STAP 7 — Loading, Error & Not-Found
|
|
|
|
**Vertel:** "In gewoon React bouw je dit zelf. In Next.js: maak een bestand aan en het werkt."
|
|
|
|
### 7a. Maak `src/app/loading.tsx`
|
|
|
|
**Vertel:** "Skeleton loading. Die grijze blokken die pulseren — `animate-pulse` is een Tailwind class."
|
|
|
|
```tsx
|
|
export default function Loading() {
|
|
return (
|
|
<div className="container mx-auto py-12 px-4 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" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
### 7b. Maak `src/app/error.tsx`
|
|
|
|
**Vertel:** "Error boundary. LET OP: `'use client'` is hier VERPLICHT. Dat is een Next.js vereiste."
|
|
|
|
```tsx
|
|
"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"
|
|
>
|
|
Probeer opnieuw
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Vertel:** "`reset` herlaadt het component dat faalde. Zo hoeft de user niet de hele pagina te refreshen."
|
|
|
|
### 7c. Maak `src/app/not-found.tsx`
|
|
|
|
```tsx
|
|
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.</p>
|
|
<Link
|
|
href="/"
|
|
className="bg-purple-600 text-white px-6 py-3 rounded-lg hover:bg-purple-700 inline-block"
|
|
>
|
|
Terug naar home
|
|
</Link>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Test:** localhost:3000/poll/999 → 404 pagina!
|
|
|
|
**Vertel:** "Dat werkt door de `notFound()` aanroep in onze poll pagina."
|
|
|
|
**Checkpoint:** "Alle drie werkend?"
|
|
|
|
---
|
|
|
|
## STAP 8 — Middleware
|
|
|
|
### 8a. Maak `src/middleware.ts`
|
|
|
|
⚠️ **Let op locatie: `src/middleware.ts` — NIET in app/, NIET in een subfolder!**
|
|
|
|
**Vertel:** "Middleware is als een portier. Elke request passeert hier eerst. Nu gebruiken we het voor logging — later bij Supabase voor authenticatie."
|
|
|
|
```tsx
|
|
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;
|
|
}
|
|
```
|
|
|
|
**Vertel:** "`NextResponse.next()` betekent: alles oké, ga door naar de route."
|
|
|
|
"De matcher bepaalt welke routes de middleware raakt:"
|
|
|
|
```tsx
|
|
export const config = {
|
|
matcher: ["/api/:path*", "/poll/:path*"],
|
|
};
|
|
```
|
|
|
|
**Vertel:** "Alleen API routes en poll pagina's. De homepage skipt de middleware."
|
|
|
|
### 8b. Testen
|
|
|
|
**Vertel:** "Open je terminal waar `npm run dev` draait. Klik op een poll in de browser."
|
|
|
|
**In de terminal:** `[GET] /poll/1` verschijnt.
|
|
|
|
**Vertel:** "En in DevTools: Network tab → klik op een request → Headers → `x-request-time`. Dat is de verwerkingstijd die onze middleware toevoegt."
|
|
|
|
---
|
|
|
|
## ✅ APP COMPLEET!
|
|
|
|
**Vertel:** "De hele app werkt. Van nul naar werkend in één les."
|
|
|
|
---
|
|
|
|
## BONUS — Nieuwe Poll Aanmaken (als je tijd overhebt)
|
|
|
|
> ~20 minuten. Doe dit klassikaal als je voor 2:45 klaar bent.
|
|
|
|
**Vertel:** "We gaan een pagina maken waar je zelf een nieuwe poll kunt aanmaken. Twee dingen nodig: een API route die polls aanmaakt, en een pagina met een formulier."
|
|
|
|
### Bonus A. Voeg `createPoll` toe aan `src/lib/data.ts`
|
|
|
|
**Vertel:** "Eerst een helper functie in onze data module. Open `data.ts` en voeg onderaan toe:"
|
|
|
|
```tsx
|
|
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;
|
|
}
|
|
```
|
|
|
|
**Vertel:** "`new Array(options.length).fill(0)` — maakt een array met nullen, even lang als het aantal opties. Elke optie begint met 0 stemmen."
|
|
|
|
### Bonus B. Maak `src/app/api/polls/route.ts`
|
|
|
|
**Vertel:** "Nu de API route. Let op: dit is `api/polls/route.ts`, niet in de `[id]` folder. Dit wordt `POST /api/polls`."
|
|
|
|
```tsx
|
|
import { NextResponse } from "next/server";
|
|
import { createPoll } from "@/lib/data";
|
|
```
|
|
|
|
**Vertel:** "We definiëren een type voor de request body."
|
|
|
|
```tsx
|
|
interface CreatePollBody {
|
|
question: string;
|
|
options: string[];
|
|
}
|
|
```
|
|
|
|
"De POST handler:"
|
|
|
|
```tsx
|
|
export async function POST(request: Request): Promise<NextResponse> {
|
|
```
|
|
|
|
"Body uitlezen:"
|
|
|
|
```tsx
|
|
const body: CreatePollBody = await request.json();
|
|
```
|
|
|
|
"Validatie — vraag moet er zijn, en minimaal 2 opties:"
|
|
|
|
```tsx
|
|
if (!body.question || !body.options || body.options.length < 2) {
|
|
return NextResponse.json(
|
|
{ error: "Vraag en minimaal 2 opties zijn verplicht" },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
```
|
|
|
|
"Poll aanmaken en teruggeven:"
|
|
|
|
```tsx
|
|
const newPoll = createPoll(body.question, body.options);
|
|
return NextResponse.json(newPoll, { status: 201 });
|
|
}
|
|
```
|
|
|
|
**Vertel:** "201 = created. Standaard HTTP status als je iets nieuws aanmaakt."
|
|
|
|
### Bonus C. Maak `src/app/create/page.tsx`
|
|
|
|
**Vertel:** "Nu het formulier. Dit is een Client Component — we hebben state nodig voor de inputs."
|
|
|
|
```tsx
|
|
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
```
|
|
|
|
**Vertel:** "`useRouter` is voor navigatie vanuit een Client Component. Straks sturen we de user terug naar home."
|
|
|
|
```tsx
|
|
export default function CreatePollPage() {
|
|
const router = useRouter();
|
|
const [question, setQuestion] = useState("");
|
|
const [options, setOptions] = useState(["", ""]);
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
```
|
|
|
|
**Vertel:** "Drie stukken state. De vraag, de opties (begint met 2 lege strings), en een submit flag."
|
|
|
|
"Een functie om een optie toe te voegen:"
|
|
|
|
```tsx
|
|
function addOption() {
|
|
setOptions([...options, ""]);
|
|
}
|
|
```
|
|
|
|
"En een functie om een optie te updaten op een specifieke index:"
|
|
|
|
```tsx
|
|
function updateOption(index: number, value: string) {
|
|
const newOptions = [...options];
|
|
newOptions[index] = value;
|
|
setOptions(newOptions);
|
|
}
|
|
```
|
|
|
|
**Vertel:** "Nu de submit functie. Filtert lege opties weg, POST naar de API, redirect naar home."
|
|
|
|
```tsx
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
setIsSubmitting(true);
|
|
|
|
const filledOptions = options.filter((opt) => opt.trim() !== "");
|
|
|
|
if (!question.trim() || filledOptions.length < 2) {
|
|
alert("Vul een vraag in en minimaal 2 opties.");
|
|
setIsSubmitting(false);
|
|
return;
|
|
}
|
|
|
|
const response = await fetch("/api/polls", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ question, options: filledOptions }),
|
|
});
|
|
|
|
if (response.ok) {
|
|
router.push("/");
|
|
}
|
|
|
|
setIsSubmitting(false);
|
|
}
|
|
```
|
|
|
|
**Vertel:** "`e.preventDefault()` voorkomt dat de pagina herlaadt. `router.push('/')` stuurt je terug naar de homepage."
|
|
|
|
"Nu de JSX. Een form met inputs:"
|
|
|
|
```tsx
|
|
return (
|
|
<div className="max-w-2xl mx-auto py-12 px-4">
|
|
<h1 className="text-2xl font-bold text-gray-900 mb-6">
|
|
Nieuwe Poll Aanmaken
|
|
</h1>
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-6">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Vraag
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={question}
|
|
onChange={(e) => setQuestion(e.target.value)}
|
|
placeholder="Stel je vraag..."
|
|
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
|
/>
|
|
</div>
|
|
```
|
|
|
|
"De opties — we mappen over de options array:"
|
|
|
|
```tsx
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Opties
|
|
</label>
|
|
<div className="space-y-3">
|
|
{options.map((option, index) => (
|
|
<input
|
|
key={index}
|
|
type="text"
|
|
value={option}
|
|
onChange={(e) => updateOption(index, e.target.value)}
|
|
placeholder={`Optie ${index + 1}`}
|
|
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
|
/>
|
|
))}
|
|
</div>
|
|
```
|
|
|
|
"Knop om meer opties toe te voegen:"
|
|
|
|
```tsx
|
|
<button
|
|
type="button"
|
|
onClick={addOption}
|
|
className="mt-3 text-purple-600 hover:text-purple-700 font-medium text-sm"
|
|
>
|
|
+ Optie toevoegen
|
|
</button>
|
|
</div>
|
|
```
|
|
|
|
"En de submit knop:"
|
|
|
|
```tsx
|
|
<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 transition-colors"
|
|
>
|
|
{isSubmitting ? "Aanmaken..." : "Poll Aanmaken"}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
### Bonus D. Link toevoegen in layout
|
|
|
|
**Vertel:** "Nog even een link in de navbar. Open `layout.tsx` en voeg toe naast de Home link:"
|
|
|
|
```tsx
|
|
<Link
|
|
href="/create"
|
|
className="text-gray-700 hover:text-purple-600 transition-colors font-medium"
|
|
>
|
|
Nieuwe Poll
|
|
</Link>
|
|
```
|
|
|
|
### Bonus E. Testen!
|
|
|
|
**Test:** localhost:3000/create → vul een vraag in → voeg opties toe → klik aanmaken → redirect naar home → nieuwe poll staat er!
|
|
|
|
**Vertel:** "Klaar! De hele app is compleet. Je kunt polls bekijken, stemmen, en nieuwe polls aanmaken."
|
|
|
|
---
|
|
|
|
## ✅ HELEMAAL KLAAR!
|
|
|
|
**Samenvatting wat we gebouwd hebben:**
|
|
1. Types & data (TypeScript interfaces, in-memory database)
|
|
2. Layout & homepage (Server Component, Link navigatie)
|
|
3. GET API route (folder = URL, dynamic routes)
|
|
4. POST API route (stemmen, validatie, status codes)
|
|
5. Poll detail pagina (generateMetadata, notFound)
|
|
6. VoteForm (Client Component, useState, fetch, animaties)
|
|
7. Loading, Error, Not-Found (speciale bestanden)
|
|
8. Middleware (request interceptie)
|
|
9. Bonus: Create poll (form, POST, redirect)
|
|
|
|
**De Next.js flow:**
|
|
Route (folder) → Page (server) → Client Component (interactie) → API Route (data) → Response
|
|
|
|
**Volgende les:** Tailwind CSS & shadcn/ui
|