Add phone number field and WhatsApp/copy message buttons for participants

This commit is contained in:
2026-01-06 02:56:35 +01:00
parent 0637267ad5
commit 427da6452a
3 changed files with 105 additions and 25 deletions

View File

@@ -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;
},

View File

@@ -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 });

View File

@@ -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() {
</p>
{/* Add Participant Form */}
<form onSubmit={handleAddParticipant} className="flex gap-2 mb-4">
<form onSubmit={handleAddParticipant} className="flex flex-wrap gap-2 mb-4">
<input
type="text"
value={newParticipantName}
onChange={(e) => setNewParticipantName(e.target.value)}
placeholder="Naam deelnemer"
className="flex-1 px-3 py-2 bg-bg-input border border-border rounded-lg text-sm text-text placeholder-text-faint focus:outline-none focus:border-accent"
className="flex-1 min-w-[150px] px-3 py-2 bg-bg-input border border-border rounded-lg text-sm text-text placeholder-text-faint focus:outline-none focus:border-accent"
required
/>
<input
type="tel"
value={newParticipantPhone}
onChange={(e) => setNewParticipantPhone(e.target.value)}
placeholder="Telefoon (optioneel, bijv. +31612345678)"
className="flex-1 min-w-[200px] px-3 py-2 bg-bg-input border border-border rounded-lg text-sm text-text placeholder-text-faint focus:outline-none focus:border-accent"
/>
<button
type="submit"
disabled={addingParticipant}
@@ -276,27 +319,53 @@ export function QuestionnaireDetail() {
{/* Participants List */}
{participants.length > 0 ? (
<div className="space-y-2">
<div className="space-y-3">
{participants.map((participant) => {
const participantUrl = `${window.location.origin}/q/access/${participant.token}`
return (
<div key={participant.id} className="flex items-center gap-2 p-3 bg-bg-input rounded-lg">
<div key={participant.id} className="p-3 bg-bg-input rounded-lg">
<div className="flex items-start gap-3">
<div className="flex-1 min-w-0">
<div className="font-medium text-text text-sm">{participant.name}</div>
<div className="text-xs font-mono text-text-faint truncate">{participantUrl}</div>
{participant.phone && (
<div className="text-xs text-text-muted mt-0.5">📱 {participant.phone}</div>
)}
<div className="text-xs font-mono text-text-faint truncate mt-1">{participantUrl}</div>
</div>
<button
onClick={() => copyUrl(participantUrl, `p-${participant.id}`)}
className="px-3 py-1 text-xs font-medium text-text-muted hover:text-text border border-border rounded transition-colors"
onClick={() => handleDeleteParticipant(participant.id)}
className="px-2 py-1 text-xs font-medium text-danger hover:bg-danger-muted rounded transition-colors shrink-0"
title="Verwijderen"
>
{copied === `p-${participant.id}` ? 'Gekopieerd!' : 'Link Kopiëren'}
</button>
</div>
{/* Action buttons */}
<div className="flex flex-wrap gap-2 mt-3 pt-3 border-t border-border-light">
<button
onClick={() => copyUrl(participantUrl, `p-${participant.id}`)}
className="px-3 py-1.5 text-xs font-medium text-text-muted hover:text-text border border-border rounded-lg transition-colors flex items-center gap-1"
>
🔗 {copied === `p-${participant.id}` ? 'Gekopieerd!' : 'Kopieer Link'}
</button>
<button
onClick={() => handleDeleteParticipant(participant.id)}
className="px-2 py-1 text-xs font-medium text-danger hover:bg-danger-muted rounded transition-colors"
onClick={() => copyInvitation(participant, participantUrl)}
className="px-3 py-1.5 text-xs font-medium text-text-muted hover:text-text border border-border rounded-lg transition-colors flex items-center gap-1"
>
Verwijderen
📋 {copied === `msg-${participant.id}` ? 'Gekopieerd!' : 'Kopieer Bericht'}
</button>
{participant.phone && (
<a
href={getWhatsAppLink(participant, participantUrl)}
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1.5 text-xs font-medium text-white bg-[#25D366] hover:bg-[#1da851] rounded-lg transition-colors flex items-center gap-1"
>
💬 WhatsApp
</a>
)}
</div>
</div>
)
})}