From ed19657b9459d0c0e3e2ce917116b0dddb21af1a Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Mon, 16 Mar 2026 15:08:28 +0530 Subject: [PATCH] feat: add Assign, WhatsApp Send, Mark Spam, Merge modals and Lead Activity slideout Co-Authored-By: Claude Opus 4.6 (1M context) --- .../leads/lead-activity-slideout.tsx | 150 ++++++++++++++ src/components/modals/assign-modal.tsx | 152 ++++++++++++++ src/components/modals/mark-spam-modal.tsx | 141 +++++++++++++ src/components/modals/merge-modal.tsx | 191 ++++++++++++++++++ src/components/modals/whatsapp-send-modal.tsx | 163 +++++++++++++++ 5 files changed, 797 insertions(+) create mode 100644 src/components/leads/lead-activity-slideout.tsx create mode 100644 src/components/modals/assign-modal.tsx create mode 100644 src/components/modals/mark-spam-modal.tsx create mode 100644 src/components/modals/merge-modal.tsx create mode 100644 src/components/modals/whatsapp-send-modal.tsx diff --git a/src/components/leads/lead-activity-slideout.tsx b/src/components/leads/lead-activity-slideout.tsx new file mode 100644 index 0000000..43166a9 --- /dev/null +++ b/src/components/leads/lead-activity-slideout.tsx @@ -0,0 +1,150 @@ +import { SlideoutMenu } from '@/components/application/slideout-menus/slideout-menu'; +import { formatPhone, formatShortDate } from '@/lib/format'; +import type { Lead, LeadActivity, LeadActivityType } from '@/types/entities'; +import { cx } from '@/utils/cx'; + +type LeadActivitySlideoutProps = { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + lead: Lead; + activities: LeadActivity[]; +}; + +type ActivityConfig = { + icon: string; + dotClass: string; + label: string; +}; + +const ACTIVITY_CONFIG: Record = { + STATUS_CHANGE: { icon: '๐Ÿ”„', dotClass: 'bg-brand-secondary', label: 'Status Changed' }, + CALL_MADE: { icon: '๐Ÿ“ž', dotClass: 'bg-brand-secondary', label: 'Call Made' }, + CALL_RECEIVED: { icon: '๐Ÿ“ฒ', dotClass: 'bg-brand-secondary', label: 'Call Received' }, + WHATSAPP_SENT: { icon: '๐Ÿ’ฌ', dotClass: 'bg-success-solid', label: 'WhatsApp Sent' }, + WHATSAPP_RECEIVED: { icon: '๐Ÿ’ฌ', dotClass: 'bg-success-solid', label: 'WhatsApp Received' }, + SMS_SENT: { icon: 'โœ‰๏ธ', dotClass: 'bg-brand-secondary', label: 'SMS Sent' }, + EMAIL_SENT: { icon: '๐Ÿ“ง', dotClass: 'bg-brand-secondary', label: 'Email Sent' }, + EMAIL_RECEIVED: { icon: '๐Ÿ“ง', dotClass: 'bg-brand-secondary', label: 'Email Received' }, + NOTE_ADDED: { icon: '๐Ÿ“', dotClass: 'bg-warning-solid', label: 'Note Added' }, + ASSIGNED: { icon: '๐Ÿ“ค', dotClass: 'bg-brand-secondary', label: 'Assigned' }, + APPOINTMENT_BOOKED: { icon: '๐Ÿ“…', dotClass: 'bg-brand-secondary', label: 'Appointment Booked' }, + FOLLOW_UP_CREATED: { icon: '๐Ÿ”', dotClass: 'bg-brand-secondary', label: 'Follow-up Created' }, + CONVERTED: { icon: 'โœ…', dotClass: 'bg-success-solid', label: 'Converted' }, + MARKED_SPAM: { icon: '๐Ÿšซ', dotClass: 'bg-error-solid', label: 'Marked as Spam' }, + DUPLICATE_DETECTED: { icon: '๐Ÿ”', dotClass: 'bg-warning-solid', label: 'Duplicate Detected' }, +}; + +const DEFAULT_CONFIG: ActivityConfig = { icon: '๐Ÿ“Œ', dotClass: 'bg-tertiary', label: 'Activity' }; + +const StatusChangeContent = ({ previousValue, newValue }: { previousValue: string | null; newValue: string | null }) => ( + + {previousValue && ( + {previousValue} + )} + {previousValue && newValue && 'โ†’ '} + {newValue && {newValue}} + +); + +const ActivityItem = ({ activity, isLast }: { activity: LeadActivity; isLast: boolean }) => { + const type = activity.activityType; + const config = type ? (ACTIVITY_CONFIG[type] ?? DEFAULT_CONFIG) : DEFAULT_CONFIG; + const occurredAt = activity.occurredAt ? formatShortDate(activity.occurredAt) : ''; + + return ( +
+ {/* Vertical connecting line */} + {!isLast && ( +
+ )} + + {/* Dot */} + + + {/* Content */} +
+
+ + {activity.summary ?? config.label} + +
+ + {type === 'STATUS_CHANGE' && (activity.previousValue || activity.newValue) && ( + + )} + + {type !== 'STATUS_CHANGE' && activity.newValue && ( +

{activity.newValue}

+ )} + +

+ {occurredAt} + {activity.performedBy ? ` ยท by ${activity.performedBy}` : ''} +

+
+
+ ); +}; + +export const LeadActivitySlideout = ({ isOpen, onOpenChange, lead, activities }: LeadActivitySlideoutProps) => { + const firstName = lead.contactName?.firstName ?? ''; + const lastName = lead.contactName?.lastName ?? ''; + const fullName = `${firstName} ${lastName}`.trim() || 'Unknown Lead'; + const phone = lead.contactPhone?.[0] ? formatPhone(lead.contactPhone[0]) : null; + const email = lead.contactEmail?.[0]?.address ?? null; + + const filteredActivities = activities + .filter((a) => a.leadId === lead.id) + .sort((a, b) => { + if (!a.occurredAt) return 1; + if (!b.occurredAt) return -1; + return new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime(); + }); + + return ( + + {({ close }) => ( + <> + +
+

Lead Activity โ€” {fullName}

+

+ {[phone, email, lead.leadSource, lead.utmCampaign] + .filter(Boolean) + .join(' ยท ')} +

+
+
+ + + {filteredActivities.length === 0 ? ( +
+ ๐Ÿ“ญ +

No activity yet

+

Activity will appear here as interactions occur.

+
+ ) : ( +
+ {filteredActivities.map((activity, idx) => ( + + ))} +
+ )} +
+ + )} +
+ ); +}; diff --git a/src/components/modals/assign-modal.tsx b/src/components/modals/assign-modal.tsx new file mode 100644 index 0000000..b504b7f --- /dev/null +++ b/src/components/modals/assign-modal.tsx @@ -0,0 +1,152 @@ +import { useState } from 'react'; +import { Button } from '@/components/base/buttons/button'; +import { Dialog, Modal, ModalOverlay } from '@/components/application/modals/modal'; +import { getInitials } from '@/lib/format'; +import type { Agent, Lead } from '@/types/entities'; +import { cx } from '@/utils/cx'; + +type AssignModalProps = { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + selectedLeads: Lead[]; + agents: Agent[]; + onAssign: (agentId: string) => void; +}; + +const agentColors = ['bg-brand-secondary', 'bg-success-solid', 'bg-warning-solid', 'bg-error-solid']; + +export const AssignModal = ({ isOpen, onOpenChange, selectedLeads, agents, onAssign }: AssignModalProps) => { + const [selectedAgentId, setSelectedAgentId] = useState(null); + const [removedLeadIds, setRemovedLeadIds] = useState>(new Set()); + + const visibleLeads = selectedLeads.filter((l) => !removedLeadIds.has(l.id)); + const selectedAgent = agents.find((a) => a.id === selectedAgentId); + + const handleRemoveLead = (leadId: string) => { + setRemovedLeadIds((prev) => new Set([...prev, leadId])); + }; + + const handleAssign = () => { + if (selectedAgentId) { + onAssign(selectedAgentId); + onOpenChange(false); + } + }; + + const getLeadName = (lead: Lead) => { + if (lead.contactName) { + return `${lead.contactName.firstName} ${lead.contactName.lastName}`.trim(); + } + return 'Unknown'; + }; + + return ( + + + + {() => ( +
+ {/* Header */} +
+

Assign to Call Center

+

+ Assign {visibleLeads.length} selected {visibleLeads.length === 1 ? 'lead' : 'leads'} to a call center agent for outbound calling. +

+
+ + {/* Body */} +
+ {/* Recipient chips */} + {visibleLeads.length > 0 && ( +
+ {visibleLeads.map((lead) => ( + + {getLeadName(lead)} + + + ))} +
+ )} + + {/* Agent selection */} +
+

Select agent

+ {agents.map((agent, idx) => { + const isSelected = selectedAgentId === agent.id; + const colorClass = agentColors[idx % agentColors.length]; + return ( + + ); + })} +
+ + {/* Warning block */} +
+

โš  This action cannot be undone

+

+ Once assigned, the lead status will change to Contacted and the agent will be notified immediately. +

+
+
+ + {/* Footer */} +
+ + +
+
+ )} +
+
+
+ ); +}; diff --git a/src/components/modals/mark-spam-modal.tsx b/src/components/modals/mark-spam-modal.tsx new file mode 100644 index 0000000..582cc8b --- /dev/null +++ b/src/components/modals/mark-spam-modal.tsx @@ -0,0 +1,141 @@ +import { Dialog, Modal, ModalOverlay } from '@/components/application/modals/modal'; +import { Avatar } from '@/components/base/avatar/avatar'; +import { Button } from '@/components/base/buttons/button'; +import { formatPhone, getInitials } from '@/lib/format'; +import type { Lead } from '@/types/entities'; + +type MarkSpamModalProps = { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + lead: Lead; + onConfirm: () => void; +}; + +type SpamFactor = { + icon: string; + description: string; + score: number; +}; + +const computeSpamFactors = (lead: Lead): SpamFactor[] => { + const factors: SpamFactor[] = []; + + const firstName = lead.contactName?.firstName ?? ''; + const lastName = lead.contactName?.lastName ?? ''; + if (firstName.length < 3 && lastName.length < 3) { + factors.push({ icon: '๐Ÿ‘ค', description: 'Name is unusually short (under 3 characters each)', score: 30 }); + } + + if (!lead.contactEmail || lead.contactEmail.length === 0) { + factors.push({ icon: '๐Ÿ“ง', description: 'No email address provided', score: 10 }); + } + + if (lead.createdAt) { + const hour = new Date(lead.createdAt).getHours(); + if (hour >= 0 && hour < 5) { + factors.push({ icon: '๐ŸŒ™', description: 'Submitted during late night hours (12โ€“5 AM)', score: 20 }); + } + } + + if (lead.contactAttempts !== null && lead.contactAttempts === 0 && lead.createdAt) { + factors.push({ icon: 'โšก', description: 'Submitted with no contact attempts โ€” possible bot', score: 18 }); + } + + if (!lead.campaignId) { + factors.push({ icon: '๐Ÿ“Š', description: 'No associated campaign or ad source', score: 10 }); + } + + return factors.filter((f) => f.score > 0); +}; + +export const MarkSpamModal = ({ isOpen, onOpenChange, lead, onConfirm }: MarkSpamModalProps) => { + const factors = computeSpamFactors(lead); + const computedScore = Math.min(100, factors.reduce((acc, f) => acc + f.score, 0)); + const displayScore = lead.spamScore ?? computedScore; + + const firstName = lead.contactName?.firstName ?? ''; + const lastName = lead.contactName?.lastName ?? ''; + const fullName = `${firstName} ${lastName}`.trim() || 'Unknown Lead'; + const initials = getInitials(firstName || 'U', lastName || 'L'); + const phone = lead.contactPhone?.[0] ? formatPhone(lead.contactPhone[0]) : 'โ€”'; + const email = lead.contactEmail?.[0]?.address ?? 'โ€”'; + const source = lead.leadSource ?? 'โ€”'; + + const handleConfirm = () => { + onConfirm(); + onOpenChange(false); + }; + + return ( + + + + {() => ( +
+ {/* Header */} +
+

Mark as Spam

+

+ Review spam indicators before marking this lead. +

+
+ + {/* Body */} +
+ {/* Lead summary */} +
+ +
+ {fullName} + {phone} + {email} + Source: {source} +
+
+ {displayScore} + spam score +
+
+ + {/* Spam factors */} + {factors.length > 0 && ( +
+

Spam indicators

+ {factors.map((factor, idx) => ( +
+ {factor.icon} + {factor.description} + +{factor.score} +
+ ))} +
+ )} + + {/* Info block */} +
+

This can be undone

+

+ You can unmark this lead as spam later from the lead details panel or by filtering the Spam queue. +

+
+
+ + {/* Footer */} +
+ + +
+
+ )} +
+
+
+ ); +}; diff --git a/src/components/modals/merge-modal.tsx b/src/components/modals/merge-modal.tsx new file mode 100644 index 0000000..d625803 --- /dev/null +++ b/src/components/modals/merge-modal.tsx @@ -0,0 +1,191 @@ +import { Dialog, Modal, ModalOverlay } from '@/components/application/modals/modal'; +import { Button } from '@/components/base/buttons/button'; +import { formatPhone, formatShortDate } from '@/lib/format'; +import type { Lead } from '@/types/entities'; +import { cx } from '@/utils/cx'; + +type MergeModalProps = { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + primaryLead: Lead; + duplicateLead: Lead; + onMerge: () => void; + onKeepSeparate: () => void; +}; + +type FieldRow = { + label: string; + primary: string; + duplicate: string; +}; + +const getLeadName = (lead: Lead) => { + if (lead.contactName) { + return `${lead.contactName.firstName} ${lead.contactName.lastName}`.trim(); + } + return 'โ€”'; +}; + +const getLeadPhone = (lead: Lead) => { + if (lead.contactPhone && lead.contactPhone.length > 0) { + return formatPhone(lead.contactPhone[0]); + } + return 'โ€”'; +}; + +const getLeadEmail = (lead: Lead) => lead.contactEmail?.[0]?.address ?? 'โ€”'; + +const buildFieldRows = (primary: Lead, duplicate: Lead): FieldRow[] => [ + { + label: 'Name', + primary: getLeadName(primary), + duplicate: getLeadName(duplicate), + }, + { + label: 'Phone', + primary: getLeadPhone(primary), + duplicate: getLeadPhone(duplicate), + }, + { + label: 'Email', + primary: getLeadEmail(primary), + duplicate: getLeadEmail(duplicate), + }, + { + label: 'Source', + primary: primary.leadSource ?? 'โ€”', + duplicate: duplicate.leadSource ?? 'โ€”', + }, + { + label: 'Campaign', + primary: primary.campaignId ?? 'โ€”', + duplicate: duplicate.campaignId ?? 'โ€”', + }, + { + label: 'Created', + primary: primary.createdAt ? formatShortDate(primary.createdAt) : 'โ€”', + duplicate: duplicate.createdAt ? formatShortDate(duplicate.createdAt) : 'โ€”', + }, + { + label: 'Status', + primary: primary.leadStatus ?? 'โ€”', + duplicate: duplicate.leadStatus ?? 'โ€”', + }, +]; + +const LeadCard = ({ + label, + isPrimary, + fieldRows, +}: { + label: string; + isPrimary: boolean; + fieldRows: FieldRow[]; +}) => ( +
+

+ {label} +

+
+ {fieldRows.map((row) => { + const value = isPrimary ? row.primary : row.duplicate; + const conflicts = row.primary !== row.duplicate; + return ( +
+ {row.label} + + {value} + +
+ ); + })} +
+
+); + +export const MergeModal = ({ isOpen, onOpenChange, primaryLead, duplicateLead, onMerge, onKeepSeparate }: MergeModalProps) => { + const fieldRows = buildFieldRows(primaryLead, duplicateLead); + const primaryName = getLeadName(primaryLead); + + const handleMerge = () => { + onMerge(); + onOpenChange(false); + }; + + const handleKeepSeparate = () => { + onKeepSeparate(); + onOpenChange(false); + }; + + return ( + + + + {() => ( +
+ {/* Header */} +
+

Merge Duplicate Leads

+

+ Compare and merge two leads with the same phone number. +

+
+ + {/* Body */} +
+ {/* Side-by-side comparison */} +
+ + +
+ โ†’ +
+ + +
+ + {/* Note */} +

+ Fields shown in amber differ between records. The primary lead's values will be preserved after merging. +

+
+ + {/* Footer */} +
+ + +
+
+ )} +
+
+
+ ); +}; diff --git a/src/components/modals/whatsapp-send-modal.tsx b/src/components/modals/whatsapp-send-modal.tsx new file mode 100644 index 0000000..5438c3c --- /dev/null +++ b/src/components/modals/whatsapp-send-modal.tsx @@ -0,0 +1,163 @@ +import { useState } from 'react'; +import { Select } from '@/components/base/select/select'; +import { Button } from '@/components/base/buttons/button'; +import { Dialog, Modal, ModalOverlay } from '@/components/application/modals/modal'; +import { formatPhone } from '@/lib/format'; +import type { Lead, WhatsAppTemplate } from '@/types/entities'; +import { cx } from '@/utils/cx'; + +type WhatsAppSendModalProps = { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + selectedLeads: Lead[]; + templates: WhatsAppTemplate[]; + onSend: (templateId: string) => void; +}; + +const getLeadName = (lead: Lead) => { + if (lead.contactName) { + return `${lead.contactName.firstName} ${lead.contactName.lastName}`.trim(); + } + return 'Unknown'; +}; + +const getLeadPhone = (lead: Lead) => { + if (lead.contactPhone && lead.contactPhone.length > 0) { + return formatPhone(lead.contactPhone[0]); + } + return ''; +}; + +const populateVariables = (body: string, lead: Lead) => { + const name = getLeadName(lead); + return body + .replace(/\{\{name\}\}/gi, name) + .replace(/\{\{first_name\}\}/gi, lead.contactName?.firstName ?? name) + .replace(/\{\{service\}\}/gi, lead.interestedService ?? 'our services'); +}; + +const highlightVariables = (text: string) => { + const parts = text.split(/(\{\{[^}]+\}\})/g); + return parts.map((part, i) => { + if (/^\{\{[^}]+\}\}$/.test(part)) { + return ( + + {part} + + ); + } + return {part}; + }); +}; + +export const WhatsAppSendModal = ({ isOpen, onOpenChange, selectedLeads, templates, onSend }: WhatsAppSendModalProps) => { + const approvedTemplates = templates.filter((t) => t.approvalStatus === 'APPROVED'); + const [selectedTemplateId, setSelectedTemplateId] = useState(approvedTemplates[0]?.id ?? ''); + + const selectedTemplate = templates.find((t) => t.id === selectedTemplateId); + const previewLead = selectedLeads[0]; + + const selectItems = approvedTemplates.map((t) => ({ + id: t.id, + label: t.name ?? 'Untitled Template', + supportingText: t.linkedCampaignName ?? undefined, + })); + + const handleSend = () => { + if (selectedTemplateId) { + onSend(selectedTemplateId); + onOpenChange(false); + } + }; + + return ( + + + + {() => ( +
+ {/* Header */} +
+

Send WhatsApp Message

+

+ Send a template message to {selectedLeads.length} selected{' '} + {selectedLeads.length === 1 ? 'lead' : 'leads'} via WhatsApp. +

+
+ + {/* Body */} +
+ {/* Recipient chips */} +
+ {selectedLeads.map((lead) => ( + + {getLeadName(lead)} + {getLeadPhone(lead) && ( + ยท {getLeadPhone(lead)} + )} + + ))} +
+ + {/* Template select */} + + + {/* Preview */} + {selectedTemplate && ( +
+

Preview

+
+
+

+ {previewLead + ? highlightVariables(populateVariables(selectedTemplate.body ?? '', previewLead)) + : highlightVariables(selectedTemplate.body ?? '')} +

+

12:00 PM โœ“โœ“

+
+
+

+ Variables auto-populated from lead data. Template is pre-approved by WhatsApp. +

+
+ )} +
+ + {/* Footer */} +
+ + +
+
+ )} +
+
+
+ ); +};