mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
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:
150
src/components/leads/lead-activity-slideout.tsx
Normal file
150
src/components/leads/lead-activity-slideout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
152
src/components/modals/assign-modal.tsx
Normal file
152
src/components/modals/assign-modal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
141
src/components/modals/mark-spam-modal.tsx
Normal file
141
src/components/modals/mark-spam-modal.tsx
Normal 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 (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 (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
191
src/components/modals/merge-modal.tsx
Normal file
191
src/components/modals/merge-modal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
163
src/components/modals/whatsapp-send-modal.tsx
Normal file
163
src/components/modals/whatsapp-send-modal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user