Add phone number field and WhatsApp/copy message buttons for participants
This commit is contained in:
@@ -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;
|
||||
},
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 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>
|
||||
<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>
|
||||
{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={() => 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"
|
||||
>
|
||||
✕
|
||||
</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={() => 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"
|
||||
>
|
||||
📋 {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>
|
||||
<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"
|
||||
>
|
||||
{copied === `p-${participant.id}` ? 'Gekopieerd!' : 'Link Kopiëren'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteParticipant(participant.id)}
|
||||
className="px-2 py-1 text-xs font-medium text-danger hover:bg-danger-muted rounded transition-colors"
|
||||
>
|
||||
Verwijderen
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user