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;
|
id: number;
|
||||||
questionnaire_id: number;
|
questionnaire_id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
phone: string | null;
|
||||||
token: string;
|
token: string;
|
||||||
created_at: 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(`
|
db.exec(`
|
||||||
CREATE INDEX IF NOT EXISTS idx_participants_questionnaire ON participants(questionnaire_id);
|
CREATE INDEX IF NOT EXISTS idx_participants_questionnaire ON participants(questionnaire_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_participants_token ON participants(token);
|
CREATE INDEX IF NOT EXISTS idx_participants_token ON participants(token);
|
||||||
@@ -308,10 +316,10 @@ export const questionnaireOps = {
|
|||||||
|
|
||||||
// Participant operations
|
// Participant operations
|
||||||
export const participantOps = {
|
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(
|
const result = db.prepare(
|
||||||
'INSERT INTO participants (questionnaire_id, name, token) VALUES (?, ?, ?)'
|
'INSERT INTO participants (questionnaire_id, name, phone, token) VALUES (?, ?, ?, ?)'
|
||||||
).run(questionnaireId, name, token);
|
).run(questionnaireId, name, phone, token);
|
||||||
return result.lastInsertRowid as number;
|
return result.lastInsertRowid as number;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ function generateToken(): string {
|
|||||||
|
|
||||||
// Add participant to questionnaire
|
// Add participant to questionnaire
|
||||||
router.post('/questionnaires/:id/participants', (req: Request, res: Response) => {
|
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));
|
const questionnaire = questionnaireOps.findById(parseInt(req.params.id));
|
||||||
|
|
||||||
if (!questionnaire) {
|
if (!questionnaire) {
|
||||||
@@ -210,13 +210,16 @@ router.post('/questionnaires/:id/participants', (req: Request, res: Response) =>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean phone number (remove spaces, keep + and digits)
|
||||||
|
const cleanPhone = phone?.trim() ? phone.trim().replace(/[^\d+]/g, '') : null;
|
||||||
|
|
||||||
// Generate unique token
|
// Generate unique token
|
||||||
let token = generateToken();
|
let token = generateToken();
|
||||||
while (!participantOps.isTokenAvailable(token)) {
|
while (!participantOps.isTokenAvailable(token)) {
|
||||||
token = generateToken();
|
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);
|
const participant = participantOps.findById(id);
|
||||||
|
|
||||||
res.json({ success: true, participant });
|
res.json({ success: true, participant });
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ interface Activity {
|
|||||||
interface Participant {
|
interface Participant {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
|
phone: string | null
|
||||||
token: string
|
token: string
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
@@ -38,6 +39,7 @@ export function QuestionnaireDetail() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [copied, setCopied] = useState('')
|
const [copied, setCopied] = useState('')
|
||||||
const [newParticipantName, setNewParticipantName] = useState('')
|
const [newParticipantName, setNewParticipantName] = useState('')
|
||||||
|
const [newParticipantPhone, setNewParticipantPhone] = useState('')
|
||||||
const [addingParticipant, setAddingParticipant] = useState(false)
|
const [addingParticipant, setAddingParticipant] = useState(false)
|
||||||
|
|
||||||
// Edit activity modal state
|
// Edit activity modal state
|
||||||
@@ -89,11 +91,15 @@ export function QuestionnaireDetail() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: JSON.stringify({ name: newParticipantName.trim() }),
|
body: JSON.stringify({
|
||||||
|
name: newParticipantName.trim(),
|
||||||
|
phone: newParticipantPhone.trim() || null
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
setNewParticipantName('')
|
setNewParticipantName('')
|
||||||
|
setNewParticipantPhone('')
|
||||||
fetchParticipants()
|
fetchParticipants()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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) {
|
async function handleDeleteParticipant(participantId: number) {
|
||||||
if (!confirm('Deze deelnemer verwijderen?')) return
|
if (!confirm('Deze deelnemer verwijderen?')) return
|
||||||
|
|
||||||
@@ -256,15 +292,22 @@ export function QuestionnaireDetail() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Add Participant Form */}
|
{/* Add Participant Form */}
|
||||||
<form onSubmit={handleAddParticipant} className="flex gap-2 mb-4">
|
<form onSubmit={handleAddParticipant} className="flex flex-wrap gap-2 mb-4">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={newParticipantName}
|
value={newParticipantName}
|
||||||
onChange={(e) => setNewParticipantName(e.target.value)}
|
onChange={(e) => setNewParticipantName(e.target.value)}
|
||||||
placeholder="Naam deelnemer"
|
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
|
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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={addingParticipant}
|
disabled={addingParticipant}
|
||||||
@@ -276,27 +319,53 @@ export function QuestionnaireDetail() {
|
|||||||
|
|
||||||
{/* Participants List */}
|
{/* Participants List */}
|
||||||
{participants.length > 0 ? (
|
{participants.length > 0 ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
{participants.map((participant) => {
|
{participants.map((participant) => {
|
||||||
const participantUrl = `${window.location.origin}/q/access/${participant.token}`
|
const participantUrl = `${window.location.origin}/q/access/${participant.token}`
|
||||||
return (
|
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-1 min-w-0">
|
<div className="flex items-start gap-3">
|
||||||
<div className="font-medium text-text text-sm">{participant.name}</div>
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-xs font-mono text-text-faint truncate">{participantUrl}</div>
|
<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>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
Reference in New Issue
Block a user