feat: add Assign, WhatsApp Send, Mark Spam, Merge modals and Lead Activity slideout

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-16 15:08:28 +05:30
parent db2e88c1e7
commit ed19657b94
5 changed files with 797 additions and 0 deletions

View File

@@ -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<LeadActivityType, ActivityConfig> = {
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 }) => (
<span className="text-sm text-secondary">
{previousValue && (
<span className="mr-1 text-sm line-through text-quaternary">{previousValue}</span>
)}
{previousValue && newValue && '→ '}
{newValue && <span className="font-medium text-brand-secondary">{newValue}</span>}
</span>
);
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 (
<div className="relative flex gap-3 pb-4">
{/* Vertical connecting line */}
{!isLast && (
<div className="absolute left-[15px] top-[36px] bottom-0 w-0.5 bg-tertiary" />
)}
{/* Dot */}
<div
className={cx(
'relative z-10 flex size-8 shrink-0 items-center justify-center rounded-full text-sm',
config.dotClass,
)}
aria-hidden="true"
>
{config.icon}
</div>
{/* Content */}
<div className="flex flex-1 flex-col gap-0.5 pt-1 min-w-0">
<div className="flex items-start gap-2">
<span className="flex-1 text-sm font-semibold text-primary">
{activity.summary ?? config.label}
</span>
</div>
{type === 'STATUS_CHANGE' && (activity.previousValue || activity.newValue) && (
<StatusChangeContent previousValue={activity.previousValue} newValue={activity.newValue} />
)}
{type !== 'STATUS_CHANGE' && activity.newValue && (
<p className="text-xs text-tertiary">{activity.newValue}</p>
)}
<p className="text-xs text-quaternary">
{occurredAt}
{activity.performedBy ? ` · by ${activity.performedBy}` : ''}
</p>
</div>
</div>
);
};
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 (
<SlideoutMenu isOpen={isOpen} onOpenChange={onOpenChange} isDismissable>
{({ close }) => (
<>
<SlideoutMenu.Header onClose={close}>
<div className="flex flex-col gap-0.5 pr-8">
<h2 className="text-lg font-semibold text-primary">Lead Activity {fullName}</h2>
<p className="text-sm text-tertiary">
{[phone, email, lead.leadSource, lead.utmCampaign]
.filter(Boolean)
.join(' · ')}
</p>
</div>
</SlideoutMenu.Header>
<SlideoutMenu.Content>
{filteredActivities.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-12 text-center">
<span className="text-3xl">📭</span>
<p className="text-sm font-medium text-secondary">No activity yet</p>
<p className="text-xs text-tertiary">Activity will appear here as interactions occur.</p>
</div>
) : (
<div className="flex flex-col">
{filteredActivities.map((activity, idx) => (
<ActivityItem
key={activity.id}
activity={activity}
isLast={idx === filteredActivities.length - 1}
/>
))}
</div>
)}
</SlideoutMenu.Content>
</>
)}
</SlideoutMenu>
);
};

View File

