final lessons
This commit is contained in:
197
Les06-NextJS-QuickPoll-Part2/Les06-Lesopdracht.md
Normal file
197
Les06-NextJS-QuickPoll-Part2/Les06-Lesopdracht.md
Normal file
@@ -0,0 +1,197 @@
|
||||
# Les 6 — Lesopdracht
|
||||
## QuickPoll Part 2 — Server Actions + polish
|
||||
|
||||
**Duur:** ~120 minuten klassikaal
|
||||
|
||||
---
|
||||
|
||||
## Doel
|
||||
|
||||
Aan het einde heeft QuickPoll:
|
||||
- Server actions voor vote submission
|
||||
- Een /create pagina voor nieuwe polls
|
||||
- Loading + error states
|
||||
- Optimistic updates
|
||||
|
||||
---
|
||||
|
||||
## Stap 1 — Server Action voor voting
|
||||
|
||||
`app/poll/[id]/actions.ts`:
|
||||
|
||||
```typescript
|
||||
"use server";
|
||||
import { polls } from "@/lib/data";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export async function vote(pollId: string, optionId: string) {
|
||||
const poll = polls.find(p => p.id === pollId);
|
||||
if (!poll) throw new Error("Poll niet gevonden");
|
||||
|
||||
const option = poll.options.find(o => o.id === optionId);
|
||||
if (!option) throw new Error("Optie niet gevonden");
|
||||
|
||||
option.votes += 1;
|
||||
revalidatePath(`/poll/${pollId}`);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stap 2 — VoteForm met server action
|
||||
|
||||
Refactor `components/VoteForm.tsx` om `vote()` server action te gebruiken in plaats van useState:
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
import { vote } from "@/app/poll/[id]/actions";
|
||||
import { useFormStatus } from "react-dom";
|
||||
|
||||
function VoteButton({ option }) {
|
||||
const { pending } = useFormStatus();
|
||||
return (
|
||||
<button
|
||||
disabled={pending}
|
||||
className="w-full text-left p-3 border rounded hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
{option.text} ({option.votes})
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function VoteForm({ poll }) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{poll.options.map(o => (
|
||||
<form
|
||||
key={o.id}
|
||||
action={vote.bind(null, poll.id, o.id)}
|
||||
>
|
||||
<VoteButton option={o} />
|
||||
</form>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stap 3 — /create pagina
|
||||
|
||||
`app/create/page.tsx`:
|
||||
|
||||
```tsx
|
||||
import { createPoll } from "./actions";
|
||||
|
||||
export default function CreatePage() {
|
||||
return (
|
||||
<main className="max-w-md mx-auto p-8">
|
||||
<h1 className="text-2xl font-bold mb-4">Nieuwe poll</h1>
|
||||
<form action={createPoll} className="space-y-4">
|
||||
<input name="title" required placeholder="Vraag" className="w-full border p-2 rounded" />
|
||||
<textarea name="options" required placeholder="Opties, één per regel" rows={4} className="w-full border p-2 rounded" />
|
||||
<button className="bg-blue-600 text-white px-4 py-2 rounded">Aanmaken</button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
`app/create/actions.ts`:
|
||||
|
||||
```typescript
|
||||
"use server";
|
||||
import { polls } from "@/lib/data";
|
||||
import { redirect } from "next/navigation";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export async function createPoll(formData: FormData) {
|
||||
const title = formData.get("title") as string;
|
||||
const optionsText = formData.get("options") as string;
|
||||
const options = optionsText.split("\n").map(t => t.trim()).filter(Boolean);
|
||||
|
||||
if (!title || options.length < 2) {
|
||||
throw new Error("Vul titel + min 2 opties in");
|
||||
}
|
||||
|
||||
const newPoll = {
|
||||
id: String(polls.length + 1),
|
||||
title,
|
||||
options: options.map((text, i) => ({
|
||||
id: String.fromCharCode(97 + i),
|
||||
text,
|
||||
votes: 0,
|
||||
})),
|
||||
};
|
||||
|
||||
polls.push(newPoll);
|
||||
revalidatePath("/");
|
||||
redirect(`/poll/${newPoll.id}`);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stap 4 — Loading + Error states
|
||||
|
||||
`app/poll/[id]/loading.tsx`:
|
||||
```tsx
|
||||
export default function Loading() {
|
||||
return (
|
||||
<main className="max-w-md mx-auto p-8 space-y-4">
|
||||
<div className="h-8 bg-gray-200 rounded animate-pulse w-3/4" />
|
||||
<div className="space-y-2">
|
||||
{[1,2,3].map(i => (
|
||||
<div key={i} className="h-12 bg-gray-200 rounded animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
`app/poll/[id]/error.tsx`:
|
||||
```tsx
|
||||
"use client";
|
||||
export default function Error({ error, reset }) {
|
||||
return (
|
||||
<main className="max-w-md mx-auto p-8">
|
||||
<p className="text-red-600">Oeps: {error.message}</p>
|
||||
<button onClick={reset} className="text-blue-600 underline">Probeer opnieuw</button>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stap 5 — Polish
|
||||
|
||||
- Voeg op homepage een "Nieuwe poll maken" knop naar `/create`
|
||||
- Voeg "Terug" link op detail-page
|
||||
- Maak buttons mooier (rounded, transitions, focus rings)
|
||||
|
||||
---
|
||||
|
||||
## Eisen
|
||||
|
||||
- [ ] Voting werkt via Server Action (geen lokaal useState meer)
|
||||
- [ ] /create pagina werkt en redirect na submit
|
||||
- [ ] Loading state zichtbaar bij langzaam internet (DevTools throttling)
|
||||
- [ ] Error state zichtbaar als je expres een error throw't
|
||||
- [ ] Polish: alles voelt netjes af
|
||||
|
||||
---
|
||||
|
||||
## Bonus
|
||||
|
||||
- `useOptimistic` toevoegen aan VoteForm voor instant feedback
|
||||
- Toast notification na vote (gebruik `sonner`)
|
||||
- Animatie op vote-count change
|
||||
|
||||
---
|
||||
|
||||
## Klaar?
|
||||
|
||||
Push naar GitHub. Volgende les: vervang in-memory data door echte Supabase database.
|
||||
Reference in New Issue
Block a user