feat: configureerbare itemnamen per vragenlijst

Voeg item_label en item_label_plural toe aan questionnaires (migratie),
beheerformulier en dynamische teksten in UI en API-fouten. Standaard
blijft Activiteit/Activiteiten.

Negeer tsbuildinfo en geëmitteerde vite.config.js/.d.ts in .gitignore.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-10 15:29:44 +02:00
parent 6871126788
commit 93aedf319b
9 changed files with 234 additions and 42 deletions

5
.gitignore vendored
View File

@@ -1,5 +1,10 @@
node_modules/ node_modules/
dist/ dist/
# tsc -b (project references) + emitted vite config
*.tsbuildinfo
vite.config.js
vite.config.d.ts
data/ data/
*.db *.db
.env .env

View File

@@ -28,6 +28,10 @@ export interface User {
created_at: string; created_at: string;
} }
/** Default labels for backwards compatibility and empty input */
export const DEFAULT_ITEM_LABEL = 'Activiteit';
export const DEFAULT_ITEM_LABEL_PLURAL = 'Activiteiten';
export interface Questionnaire { export interface Questionnaire {
id: number; id: number;
uuid: string; uuid: string;
@@ -38,10 +42,22 @@ export interface Questionnaire {
is_private: boolean; is_private: boolean;
created_by: number; created_by: number;
created_at: string; created_at: string;
item_label: string | null;
item_label_plural: string | null;
creator_name?: string; creator_name?: string;
activity_count?: number; activity_count?: number;
} }
/** Resolved singular/plural labels for UI and API messages (max 60 chars each). */
export function resolveItemLabels(q: {
item_label?: string | null;
item_label_plural?: string | null;
}): { singular: string; plural: string } {
const singular = (q.item_label?.trim() || DEFAULT_ITEM_LABEL).slice(0, 60);
const plural = (q.item_label_plural?.trim() || DEFAULT_ITEM_LABEL_PLURAL).slice(0, 60);
return { singular, plural };
}
export interface Participant { export interface Participant {
id: number; id: number;
questionnaire_id: number; questionnaire_id: number;
@@ -129,6 +145,18 @@ export function initializeDatabase(): void {
} catch (e) { } catch (e) {
// Column already exists, ignore // Column already exists, ignore
} }
// Migration: configurable inventory item name (singular / plural) per questionnaire
try {
db.exec(`ALTER TABLE questionnaires ADD COLUMN item_label TEXT DEFAULT '${DEFAULT_ITEM_LABEL.replace(/'/g, "''")}'`);
} catch (e) {
// Column already exists, ignore
}
try {
db.exec(`ALTER TABLE questionnaires ADD COLUMN item_label_plural TEXT DEFAULT '${DEFAULT_ITEM_LABEL_PLURAL.replace(/'/g, "''")}'`);
} catch (e) {
// Column already exists, ignore
}
// Participants table // Participants table
db.exec(` db.exec(`
@@ -267,10 +295,20 @@ export const userOps = {
// Questionnaire operations // Questionnaire operations
export const questionnaireOps = { export const questionnaireOps = {
create: (uuid: string, slug: string, title: string, description: string | null, ogImage: string | null, isPrivate: boolean, createdBy: number): number => { create: (
uuid: string,
slug: string,
title: string,
description: string | null,
ogImage: string | null,
isPrivate: boolean,
createdBy: number,
itemLabel: string,
itemLabelPlural: string
): number => {
const result = db.prepare( const result = db.prepare(
'INSERT INTO questionnaires (uuid, slug, title, description, og_image, is_private, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)' 'INSERT INTO questionnaires (uuid, slug, title, description, og_image, is_private, created_by, item_label, item_label_plural) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
).run(uuid, slug, title, description, ogImage, isPrivate ? 1 : 0, createdBy); ).run(uuid, slug, title, description, ogImage, isPrivate ? 1 : 0, createdBy, itemLabel, itemLabelPlural);
return result.lastInsertRowid as number; return result.lastInsertRowid as number;
}, },
@@ -296,8 +334,19 @@ export const questionnaireOps = {
`).all() as Questionnaire[]; `).all() as Questionnaire[];
}, },
update: (id: number, slug: string, title: string, description: string | null, ogImage: string | null, isPrivate: boolean): void => { update: (
db.prepare('UPDATE questionnaires SET slug = ?, title = ?, description = ?, og_image = ?, is_private = ? WHERE id = ?').run(slug, title, description, ogImage, isPrivate ? 1 : 0, id); id: number,
slug: string,
title: string,
description: string | null,
ogImage: string | null,
isPrivate: boolean,
itemLabel: string,
itemLabelPlural: string
): void => {
db.prepare(
'UPDATE questionnaires SET slug = ?, title = ?, description = ?, og_image = ?, is_private = ?, item_label = ?, item_label_plural = ? WHERE id = ?'
).run(slug, title, description, ogImage, isPrivate ? 1 : 0, itemLabel, itemLabelPlural, id);
}, },
delete: (id: number): void => { delete: (id: number): void => {

View File

@@ -6,7 +6,7 @@ import path from 'path';
import fs from 'fs'; import fs from 'fs';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { initializeDatabase, questionnaireOps, participantOps } from './database.js'; import { initializeDatabase, questionnaireOps, participantOps, resolveItemLabels } from './database.js';
import authRoutes from './routes/auth.js'; import authRoutes from './routes/auth.js';
import adminRoutes from './routes/admin.js'; import adminRoutes from './routes/admin.js';
import questionnaireRoutes from './routes/questionnaire.js'; import questionnaireRoutes from './routes/questionnaire.js';
@@ -108,7 +108,9 @@ if (isProduction) {
const baseUrl = `${req.protocol}://${req.get('host')}`; const baseUrl = `${req.protocol}://${req.get('host')}`;
const pageUrl = `${baseUrl}/q/${questionnaire.slug}`; const pageUrl = `${baseUrl}/q/${questionnaire.slug}`;
const title = questionnaire.title; const title = questionnaire.title;
const description = questionnaire.description || 'Activiteiten Inventaris - Voeg activiteiten toe en stem!'; const { plural } = resolveItemLabels(questionnaire);
const description =
questionnaire.description || `Voeg ${plural.toLowerCase()} toe en stem!`;
// Make image URL absolute if it's a relative path // Make image URL absolute if it's a relative path
let ogImageUrl = questionnaire.og_image; let ogImageUrl = questionnaire.og_image;
@@ -122,7 +124,7 @@ if (isProduction) {
html = html.replace('</head>', `${ogTags}</head>`); html = html.replace('</head>', `${ogTags}</head>`);
// Update title // Update title
html = html.replace(/<title>.*?<\/title>/, `<title>${title} - Activiteiten Inventaris</title>`); html = html.replace(/<title>.*?<\/title>/, `<title>${title}</title>`);
res.send(html); res.send(html);
}); });
@@ -153,8 +155,10 @@ if (isProduction) {
// Get first name from participant name // Get first name from participant name
const firstName = participant.name.trim().split(/\s+/)[0] || participant.name; const firstName = participant.name.trim().split(/\s+/)[0] || participant.name;
const description = questionnaire.description const { plural } = resolveItemLabels(questionnaire);
|| `Hoi ${firstName}! Voeg je ideeën toe en stem op activiteiten.`; const description =
questionnaire.description ||
`Hoi ${firstName}! Voeg je ideeën toe en stem op ${plural.toLowerCase()}.`;
// Make image URL absolute if it's a relative path // Make image URL absolute if it's a relative path
let ogImageUrl = questionnaire.og_image; let ogImageUrl = questionnaire.og_image;

View File

@@ -5,7 +5,14 @@ import multer, { FileFilterCallback } from 'multer';
import path from 'path'; import path from 'path';
import fs from 'fs'; import fs from 'fs';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { userOps, questionnaireOps, activityOps, participantOps } from '../database.js'; import {
userOps,
questionnaireOps,
activityOps,
participantOps,
resolveItemLabels,
DEFAULT_ITEM_LABEL,
} from '../database.js';
import { requireAuth } from '../middleware/auth.js'; import { requireAuth } from '../middleware/auth.js';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
@@ -74,9 +81,17 @@ function isValidSlug(slug: string): boolean {
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug); return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug);
} }
function parseItemLabelsFromBody(itemLabel: unknown, itemLabelPlural: unknown): { singular: string; plural: string } {
return resolveItemLabels({
item_label: typeof itemLabel === 'string' ? itemLabel : null,
item_label_plural: typeof itemLabelPlural === 'string' ? itemLabelPlural : null,
});
}
// Create questionnaire // Create questionnaire
router.post('/questionnaires', (req: Request, res: Response) => { router.post('/questionnaires', (req: Request, res: Response) => {
const { title, description, slug, ogImage, isPrivate } = req.body; const { title, description, slug, ogImage, isPrivate, itemLabel, itemLabelPlural } = req.body;
const { singular, plural } = parseItemLabelsFromBody(itemLabel, itemLabelPlural);
if (!title?.trim()) { if (!title?.trim()) {
res.status(400).json({ error: 'Titel is verplicht' }); res.status(400).json({ error: 'Titel is verplicht' });
@@ -106,7 +121,17 @@ router.post('/questionnaires', (req: Request, res: Response) => {
} }
const uuid = uuidv4(); const uuid = uuidv4();
const id = questionnaireOps.create(uuid, cleanSlug, title.trim(), description?.trim() || null, ogImage?.trim() || null, !!isPrivate, req.session.user!.id); const id = questionnaireOps.create(
uuid,
cleanSlug,
title.trim(),
description?.trim() || null,
ogImage?.trim() || null,
!!isPrivate,
req.session.user!.id,
singular,
plural
);
const questionnaire = questionnaireOps.findById(id); const questionnaire = questionnaireOps.findById(id);
res.json({ success: true, questionnaire }); res.json({ success: true, questionnaire });
@@ -127,7 +152,8 @@ router.get('/questionnaires/:id', (req: Request, res: Response) => {
// Update questionnaire // Update questionnaire
router.put('/questionnaires/:id', (req: Request, res: Response) => { router.put('/questionnaires/:id', (req: Request, res: Response) => {
const { title, description, slug, ogImage, isPrivate } = req.body; const { title, description, slug, ogImage, isPrivate, itemLabel, itemLabelPlural } = req.body;
const { singular, plural } = parseItemLabelsFromBody(itemLabel, itemLabelPlural);
const questionnaire = questionnaireOps.findById(parseInt(req.params.id)); const questionnaire = questionnaireOps.findById(parseInt(req.params.id));
if (!questionnaire) { if (!questionnaire) {
@@ -162,7 +188,16 @@ router.put('/questionnaires/:id', (req: Request, res: Response) => {
return; return;
} }
questionnaireOps.update(questionnaire.id, cleanSlug, title.trim(), description?.trim() || null, ogImage?.trim() || null, !!isPrivate); questionnaireOps.update(
questionnaire.id,
cleanSlug,
title.trim(),
description?.trim() || null,
ogImage?.trim() || null,
!!isPrivate,
singular,
plural
);
res.json({ success: true }); res.json({ success: true });
}); });
@@ -267,12 +302,15 @@ router.put('/activities/:id', (req: Request, res: Response) => {
const activity = activityOps.findById(parseInt(req.params.id)); const activity = activityOps.findById(parseInt(req.params.id));
if (!activity) { if (!activity) {
res.status(404).json({ error: 'Activiteit niet gevonden' }); res.status(404).json({ error: `${DEFAULT_ITEM_LABEL} niet gevonden` });
return; return;
} }
const q = questionnaireOps.findById(activity.questionnaire_id);
const itemSingular = q ? resolveItemLabels(q).singular : DEFAULT_ITEM_LABEL;
if (!name?.trim()) { if (!name?.trim()) {
res.status(400).json({ error: 'Naam activiteit is verplicht' }); res.status(400).json({ error: `Naam ${itemSingular.toLowerCase()} is verplicht` });
return; return;
} }

View File

@@ -1,5 +1,14 @@
import { Router, Request, Response } from 'express'; import { Router, Request, Response } from 'express';
import { questionnaireOps, activityOps, voteOps, commentOps, participantOps, Comment, Questionnaire } from '../database.js'; import {
questionnaireOps,
activityOps,
voteOps,
commentOps,
participantOps,
Comment,
Questionnaire,
resolveItemLabels,
} from '../database.js';
// Helper to check if user can participate (write access) // Helper to check if user can participate (write access)
function canParticipate(questionnaire: Questionnaire, req: Request): { canWrite: boolean; participantName: string | null } { function canParticipate(questionnaire: Questionnaire, req: Request): { canWrite: boolean; participantName: string | null } {
@@ -152,8 +161,9 @@ router.post('/:slug/activities', (req: Request, res: Response) => {
return; return;
} }
const { singular: itemSingular } = resolveItemLabels(questionnaire);
if (!name?.trim()) { if (!name?.trim()) {
res.status(400).json({ error: 'Naam activiteit is verplicht' }); res.status(400).json({ error: `Naam ${itemSingular.toLowerCase()} is verplicht` });
return; return;
} }
@@ -186,20 +196,21 @@ router.put('/:slug/activities/:activityId', (req: Request, res: Response) => {
return; return;
} }
const { singular: itemSingular, plural: itemPlural } = resolveItemLabels(questionnaire);
const activity = activityOps.findById(parseInt(req.params.activityId)); const activity = activityOps.findById(parseInt(req.params.activityId));
if (!activity || activity.questionnaire_id !== questionnaire.id) { if (!activity || activity.questionnaire_id !== questionnaire.id) {
res.status(404).json({ error: 'Activiteit niet gevonden' }); res.status(404).json({ error: `${itemSingular} niet gevonden` });
return; return;
} }
// Check if the user is the one who added the activity // Check if the user is the one who added the activity
if (activity.added_by !== participantName) { if (activity.added_by !== participantName) {
res.status(403).json({ error: 'Je kunt alleen je eigen activiteiten bewerken' }); res.status(403).json({ error: `Je kunt alleen je eigen ${itemPlural.toLowerCase()} bewerken` });
return; return;
} }
if (!name?.trim()) { if (!name?.trim()) {
res.status(400).json({ error: 'Naam activiteit is verplicht' }); res.status(400).json({ error: `Naam ${itemSingular.toLowerCase()} is verplicht` });
return; return;
} }
@@ -231,9 +242,10 @@ router.post('/:slug/activities/:activityId/vote', (req: Request, res: Response)
return; return;
} }
const { singular: itemSingular } = resolveItemLabels(questionnaire);
const activity = activityOps.findById(parseInt(req.params.activityId)); const activity = activityOps.findById(parseInt(req.params.activityId));
if (!activity || activity.questionnaire_id !== questionnaire.id) { if (!activity || activity.questionnaire_id !== questionnaire.id) {
res.status(404).json({ error: 'Activiteit niet gevonden' }); res.status(404).json({ error: `${itemSingular} niet gevonden` });
return; return;
} }
@@ -286,9 +298,10 @@ router.get('/:slug/activities/:activityId/comments', (req: Request, res: Respons
return; return;
} }
const { singular: itemSingular } = resolveItemLabels(questionnaire);
const activity = activityOps.findById(parseInt(req.params.activityId)); const activity = activityOps.findById(parseInt(req.params.activityId));
if (!activity || activity.questionnaire_id !== questionnaire.id) { if (!activity || activity.questionnaire_id !== questionnaire.id) {
res.status(404).json({ error: 'Activiteit niet gevonden' }); res.status(404).json({ error: `${itemSingular} niet gevonden` });
return; return;
} }
@@ -337,9 +350,10 @@ router.post('/:slug/activities/:activityId/comments', (req: Request, res: Respon
return; return;
} }
const { singular: itemSingular } = resolveItemLabels(questionnaire);
const activity = activityOps.findById(parseInt(req.params.activityId)); const activity = activityOps.findById(parseInt(req.params.activityId));
if (!activity || activity.questionnaire_id !== questionnaire.id) { if (!activity || activity.questionnaire_id !== questionnaire.id) {
res.status(404).json({ error: 'Activiteit niet gevonden' }); res.status(404).json({ error: `${itemSingular} niet gevonden` });
return; return;
} }

View File

@@ -11,6 +11,11 @@ interface Questionnaire {
creator_name: string creator_name: string
created_at: string created_at: string
activity_count: number activity_count: number
item_label_plural: string | null
}
function pluralLabel(q: Questionnaire) {
return (q.item_label_plural?.trim() || 'Activiteiten').slice(0, 60)
} }
export function Dashboard() { export function Dashboard() {
@@ -72,7 +77,7 @@ export function Dashboard() {
<span className="px-2 py-0.5 bg-accent/20 text-accent text-xs font-semibold rounded">Privé</span> <span className="px-2 py-0.5 bg-accent/20 text-accent text-xs font-semibold rounded">Privé</span>
)} )}
<span className="px-2 py-0.5 bg-bg-input rounded text-xs font-semibold text-text-muted"> <span className="px-2 py-0.5 bg-bg-input rounded text-xs font-semibold text-text-muted">
{q.activity_count} activiteiten {q.activity_count} {pluralLabel(q).toLowerCase()}
</span> </span>
</div> </div>
</div> </div>
@@ -111,7 +116,7 @@ export function Dashboard() {
<div className="text-center py-16 bg-bg-card border border-dashed border-border rounded-xl"> <div className="text-center py-16 bg-bg-card border border-dashed border-border rounded-xl">
<div className="text-4xl mb-4 opacity-50">📋</div> <div className="text-4xl mb-4 opacity-50">📋</div>
<h2 className="text-lg font-semibold text-text mb-2">Nog geen vragenlijsten</h2> <h2 className="text-lg font-semibold text-text mb-2">Nog geen vragenlijsten</h2>
<p className="text-text-muted mb-6">Maak je eerste vragenlijst om activiteiten en stemmen te verzamelen.</p> <p className="text-text-muted mb-6">Maak je eerste vragenlijst om voorstellen te inventariseren en stemmen te verzamelen.</p>
<Link <Link
to="/admin/questionnaires/new" to="/admin/questionnaires/new"
className="inline-flex items-center gap-2 px-4 py-2 bg-accent hover:bg-accent-hover text-white font-semibold rounded-lg transition-colors" className="inline-flex items-center gap-2 px-4 py-2 bg-accent hover:bg-accent-hover text-white font-semibold rounded-lg transition-colors"

View File

@@ -27,6 +27,15 @@ interface Questionnaire {
title: string title: string
description: string | null description: string | null
is_private: boolean is_private: boolean
item_label: string | null
item_label_plural: string | null
}
function itemLabels(q: Questionnaire) {
return {
singular: (q.item_label?.trim() || 'Activiteit').slice(0, 60),
plural: (q.item_label_plural?.trim() || 'Activiteiten').slice(0, 60),
}
} }
export function PublicQuestionnaire({ accessToken }: { accessToken?: string } = {}) { export function PublicQuestionnaire({ accessToken }: { accessToken?: string } = {}) {
@@ -318,6 +327,8 @@ export function PublicQuestionnaire({ accessToken }: { accessToken?: string } =
) )
} }
const labels = questionnaire ? itemLabels(questionnaire) : { singular: 'Activiteit', plural: 'Activiteiten' }
if (error || !questionnaire) { if (error || !questionnaire) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-bg"> <div className="min-h-screen flex items-center justify-center bg-bg">
@@ -396,14 +407,14 @@ export function PublicQuestionnaire({ accessToken }: { accessToken?: string } =
{/* Add Activity */} {/* Add Activity */}
{canWrite && ( {canWrite && (
<div className="bg-bg-card border border-border rounded-xl p-5 mb-8"> <div className="bg-bg-card border border-border rounded-xl p-5 mb-8">
<h3 className="font-semibold text-text mb-3">Activiteit Toevoegen</h3> <h3 className="font-semibold text-text mb-3">{labels.singular} toevoegen</h3>
<form onSubmit={handleAddActivity} className="space-y-3"> <form onSubmit={handleAddActivity} className="space-y-3">
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
type="text" type="text"
value={activityInput} value={activityInput}
onChange={(e) => setActivityInput(e.target.value)} onChange={(e) => setActivityInput(e.target.value)}
placeholder="Naam activiteit" placeholder={`Naam ${labels.singular.toLowerCase()}`}
className="flex-1 px-4 py-2 bg-bg-input border border-border rounded-lg text-text placeholder-text-faint focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-colors" className="flex-1 px-4 py-2 bg-bg-input border border-border rounded-lg text-text placeholder-text-faint focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-colors"
required required
/> />
@@ -448,7 +459,7 @@ export function PublicQuestionnaire({ accessToken }: { accessToken?: string } =
)} )}
{/* Activities List */} {/* Activities List */}
<h2 className="font-semibold text-text mb-4">Activiteiten</h2> <h2 className="font-semibold text-text mb-4">{labels.plural}</h2>
{activities.length > 0 ? ( {activities.length > 0 ? (
<div className="space-y-2"> <div className="space-y-2">
@@ -529,7 +540,7 @@ export function PublicQuestionnaire({ accessToken }: { accessToken?: string } =
</div> </div>
) : ( ) : (
<div className="text-center py-8 bg-bg-card border border-dashed border-border rounded-xl"> <div className="text-center py-8 bg-bg-card border border-dashed border-border rounded-xl">
<p className="text-text-muted">Nog geen activiteiten. Wees de eerste om er één toe te voegen!</p> <p className="text-text-muted">Nog geen {labels.plural.toLowerCase()}. Wees de eerste om er één toe te voegen!</p>
</div> </div>
)} )}
</div> </div>
@@ -619,7 +630,7 @@ export function PublicQuestionnaire({ accessToken }: { accessToken?: string } =
> >
{/* Header */} {/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border"> <div className="flex items-center justify-between p-4 border-b border-border">
<h3 className="font-semibold text-text">Activiteit Bewerken</h3> <h3 className="font-semibold text-text">{labels.singular} bewerken</h3>
<button <button
onClick={() => setShowEditModal(false)} onClick={() => setShowEditModal(false)}
className="text-2xl text-text-muted hover:text-text leading-none" className="text-2xl text-text-muted hover:text-text leading-none"

View File

@@ -28,6 +28,15 @@ interface Questionnaire {
description: string | null description: string | null
is_private: boolean is_private: boolean
created_at: string created_at: string
item_label: string | null
item_label_plural: string | null
}
function itemLabels(q: Questionnaire) {
return {
singular: (q.item_label?.trim() || 'Activiteit').slice(0, 60),
plural: (q.item_label_plural?.trim() || 'Activiteiten').slice(0, 60),
}
} }
export function QuestionnaireDetail() { export function QuestionnaireDetail() {
@@ -126,7 +135,11 @@ export function QuestionnaireDetail() {
function getInvitationMessage(participant: Participant, url: string): string { function getInvitationMessage(participant: Participant, url: string): string {
const firstName = getFirstName(participant.name) const firstName = getFirstName(participant.name)
const title = questionnaire?.title || 'de vragenlijst' const title = questionnaire?.title || 'de vragenlijst'
return `Hoi ${firstName}, we zijn benieuwd naar je ideeën voor "${title}". Je kunt nieuwe ideeën aandragen en/of stemmen op reeds toegevoegde ideeën via deze link: ${url}. Alvast bedankt voor je hulp!` if (!questionnaire) {
return `Hoi ${firstName}, we zijn benieuwd naar je ideeën voor "${title}". Je kunt nieuwe ideeën aandragen en/of stemmen op reeds toegevoegde ideeën via deze link: ${url}. Alvast bedankt voor je hulp!`
}
const { plural } = itemLabels(questionnaire)
return `Hoi ${firstName}, we zijn benieuwd naar je input voor "${title}". Je kunt ${plural.toLowerCase()} toevoegen en stemmen op reeds toegevoegde ${plural.toLowerCase()} via deze link: ${url}. Alvast bedankt voor je hulp!`
} }
// Copy invitation message to clipboard // Copy invitation message to clipboard
@@ -199,7 +212,9 @@ export function QuestionnaireDetail() {
} }
async function handleDelete() { async function handleDelete() {
if (!confirm('Weet je zeker dat je deze vragenlijst wilt verwijderen? Dit verwijdert ook alle activiteiten en stemmen.')) { if (!questionnaire) return
const pl = itemLabels(questionnaire).plural.toLowerCase()
if (!confirm(`Weet je zeker dat je deze vragenlijst wilt verwijderen? Dit verwijdert ook alle ${pl} en stemmen.`)) {
return return
} }
@@ -215,7 +230,8 @@ export function QuestionnaireDetail() {
} }
async function handleDeleteActivity(activityId: number) { async function handleDeleteActivity(activityId: number) {
if (!confirm('Deze activiteit verwijderen?')) return if (!questionnaire) return
if (!confirm(`Deze ${itemLabels(questionnaire).singular.toLowerCase()} verwijderen?`)) return
try { try {
await fetch(`/api/admin/activities/${activityId}`, { await fetch(`/api/admin/activities/${activityId}`, {
@@ -284,6 +300,7 @@ export function QuestionnaireDetail() {
return <div className="text-text-muted">Vragenlijst niet gevonden</div> return <div className="text-text-muted">Vragenlijst niet gevonden</div>
} }
const labels = itemLabels(questionnaire)
const shareUrl = `${window.location.origin}/q/${questionnaire.slug}` const shareUrl = `${window.location.origin}/q/${questionnaire.slug}`
return ( return (
@@ -323,8 +340,8 @@ export function QuestionnaireDetail() {
</div> </div>
<p className="text-xs text-text-faint"> <p className="text-xs text-text-faint">
{questionnaire.is_private {questionnaire.is_private
? 'Iedereen met deze link kan activiteiten en stemmen bekijken, maar niet deelnemen.' ? `Iedereen met deze link kan ${labels.plural.toLowerCase()} en stemmen bekijken, maar niet deelnemen.`
: 'Deel deze URL met mensen die activiteiten moeten toevoegen en stemmen.'} : `Deel deze URL met mensen die ${labels.plural.toLowerCase()} moeten toevoegen en stemmen.`}
</p> </p>
</div> </div>
@@ -429,14 +446,14 @@ export function QuestionnaireDetail() {
)} )}
{/* Activities */} {/* Activities */}
<h2 className="font-semibold text-text mb-4">Activiteiten ({activities.length})</h2> <h2 className="font-semibold text-text mb-4">{labels.plural} ({activities.length})</h2>
{activities.length > 0 ? ( {activities.length > 0 ? (
<div className="bg-bg-card border border-border rounded-xl overflow-hidden"> <div className="bg-bg-card border border-border rounded-xl overflow-hidden">
<table className="w-full"> <table className="w-full">
<thead> <thead>
<tr className="bg-bg-elevated"> <tr className="bg-bg-elevated">
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-text-muted">Activiteit</th> <th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-text-muted">{labels.singular}</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-text-muted">Toegevoegd door</th> <th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-text-muted">Toegevoegd door</th>
<th className="px-4 py-3 text-center text-xs font-semibold uppercase tracking-wider text-text-muted">Voor</th> <th className="px-4 py-3 text-center text-xs font-semibold uppercase tracking-wider text-text-muted">Voor</th>
<th className="px-4 py-3 text-center text-xs font-semibold uppercase tracking-wider text-text-muted">Tegen</th> <th className="px-4 py-3 text-center text-xs font-semibold uppercase tracking-wider text-text-muted">Tegen</th>
@@ -480,7 +497,7 @@ export function QuestionnaireDetail() {
</div> </div>
) : ( ) : (
<div className="text-center py-8 bg-bg-card border border-dashed border-border rounded-xl"> <div className="text-center py-8 bg-bg-card border border-dashed border-border rounded-xl">
<p className="text-text-muted">Er zijn nog geen activiteiten toegevoegd. Deel de vragenlijst URL zodat mensen activiteiten kunnen toevoegen.</p> <p className="text-text-muted">Er zijn nog geen {labels.plural.toLowerCase()} toegevoegd. Deel de vragenlijst-URL zodat mensen {labels.plural.toLowerCase()} kunnen toevoegen.</p>
</div> </div>
)} )}
@@ -506,7 +523,7 @@ export function QuestionnaireDetail() {
> >
{/* Header */} {/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border"> <div className="flex items-center justify-between p-4 border-b border-border">
<h3 className="font-semibold text-text">Activiteit Bewerken</h3> <h3 className="font-semibold text-text">{labels.singular} bewerken</h3>
<button <button
onClick={() => setShowEditModal(false)} onClick={() => setShowEditModal(false)}
className="text-2xl text-text-muted hover:text-text leading-none" className="text-2xl text-text-muted hover:text-text leading-none"

View File

@@ -13,6 +13,8 @@ export function QuestionnaireForm() {
const [ogImage, setOgImage] = useState('') const [ogImage, setOgImage] = useState('')
const [ogImageMode, setOgImageMode] = useState<'upload' | 'url'>('upload') const [ogImageMode, setOgImageMode] = useState<'upload' | 'url'>('upload')
const [isPrivate, setIsPrivate] = useState(false) const [isPrivate, setIsPrivate] = useState(false)
const [itemLabel, setItemLabel] = useState('Activiteit')
const [itemLabelPlural, setItemLabelPlural] = useState('Activiteiten')
const [error, setError] = useState('') const [error, setError] = useState('')
const [slugError, setSlugError] = useState('') const [slugError, setSlugError] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@@ -34,6 +36,8 @@ export function QuestionnaireForm() {
setDescription(data.questionnaire.description || '') setDescription(data.questionnaire.description || '')
setOgImage(data.questionnaire.og_image || '') setOgImage(data.questionnaire.og_image || '')
setIsPrivate(!!data.questionnaire.is_private) setIsPrivate(!!data.questionnaire.is_private)
setItemLabel(data.questionnaire.item_label?.trim() || 'Activiteit')
setItemLabelPlural(data.questionnaire.item_label_plural?.trim() || 'Activiteiten')
} }
} catch (error) { } catch (error) {
console.error('Failed to fetch questionnaire:', error) console.error('Failed to fetch questionnaire:', error)
@@ -142,7 +146,15 @@ export function QuestionnaireForm() {
method, method,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
credentials: 'include', credentials: 'include',
body: JSON.stringify({ title, slug, description, ogImage, isPrivate }), body: JSON.stringify({
title,
slug,
description,
ogImage,
isPrivate,
itemLabel: itemLabel.trim() || 'Activiteit',
itemLabelPlural: itemLabelPlural.trim() || 'Activiteiten',
}),
}) })
const data = await res.json() const data = await res.json()
@@ -236,6 +248,43 @@ export function QuestionnaireForm() {
/> />
</div> </div>
<div className="grid gap-6 sm:grid-cols-2">
<div>
<label htmlFor="itemLabel" className="block text-sm font-medium text-text mb-2">
Naam per item (enkelvoud)
</label>
<input
type="text"
id="itemLabel"
value={itemLabel}
onChange={(e) => setItemLabel(e.target.value)}
maxLength={60}
className="w-full px-4 py-3 bg-bg-input border border-border rounded-lg text-text placeholder-text-faint focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-colors"
placeholder="bijv. Activiteit, Muzieknummer"
/>
<p className="mt-1 text-xs text-text-faint">
Zo wordt één voorstel getoond in formulieren en knoppen.
</p>
</div>
<div>
<label htmlFor="itemLabelPlural" className="block text-sm font-medium text-text mb-2">
Meervoud
</label>
<input
type="text"
id="itemLabelPlural"
value={itemLabelPlural}
onChange={(e) => setItemLabelPlural(e.target.value)}
maxLength={60}
className="w-full px-4 py-3 bg-bg-input border border-border rounded-lg text-text placeholder-text-faint focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-colors"
placeholder="bijv. Activiteiten, Muzieknummers"
/>
<p className="mt-1 text-xs text-text-faint">
Voor lijsten en tellers (bijv. &quot;12 muzieknummers&quot;).
</p>
</div>
</div>
<div> <div>
<label className="block text-sm font-medium text-text mb-2"> <label className="block text-sm font-medium text-text mb-2">
Preview Afbeelding (voor WhatsApp/Social Media) Preview Afbeelding (voor WhatsApp/Social Media)