@@ -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<string | null>(null);
const [removedLeadIds, setRemovedLeadIds] = useState<Set<string>>(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 (
<ModalOverlay isOpen={isOpen} onOpenChange={onOpenChange} isDismissable>
<Modal className="sm:max-w-lg">
<Dialog>
{() => (
<div className="flex w-full flex-col gap-0 rounded-xl bg-primary shadow-xl ring-1 ring-secondary overflow-hidden">
{/* Header */}
<div className="px-6 pt-6 pb-4 border-b border-secondary">
<h2 className="text-lg font-semibold text-primary">Assign to Call Center</h2>
<p className="mt-1 text-sm text-tertiary">
Assign {visibleLeads.length} selected {visibleLeads.length === 1 ? 'lead' : 'leads'} to a call center agent for outbound calling.
</p>
</div>
{/* Body */}
<div className="flex flex-col gap-4 px-6 py-4 overflow-y-auto max-h-[60vh]">
{/* Recipient chips */}
{visibleLeads.length > 0 && (
<div className="flex flex-wrap gap-2">
{visibleLeads.map((lead) => (
<span
key={lead.id}
className="flex items-center gap-1.5 rounded-full border border-brand bg-brand-primary px-3 py-1 text-xs text-brand-secondary"
>
{getLeadName(lead)}
<button
type="button"
onClick={() => handleRemoveLead(lead.id)}
className="ml-0.5 flex items-center justify-center rounded-full text-brand-secondary hover:text-brand-secondary_hover transition duration-100 ease-linear"
aria-label={`Remove ${getLeadName(lead)}`}
>
×
</button>
</span>
))}
</div>
)}
{/* Agent selection */}
<div className="flex flex-col gap-2">
<p className="text-sm font-medium text-secondary">Select agent</p>
{agents.map((agent, idx) => {
const isSelected = selectedAgentId === agent.id;
const colorClass = agentColors[idx % agentColors.length];
return (
<button
key={agent.id}
type="button"
onClick={() => setSelectedAgentId(agent.id)}
className={cx(
'flex w-full cursor-pointer items-center gap-3 rounded-xl border-2 p-3 text-left transition duration-100 ease-linear',
isSelected
? 'border-brand bg-brand-primary'
: 'border-secondary hover:border-brand/50',
)}
>
<div
className={cx(
'flex size-9 shrink-0 items-center justify-center rounded-full text-sm font-semibold text-white',
colorClass,
)}
>
{agent.initials ?? getInitials(agent.name?.split(' ')[0] ?? '', agent.name?.split(' ')[1] ?? '')}
</div>
<div className="flex flex-1 flex-col min-w-0">
<span className="text-sm font-semibold text-primary truncate">{agent.name}</span>
<span className="text-xs text-tertiary truncate">
{agent.isOnShift ? '🟢 On shift' : '⚫ Off shift'} · Avg {agent.avgResponseHours}h response
</span>
</div>
<div className="flex flex-col items-end shrink-0">
<span className="text-lg font-bold text-primary">{agent.activeLeadCount ?? 0}</span>
<span className="text-xs text-quaternary">Active leads</span>
</div>
</button>
);
})}
</div>
{/* Warning block */}
<div className="rounded-xl border border-warning bg-warning-primary p-3.5">
<p className="text-xs font-bold text-warning-primary"> This action cannot be undone</p>
<p className="mt-0.5 text-xs text-warning-primary/80">
Once assigned, the lead status will change to Contacted and the agent will be notified immediately.
</p>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 border-t border-secondary px-6 py-4">
<Button size="md" color="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
size="md"
color="primary"
isDisabled={!selectedAgentId || visibleLeads.length === 0}
onClick={handleAssign}
>
Assign {visibleLeads.length} {visibleLeads.length === 1 ? 'Lead' : 'Leads'}
{selectedAgent ? ` to ${selectedAgent.name}` : ''}
</Button>
</div>
</div>
)}
</Dialog>
</Modal>
</ModalOverlay>
);
};

View File

