241 lines
5.4 KiB
Markdown
241 lines
5.4 KiB
Markdown
# Les 8 — Lesopdracht
|
|
## CRUD + voting werkend met Supabase
|
|
|
|
**Duur:** ~120 minuten klassikaal
|
|
|
|
---
|
|
|
|
## Doel
|
|
|
|
Aan het einde: QuickPoll volledig werkend tegen Supabase. Polls aanmaken via /create, stemmen werkt persistent, alles in database.
|
|
|
|
---
|
|
|
|
## Stap 1 — /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>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Stap 2 — Create action
|
|
|
|
`app/create/actions.ts`:
|
|
|
|
```typescript
|
|
"use server";
|
|
import { supabase } from "@/lib/supabase";
|
|
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");
|
|
}
|
|
|
|
// Insert poll
|
|
const { data: poll, error: pollErr } = await supabase
|
|
.from("polls")
|
|
.insert({ title })
|
|
.select()
|
|
.single();
|
|
if (pollErr) throw new Error(pollErr.message);
|
|
|
|
// Insert opties
|
|
const { error: optErr } = await supabase
|
|
.from("options")
|
|
.insert(options.map(text => ({ poll_id: poll.id, text })));
|
|
if (optErr) throw new Error(optErr.message);
|
|
|
|
revalidatePath("/");
|
|
redirect(`/poll/${poll.id}`);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Stap 3 — RPC function voor voting
|
|
|
|
In Supabase SQL Editor:
|
|
|
|
```sql
|
|
create or replace function increment_vote(option_id_input bigint)
|
|
returns void as $$
|
|
update options
|
|
set votes = votes + 1
|
|
where id = option_id_input;
|
|
$$ language sql;
|
|
```
|
|
|
|
Test:
|
|
```sql
|
|
select increment_vote(1);
|
|
select * from options where id = 1;
|
|
-- votes moet +1 zijn
|
|
```
|
|
|
|
---
|
|
|
|
## Stap 4 — Vote action + VoteForm
|
|
|
|
`app/poll/[id]/actions.ts`:
|
|
|
|
```typescript
|
|
"use server";
|
|
import { supabase } from "@/lib/supabase";
|
|
import { revalidatePath } from "next/cache";
|
|
|
|
export async function vote(pollId: number, optionId: number) {
|
|
const { error } = await supabase.rpc("increment_vote", {
|
|
option_id_input: optionId,
|
|
});
|
|
if (error) throw new Error(error.message);
|
|
|
|
revalidatePath(`/poll/${pollId}`);
|
|
}
|
|
```
|
|
|
|
`components/VoteForm.tsx`:
|
|
|
|
```tsx
|
|
"use client";
|
|
import { vote } from "@/app/poll/[id]/actions";
|
|
import { useFormStatus } from "react-dom";
|
|
|
|
function VoteButton({ option, pollId }) {
|
|
const { pending } = useFormStatus();
|
|
return (
|
|
<form action={vote.bind(null, pollId, option.id)}>
|
|
<button
|
|
disabled={pending}
|
|
className="w-full text-left p-3 border rounded hover:bg-gray-50 disabled:opacity-50 flex justify-between"
|
|
>
|
|
<span>{option.text}</span>
|
|
<span className="text-gray-500">{option.votes}</span>
|
|
</button>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
export function VoteForm({ poll }) {
|
|
return (
|
|
<div className="space-y-2">
|
|
{poll.options.map(o => (
|
|
<VoteButton key={o.id} option={o} pollId={poll.id} />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Stap 5 — Detail-pagina updaten
|
|
|
|
```tsx
|
|
// app/poll/[id]/page.tsx
|
|
import { supabase } from "@/lib/supabase";
|
|
import { notFound } from "next/navigation";
|
|
import { VoteForm } from "@/components/VoteForm";
|
|
|
|
export default async function PollPage({
|
|
params,
|
|
}: { params: Promise<{ id: string }> }) {
|
|
const { id } = await params;
|
|
const { data: poll } = await supabase
|
|
.from("polls")
|
|
.select("*, options(*)")
|
|
.eq("id", Number(id))
|
|
.single();
|
|
|
|
if (!poll) notFound();
|
|
|
|
return (
|
|
<main className="max-w-md mx-auto p-8">
|
|
<h1 className="text-2xl font-bold mb-4">{poll.title}</h1>
|
|
<VoteForm poll={poll} />
|
|
</main>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Stap 6 — Test
|
|
|
|
1. Open `/create` → maak een nieuwe poll
|
|
2. Wordt geredirect naar `/poll/[nieuw-id]`
|
|
3. Klik op een optie → vote-count gaat omhoog
|
|
4. Refresh → vote blijft (persistent!)
|
|
5. Open Supabase Table Editor → zie de vote in DB
|
|
|
|
---
|
|
|
|
## Eisen
|
|
|
|
- [ ] Polls aanmaken werkt via /create
|
|
- [ ] Stemmen werkt en persistent
|
|
- [ ] Server actions met Supabase
|
|
- [ ] RPC function `increment_vote` bestaat
|
|
- [ ] Geen errors in console
|
|
- [ ] Vote-count update direct na klik (revalidatePath werkt)
|
|
|
|
---
|
|
|
|
## Bonus
|
|
|
|
- **Optimistic UI** — `useOptimistic` voor instant vote-feedback
|
|
- **Empty state** — als geen polls: mooie CTA naar /create
|
|
- **Disable vote per session** — sla in localStorage op wie heeft gestemd
|
|
- **Total votes** — toon onderaan totaal aantal stemmen
|
|
|
|
---
|
|
|
|
## Veelvoorkomende problemen
|
|
|
|
| Probleem | Oplossing |
|
|
|----------|-----------|
|
|
| "function increment_vote does not exist" | RPC nog niet aangemaakt — Stap 3 |
|
|
| Vote-count update niet | `revalidatePath` ontbreekt of verkeerde path |
|
|
| Form submit doet niets | `"use server"` mist in actions.ts |
|
|
| Foreign key error op options | Poll moet eerst inserten, dan opties |
|
|
|
|
---
|
|
|
|
## Klaar?
|
|
|
|
Volgende les: Supabase Auth — gebruikers laten inloggen.
|