From 427da6452add5596bb5b765389f89e9a6db67cdd Mon Sep 17 00:00:00 2001
From: Bert Hausmans
Date: Tue, 6 Jan 2026 02:56:35 +0100
Subject: [PATCH] Add phone number field and WhatsApp/copy message buttons for
participants
---
server/database.ts | 14 +++-
server/routes/admin.ts | 7 +-
src/pages/QuestionnaireDetail.tsx | 109 ++++++++++++++++++++++++------
3 files changed, 105 insertions(+), 25 deletions(-)
diff --git a/server/database.ts b/server/database.ts
index 548a269..2aa6b7b 100644
--- a/server/database.ts
+++ b/server/database.ts
@@ -46,6 +46,7 @@ export interface Participant {
id: number;
questionnaire_id: number;
name: string;
+ phone: string | null;
token: string;
created_at: string;
}
@@ -141,6 +142,13 @@ export function initializeDatabase(): void {
)
`);
+ // Migration: Add phone column to participants if it doesn't exist
+ try {
+ db.exec(`ALTER TABLE participants ADD COLUMN phone TEXT`);
+ } catch (e) {
+ // Column already exists, ignore
+ }
+
db.exec(`
CREATE INDEX IF NOT EXISTS idx_participants_questionnaire ON participants(questionnaire_id);
CREATE INDEX IF NOT EXISTS idx_participants_token ON participants(token);
@@ -308,10 +316,10 @@ export const questionnaireOps = {
// Participant operations
export const participantOps = {
- create: (questionnaireId: number, name: string, token: string): number => {
+ create: (questionnaireId: number, name: string, phone: string | null, token: string): number => {
const result = db.prepare(
- 'INSERT INTO participants (questionnaire_id, name, token) VALUES (?, ?, ?)'
- ).run(questionnaireId, name, token);
+ 'INSERT INTO participants (questionnaire_id, name, phone, token) VALUES (?, ?, ?, ?)'
+ ).run(questionnaireId, name, phone, token);
return result.lastInsertRowid as number;
},
diff --git a/server/routes/admin.ts b/server/routes/admin.ts
index 6398a3c..3baa552 100644
--- a/server/routes/admin.ts
+++ b/server/routes/admin.ts
@@ -197,7 +197,7 @@ function generateToken(): string {
// Add participant to questionnaire
router.post('/questionnaires/:id/participants', (req: Request, res: Response) => {
- const { name } = req.body;
+ const { name, phone } = req.body;
const questionnaire = questionnaireOps.findById(parseInt(req.params.id));
if (!questionnaire) {
@@ -210,13 +210,16 @@ router.post('/questionnaires/:id/participants', (req: Request, res: Response) =>
return;
}
+ // Clean phone number (remove spaces, keep + and digits)
+ const cleanPhone = phone?.trim() ? phone.trim().replace(/[^\d+]/g, '') : null;
+
// Generate unique token
let token = generateToken();
while (!participantOps.isTokenAvailable(token)) {
token = generateToken();
}
- const id = participantOps.create(questionnaire.id, name.trim(), token);
+ const id = participantOps.create(questionnaire.id, name.trim(), cleanPhone, token);
const participant = participantOps.findById(id);
res.json({ success: true, participant });
diff --git a/src/pages/QuestionnaireDetail.tsx b/src/pages/QuestionnaireDetail.tsx
index 56f91c3..8f2269f 100644
--- a/src/pages/QuestionnaireDetail.tsx
+++ b/src/pages/QuestionnaireDetail.tsx
@@ -15,6 +15,7 @@ interface Activity {
interface Participant {
id: number
name: string
+ phone: string | null
token: string
created_at: string
}
@@ -38,6 +39,7 @@ export function QuestionnaireDetail() {
const [loading, setLoading] = useState(true)
const [copied, setCopied] = useState('')
const [newParticipantName, setNewParticipantName] = useState('')
+ const [newParticipantPhone, setNewParticipantPhone] = useState('')
const [addingParticipant, setAddingParticipant] = useState(false)
// Edit activity modal state
@@ -89,11 +91,15 @@ export function QuestionnaireDetail() {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
- body: JSON.stringify({ name: newParticipantName.trim() }),
+ body: JSON.stringify({
+ name: newParticipantName.trim(),
+ phone: newParticipantPhone.trim() || null
+ }),
})
const data = await res.json()
if (data.success) {
setNewParticipantName('')
+ setNewParticipantPhone('')
fetchParticipants()
}
} catch (error) {
@@ -103,6 +109,36 @@ export function QuestionnaireDetail() {
}
}
+ // Get first name from full name
+ function getFirstName(fullName: string): string {
+ const parts = fullName.trim().split(/\s+/)
+ return parts[0] || fullName
+ }
+
+ // Generate invitation message for a participant
+ function getInvitationMessage(participant: Participant, url: string): string {
+ const firstName = getFirstName(participant.name)
+ 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!`
+ }
+
+ // Copy invitation message to clipboard
+ function copyInvitation(participant: Participant, url: string) {
+ const message = getInvitationMessage(participant, url)
+ navigator.clipboard.writeText(message)
+ setCopied(`msg-${participant.id}`)
+ setTimeout(() => setCopied(''), 2000)
+ }
+
+ // Generate WhatsApp link
+ function getWhatsAppLink(participant: Participant, url: string): string {
+ const message = getInvitationMessage(participant, url)
+ const encodedMessage = encodeURIComponent(message)
+ // Remove leading + from phone number for wa.me link
+ const phone = participant.phone?.replace(/^\+/, '') || ''
+ return `https://wa.me/${phone}?text=${encodedMessage}`
+ }
+
async function handleDeleteParticipant(participantId: number) {
if (!confirm('Deze deelnemer verwijderen?')) return
@@ -256,15 +292,22 @@ export function QuestionnaireDetail() {
{/* Add Participant Form */}
-