@@ -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 (125 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 (
<ModalOverlay isOpen={isOpen} onOpenChange={onOpenChange} isDismissable>
<Modal className="sm:max-w-lg">
<Dialog>
{() => (
<div className="flex w-full flex-col rounded-xl bg-primary shadow-xl ring-1 ring-secondary overflow-hidden">
{/* Header */}
<div className="px-6 pt-6 pb-4 border-b border-secondary">
<h2 className="text-lg font-semibold text-primary">Mark as Spam</h2>
<p className="mt-1 text-sm text-tertiary">
Review spam indicators before marking this lead.
</p>
</div>
{/* Body */}
<div className="flex flex-col gap-4 px-6 py-4 overflow-y-auto max-h-[65vh]">
{/* Lead summary */}
<div className="flex items-center gap-3 rounded-xl bg-error-primary p-4">
<Avatar initials={initials} size="md" />
<div className="flex flex-1 flex-col min-w-0">
<span className="text-sm font-semibold text-primary truncate">{fullName}</span>
<span className="text-xs text-tertiary truncate">{phone}</span>
<span className="text-xs text-tertiary truncate">{email}</span>
<span className="text-xs text-quaternary">Source: {source}</span>
</div>
<div className="flex shrink-0 flex-col items-center">
<span className="text-display-xs font-bold text-error-primary">{displayScore}</span>
<span className="text-xs text-error-primary/70">spam score</span>
</div>
</div>
{/* Spam factors */}
{factors.length > 0 && (
<div className="flex flex-col gap-2">
<p className="text-sm font-medium text-secondary">Spam indicators</p>
{factors.map((factor, idx) => (
<div
key={idx}
className="flex items-center gap-2 rounded-lg bg-secondary p-3"
>
<span className="text-base">{factor.icon}</span>
<span className="flex-1 text-xs text-secondary">{factor.description}</span>
<span className="shrink-0 text-xs font-bold text-error-primary">+{factor.score}</span>
</div>
))}
</div>
)}
{/* Info block */}
<div className="rounded-xl border border-error_subtle bg-error-primary/50 p-3.5">
<p className="text-xs font-bold text-primary">This can be undone</p>
<p className="mt-0.5 text-xs text-secondary">
You can unmark this lead as spam later from the lead details panel or by filtering the Spam queue.
</p>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 border-t border-secondary px-6 py-4">
<Button size="md" color="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button size="md" color="primary-destructive" onClick={handleConfirm}>
Mark as Spam
</Button>
</div>
</div>
)}
</Dialog>
</Modal>
</ModalOverlay>
);
};

View File

@@ -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[];
}) => (
<div
className={cx(
'flex-1 rounded-xl border-2 p-4 flex flex-col gap-3',
isPrimary ? 'border-brand' : 'border-secondary',
)}
>
<p
className={cx(
'text-xs font-bold uppercase tracking-wide',
isPrimary ? 'text-brand-secondary' : 'text-quaternary',
)}
>
{label}
</p>
<div className="flex flex-col gap-2">
{fieldRows.map((row) => {
const value = isPrimary ? row.primary : row.duplicate;
const conflicts = row.primary !== row.duplicate;
return (
<div key={row.label} className="flex flex-col gap-0.5">
<span className="text-xs text-quaternary">{row.label}</span>
<span
className={cx(
'text-xs break-words',
!isPrimary && conflicts ? 'font-semibold text-warning-primary' : 'text-primary',
)}
>
{value}
</span>
</div>
);
})}
</div>
</div>
);
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 (
<ModalOverlay isOpen={isOpen} onOpenChange={onOpenChange} isDismissable>
<Modal className="sm:max-w-2xl">
<Dialog>
{() => (
<div className="flex w-full flex-col rounded-xl bg-primary shadow-xl ring-1 ring-secondary overflow-hidden">
{/* Header */}
<div className="px-6 pt-6 pb-4 border-b border-secondary">
<h2 className="text-lg font-semibold text-primary">Merge Duplicate Leads</h2>
<p className="mt-1 text-sm text-tertiary">
Compare and merge two leads with the same phone number.
</p>
</div>
{/* Body */}
<div className="flex flex-col gap-4 px-6 py-4 overflow-y-auto max-h-[70vh]">
{/* Side-by-side comparison */}
<div className="flex items-start gap-2">
<LeadCard
label="Keep (Primary)"
isPrimary={true}
fieldRows={fieldRows}
/>
<div className="flex shrink-0 items-center self-center px-1 pt-6">
<span className="text-xl text-quaternary"></span>
</div>
<LeadCard
label="Merge Into Primary"
isPrimary={false}
fieldRows={fieldRows}
/>
</div>
{/* Note */}
<p className="text-xs text-quaternary">
Fields shown in amber differ between records. The primary lead's values will be preserved after merging.
</p>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 border-t border-secondary px-6 py-4">
<Button size="md" color="secondary" onClick={handleKeepSeparate}>
Keep Separate
</Button>
<Button size="md" color="primary" onClick={handleMerge}>
Merge Keep {primaryName}
</Button>
</div>
</div>
)}
</Dialog>
</Modal>
</ModalOverlay>
);
};

View File

@@ -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 (
<span key={i} className="rounded bg-success-secondary px-1 font-semibold text-success-primary">
{part}
</span>
);
}
return <span key={i}>{part}</span>;
});
};
export const WhatsAppSendModal = ({ isOpen, onOpenChange, selectedLeads, templates, onSend }: WhatsAppSendModalProps) => {
const approvedTemplates = templates.filter((t) => t.approvalStatus === 'APPROVED');
const [selectedTemplateId, setSelectedTemplateId] = useState<string>(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 (
<ModalOverlay isOpen={isOpen} onOpenChange={onOpenChange} isDismissable>
<Modal className="sm:max-w-lg">
<Dialog>
{() => (
<div className="flex w-full flex-col rounded-xl bg-primary shadow-xl ring-1 ring-secondary overflow-hidden">
{/* Header */}
<div className="px-6 pt-6 pb-4 border-b border-secondary">
<h2 className="text-lg font-semibold text-primary">Send WhatsApp Message</h2>
<p className="mt-1 text-sm text-tertiary">
Send a template message to {selectedLeads.length} selected{' '}
{selectedLeads.length === 1 ? 'lead' : 'leads'} via WhatsApp.
</p>
</div>
{/* Body */}
<div className="flex flex-col gap-4 px-6 py-4 overflow-y-auto max-h-[65vh]">
{/* Recipient chips */}
<div className="flex flex-wrap gap-2">
{selectedLeads.map((lead) => (
<span
key={lead.id}
className="flex items-center gap-1 rounded-full border border-brand bg-brand-primary px-3 py-1 text-xs text-brand-secondary"
>
<span className="font-medium">{getLeadName(lead)}</span>
{getLeadPhone(lead) && (
<span className="text-brand-tertiary">· {getLeadPhone(lead)}</span>
)}
</span>
))}
</div>
{/* Template select */}
<Select
label="Template"
placeholder="Select a template"
size="sm"
items={selectItems}
selectedKey={selectedTemplateId}
onSelectionChange={(key) => setSelectedTemplateId(String(key))}
>
{(item) => (
<Select.Item id={item.id} supportingText={item.supportingText}>
{item.label}
</Select.Item>
)}
</Select>
{/* Preview */}
{selectedTemplate && (
<div className="flex flex-col gap-2">
<p className="text-sm font-medium text-secondary">Preview</p>
<div className="rounded-xl p-3" style={{ backgroundColor: '#e5ddd5' }}>
<div className="max-w-[85%] rounded-xl rounded-tl-none bg-white px-3 py-2 shadow-sm">
<p className="text-sm text-primary leading-relaxed">
{previewLead
? highlightVariables(populateVariables(selectedTemplate.body ?? '', previewLead))
: highlightVariables(selectedTemplate.body ?? '')}
</p>
<p className="mt-1 text-right text-xs text-quaternary">12:00 PM </p>
</div>
</div>
<p className="text-xs text-quaternary">
Variables auto-populated from lead data. Template is pre-approved by WhatsApp.
</p>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 border-t border-secondary px-6 py-4">
<Button size="md" color="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
size="md"
color="primary"
isDisabled={!selectedTemplateId}
onClick={handleSend}
className={cx('bg-success-solid hover:bg-success-solid/90 text-white')}
>
Send to {selectedLeads.length} {selectedLeads.length === 1 ? 'Lead' : 'Leads'}
</Button>
</div>
</div>
)}
</Dialog>
</Modal>
</ModalOverlay>
);
};