feat: build Outreach page with template list, WhatsApp preview, and message history

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

View File

@@ -0,0 +1,83 @@
import { Avatar } from '@/components/base/avatar/avatar';
import { Badge } from '@/components/base/badges/badges';
import type { PatientMessage, MessageStatus } from '@/types/entities';
interface MessageListProps {
messages: PatientMessage[];
}
const statusConfig: Record<
MessageStatus,
{ label: string; color: 'blue' | 'success' | 'gray' | 'error' }
> = {
READ: { label: 'Read', color: 'blue' },
DELIVERED: { label: 'Delivered', color: 'success' },
SENT: { label: 'Sent', color: 'gray' },
FAILED: { label: 'Failed', color: 'error' },
};
const getInitials = (name: string | null | undefined): string => {
if (!name) return '?';
const parts = name.trim().split(' ');
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
};
const formatTime = (sentAt: string | null): string => {
if (!sentAt) return '';
const date = new Date(sentAt);
return date.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit', hour12: true });
};
export const MessageList = ({ messages }: MessageListProps) => {
return (
<div className="bg-primary rounded-2xl border border-secondary overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-secondary">
<p className="text-md font-bold text-primary">Recent Messages</p>
<button type="button" className="text-sm text-brand-secondary hover:text-brand-secondary_hover transition duration-100 ease-linear cursor-pointer">
View All
</button>
</div>
{/* Message rows */}
<div className="divide-y divide-secondary">
{messages.slice(0, 5).map((message) => {
const recipientName = message.recipientName ?? message.senderName ?? 'Unknown';
const status = message.status ?? 'SENT';
const statusInfo = statusConfig[status];
return (
<div key={message.id} className="flex items-center gap-3 px-5 py-3.5 hover:bg-secondary transition duration-100 ease-linear">
{/* Avatar */}
<Avatar
initials={getInitials(recipientName)}
size="sm"
/>
{/* Name + preview */}
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-primary">{recipientName}</p>
<p className="text-xs text-tertiary overflow-hidden text-ellipsis whitespace-nowrap">
{message.subject ?? message.body ?? '—'}
</p>
</div>
{/* Time + status */}
<div className="flex flex-col items-end gap-1 flex-shrink-0">
<span className="text-xs text-quaternary">{formatTime(message.sentAt)}</span>
<Badge type="pill-color" color={statusInfo.color} size="sm">
{statusInfo.label}
</Badge>
</div>
</div>
);
})}
{messages.length === 0 && (
<p className="py-8 text-center text-sm text-tertiary">No messages yet.</p>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,135 @@
import { useState } from 'react';
import { SearchLg } from '@untitledui/icons';
import { Input } from '@/components/base/input/input';
import { Badge } from '@/components/base/badges/badges';
import { Tabs, TabList, Tab, TabPanel } from '@/components/application/tabs/tabs';
import { cx } from '@/utils/cx';
import type { WhatsAppTemplate } from '@/types/entities';
interface TemplateListProps {
templates: WhatsAppTemplate[];
selectedId: string | null;
onSelect: (id: string) => void;
}
type TabId = 'all' | 'campaign' | 'custom';
export const TemplateList = ({ templates, selectedId, onSelect }: TemplateListProps) => {
const [search, setSearch] = useState('');
const [activeTab, setActiveTab] = useState<TabId>('all');
const filtered = templates.filter((t) => {
const matchesSearch =
!search ||
t.name?.toLowerCase().includes(search.toLowerCase()) ||
t.body?.toLowerCase().includes(search.toLowerCase());
const matchesTab =
activeTab === 'all' ||
(activeTab === 'campaign' && t.linkedCampaignId !== null) ||
(activeTab === 'custom' && t.linkedCampaignId === null);
return matchesSearch && matchesTab;
});
const tabs = [
{ id: 'all' as TabId, label: `All (${templates.length})` },
{ id: 'campaign' as TabId, label: 'By Campaign' },
{ id: 'custom' as TabId, label: 'Custom' },
];
return (
<div className="w-96 border-r border-secondary bg-primary flex flex-col h-full flex-shrink-0">
{/* Header */}
<div className="p-5 border-b border-secondary space-y-3">
<p className="text-sm font-bold text-primary">Message Templates</p>
<Input
size="sm"
placeholder="Search templates..."
icon={SearchLg}
value={search}
onChange={(val) => setSearch(val)}
aria-label="Search templates"
/>
</div>
{/* Tabs */}
<div className="border-b border-secondary px-5">
<Tabs selectedKey={activeTab} onSelectionChange={(key) => setActiveTab(key as TabId)}>
<TabList type="underline" size="sm" items={tabs}>
{(item) => <Tab key={item.id} id={item.id} label={item.label} />}
</TabList>
{tabs.map((tab) => (
<TabPanel key={tab.id} id={tab.id} />
))}
</Tabs>
</div>
{/* Scrollable list */}
<div className="flex-1 overflow-y-auto p-2">
{filtered.length === 0 && (
<p className="py-8 text-center text-xs text-tertiary">No templates found.</p>
)}
{filtered.map((template) => {
const isSelected = template.id === selectedId;
const isPending = template.approvalStatus === 'PENDING';
const deliveredRate =
template.sendCount && template.sendCount > 0
? Math.round(((template.deliveredCount ?? 0) / template.sendCount) * 100)
: 0;
return (
<button
key={template.id}
type="button"
onClick={() => onSelect(template.id)}
className={cx(
'w-full text-left p-3.5 rounded-xl border-2 transition duration-100 ease-linear cursor-pointer',
isSelected
? 'bg-brand-primary border-brand'
: 'border-transparent hover:bg-secondary',
isPending && 'opacity-60',
)}
>
{/* Top row */}
<div className="flex items-center justify-between gap-2 mb-1">
<span className={cx('text-sm font-semibold truncate', isSelected ? 'text-white' : 'text-primary')}>
{template.name}
</span>
{template.linkedCampaignId ? (
<Badge type="pill-color" color="brand" size="sm">
Campaign
</Badge>
) : (
<Badge type="pill-color" color="gray" size="sm">
Custom
</Badge>
)}
</div>
{/* Preview text */}
<p className={cx('text-xs line-clamp-2 mb-1.5', isSelected ? 'text-white/80' : 'text-tertiary')}>
{template.body}
</p>
{/* Meta row */}
<div className={cx('flex gap-3 text-xs', isSelected ? 'text-white/70' : 'text-quaternary')}>
{template.approvalStatus === 'APPROVED' ? (
<span className={isSelected ? 'text-white/90' : 'text-success-primary'}> Approved</span>
) : (
<span className={isSelected ? 'text-white/90' : 'text-warning-primary'}> Pending</span>
)}
<span>
Sent <strong>{template.sendCount ?? 0}</strong> times
</span>
<span>
<strong>{deliveredRate}%</strong> delivered
</span>
</div>
</button>
);
})}
</div>
</div>
);
};

View File

@@ -0,0 +1,138 @@
import { Button } from '@/components/base/buttons/button';
import { Badge } from '@/components/base/badges/badges';
import { WhatsAppMockup } from './whatsapp-mockup';
import type { WhatsAppTemplate } from '@/types/entities';
interface TemplatePreviewProps {
template: WhatsAppTemplate;
}
const variableDescriptions: Record<string, string> = {
patient_name: "Lead's contact name",
hospital_name: 'Clinic or hospital name',
campaign_name: 'Linked campaign name',
booking_link: 'Appointment booking URL',
};
export const TemplatePreview = ({ template }: TemplatePreviewProps) => {
const totalSent = template.sendCount ?? 0;
const delivered = template.deliveredCount ?? 0;
const read = template.readCount ?? 0;
const clicked = template.clickedCount ?? 0;
const failed = template.failedCount ?? 0;
const metrics = [
{ label: 'Sent', value: totalSent },
{ label: 'Delivered', value: delivered },
{ label: 'Read', value: read },
{ label: 'Clicked', value: clicked },
{ label: 'Failed', value: failed },
];
return (
<div className="bg-primary rounded-2xl border border-secondary overflow-hidden">
{/* Header */}
<div className="p-5 border-b border-secondary flex items-center justify-between">
<p className="text-md font-bold text-primary">
Template Preview {template.name}
</p>
<Button color="secondary" size="sm">
Edit Template
</Button>
</div>
{/* Body */}
<div className="flex">
{/* Left: phone mockup */}
<div className="bg-secondary p-5 flex items-start justify-center flex-shrink-0">
<WhatsAppMockup body={template.body ?? ''} variables={template.variables} />
</div>
{/* Right: details */}
<div className="flex-1 p-5 space-y-5">
{/* Variables */}
{template.variables.length > 0 && (
<div>
<p className="text-xs font-semibold text-secondary uppercase tracking-wider mb-2">Variables</p>
<div className="space-y-1.5">
{template.variables.map((variable) => (
<div key={variable} className="flex items-center gap-2 text-sm">
<code className="bg-secondary px-1.5 py-0.5 rounded text-xs text-brand-secondary font-mono">
{`{{${variable}}}`}
</code>
<span className="text-quaternary"></span>
<span className="text-tertiary">
{variableDescriptions[variable] ?? variable}
</span>
</div>
))}
</div>
</div>
)}
{/* Linked Campaign */}
<div>
<p className="text-xs font-semibold text-secondary uppercase tracking-wider mb-2">Linked Campaign</p>
{template.linkedCampaignName ? (
<Badge type="pill-color" color="brand" size="md">
{template.linkedCampaignName}
</Badge>
) : (
<Badge type="pill-color" color="gray" size="md">
None (Custom)
</Badge>
)}
</div>
{/* Approval Status */}
<div>
<p className="text-xs font-semibold text-secondary uppercase tracking-wider mb-2">Approval Status</p>
{template.approvalStatus === 'APPROVED' ? (
<Badge type="pill-color" color="success" size="md">
Approved
</Badge>
) : template.approvalStatus === 'PENDING' ? (
<Badge type="pill-color" color="warning" size="md">
Pending Review
</Badge>
) : (
<Badge type="pill-color" color="error" size="md">
Rejected
</Badge>
)}
</div>
{/* Performance */}
<div>
<p className="text-xs font-semibold text-secondary uppercase tracking-wider mb-2">Performance</p>
<div className="flex gap-3">
{metrics.map((metric) => (
<div
key={metric.label}
className="flex-1 bg-secondary rounded-xl p-3 text-center min-w-0"
>
<p className="text-lg font-bold text-primary">{metric.value}</p>
<p className="text-xs text-quaternary mt-0.5">{metric.label}</p>
</div>
))}
</div>
</div>
{/* Languages */}
{template.language.length > 0 && (
<div>
<p className="text-xs font-semibold text-secondary uppercase tracking-wider mb-2">Language</p>
<div className="flex flex-wrap gap-1.5">
{template.language.map((lang) => (
<Badge key={lang} type="color" color="gray" size="sm">
{lang.toUpperCase()}
</Badge>
))}
</div>
</div>
)}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,51 @@
import React from 'react';
interface WhatsAppMockupProps {
body: string;
variables: string[];
}
const highlightVariables = (text: string): React.ReactNode => {
const parts = text.split(/({{[^}]+}})/g);
return parts.map((part, index) => {
if (/^{{[^}]+}}$/.test(part)) {
return (
<span key={index} className="bg-[#c6e8b0] px-1 rounded font-semibold text-[#1a6b00]">
{part}
</span>
);
}
return part;
});
};
export const WhatsAppMockup = ({ body }: WhatsAppMockupProps) => {
const lines = body.split('\n');
// Extract CTA line — last non-empty line that looks like a URL or "Book here"
const ctaLine = lines.find((l) => l.includes('{{booking_link}}') || l.toLowerCase().includes('book '));
return (
<div className="w-[260px] rounded-2xl overflow-hidden shadow-lg">
{/* WA Header */}
<div className="bg-[#075e54] px-3.5 py-2.5 flex items-center gap-2.5">
<div className="w-7 h-7 rounded-full bg-[#128c7e] flex items-center justify-center flex-shrink-0">
<span className="text-white text-xs font-bold">R</span>
</div>
<span className="text-white text-xs font-semibold">Ramaiah Memorial</span>
</div>
{/* WA Body */}
<div className="bg-[#e5ddd5] p-3.5 min-h-[200px]">
<div className="bg-[#dcf8c6] rounded-lg rounded-br-none p-3 max-w-[220px] ml-auto text-xs text-[#303030] leading-relaxed shadow-xs">
<p className="whitespace-pre-wrap">{highlightVariables(body)}</p>
{ctaLine && (
<span className="block mt-2 p-2 bg-white rounded-md text-center text-[#075e54] font-semibold text-xs cursor-pointer">
Book Now
</span>
)}
<p className="text-[10px] text-right text-[#667781] mt-1">2:30 PM </p>
</div>
</div>
</div>
);
};

View File

@@ -1,11 +1,148 @@
import { TopBar } from "@/components/layout/top-bar";
import { useState } from 'react';
import { TopBar } from '@/components/layout/top-bar';
import { TemplateList } from '@/components/outreach/template-list';
import { TemplatePreview } from '@/components/outreach/template-preview';
import { MessageList } from '@/components/outreach/message-list';
import { useData } from '@/providers/data-provider';
import { cx } from '@/utils/cx';
import type { PatientMessage } from '@/types/entities';
type OutreachKpiCard = {
label: string;
value: number;
delta: string;
deltaColor: string;
};
const recentMessages: PatientMessage[] = [
{
id: 'msg-1',
createdAt: '2026-03-16T08:30:00Z',
subject: "Women's Day Health Checkup",
body: 'Namaste Priya, Happy Women\'s Day from Ramaiah Memorial! Book your free checkup here.',
direction: 'CLINIC_TO_PATIENT',
channel: 'WHATSAPP',
priority: 'NORMAL',
sentAt: '2026-03-16T08:30:00Z',
readAt: '2026-03-16T09:15:00Z',
senderName: 'Care Team',
patientId: 'p-001',
status: 'READ',
recipientName: 'Priya Sharma',
recipientPhone: '+91 98765 43210',
},
{
id: 'msg-2',
createdAt: '2026-03-16T09:10:00Z',
subject: 'IVF Free Consultation',
body: 'Namaste Anitha, We would like to invite you for a FREE first consultation with our IVF specialist.',
direction: 'CLINIC_TO_PATIENT',
channel: 'WHATSAPP',
priority: 'NORMAL',
sentAt: '2026-03-16T09:10:00Z',
readAt: null,
senderName: 'Care Team',
patientId: 'p-002',
status: 'DELIVERED',
recipientName: 'Anitha Reddy',
recipientPhone: '+91 87654 32109',
},
{
id: 'msg-3',
createdAt: '2026-03-16T10:00:00Z',
subject: 'Cervical Screening Reminder',
body: 'Namaste Kavitha, This is a gentle reminder for your cervical cancer screening appointment.',
direction: 'CLINIC_TO_PATIENT',
channel: 'WHATSAPP',
priority: 'HIGH',
sentAt: '2026-03-16T10:00:00Z',
readAt: null,
senderName: 'Care Team',
patientId: 'p-003',
status: 'SENT',
recipientName: 'Kavitha Nair',
recipientPhone: '+91 76543 21098',
},
{
id: 'msg-4',
createdAt: '2026-03-16T10:45:00Z',
subject: 'General Follow-up',
body: 'Namaste Meena, This is Ramaiah Memorial reaching out to follow up on your recent enquiry.',
direction: 'CLINIC_TO_PATIENT',
channel: 'WHATSAPP',
priority: 'NORMAL',
sentAt: '2026-03-16T10:45:00Z',
readAt: '2026-03-16T11:02:00Z',
senderName: 'Care Team',
patientId: 'p-004',
status: 'READ',
recipientName: 'Meena Iyer',
recipientPhone: '+91 65432 10987',
},
{
id: 'msg-5',
createdAt: '2026-03-16T11:30:00Z',
subject: 'Senior Health Package',
body: 'Namaste Lakshmi, Our Senior Health Package is designed specifically for individuals above 60 years.',
direction: 'CLINIC_TO_PATIENT',
channel: 'WHATSAPP',
priority: 'NORMAL',
sentAt: '2026-03-16T11:30:00Z',
readAt: null,
senderName: 'Care Team',
patientId: 'p-005',
status: 'FAILED',
recipientName: 'Lakshmi Devi',
recipientPhone: '+91 54321 09876',
},
];
const kpiCards: OutreachKpiCard[] = [
{ label: 'Messages Sent (24h)', value: 87, delta: '+14% vs yesterday', deltaColor: 'text-success-primary' },
{ label: 'Delivered', value: 78, delta: '90% delivery rate', deltaColor: 'text-brand-secondary' },
{ label: 'Read', value: 52, delta: '67% read rate', deltaColor: 'text-brand-secondary' },
{ label: 'CTA Clicked', value: 18, delta: '+5 vs yesterday', deltaColor: 'text-success-primary' },
];
export const OutreachPage = () => {
const { templates } = useData();
const [selectedId, setSelectedId] = useState<string | null>(templates[0]?.id ?? null);
const selectedTemplate = templates.find((t) => t.id === selectedId) ?? null;
return (
<div className="flex flex-1 flex-col">
<TopBar title="Outreach" />
<div className="flex flex-1 items-center justify-center p-8">
<p className="text-tertiary">Outreach coming soon</p>
<div className="flex flex-1 flex-col overflow-hidden">
<TopBar title="Outreach" subtitle="WhatsApp templates and message history" />
<div className="flex flex-1 overflow-hidden">
{/* Left: Template List */}
<TemplateList templates={templates} selectedId={selectedId} onSelect={setSelectedId} />
{/* Right: Preview + KPIs + Messages */}
<div className="flex-1 overflow-y-auto p-7 space-y-5">
{/* Outreach KPIs */}
<div className="grid grid-cols-4 gap-3">
{kpiCards.map((card) => (
<div
key={card.label}
className="bg-primary rounded-2xl border border-secondary p-4 transition hover:shadow-md"
>
<p className="text-xs font-medium uppercase tracking-wider text-quaternary">
{card.label}
</p>
<p className="mt-1 text-display-sm font-bold text-primary">{card.value}</p>
<p className={cx('mt-1 text-xs', card.deltaColor)}>{card.delta}</p>
</div>
))}
</div>
{/* Template Preview */}
{selectedTemplate && <TemplatePreview template={selectedTemplate} />}
{/* Recent Messages */}
<MessageList messages={recentMessages} />
</div>
</div>
</div>
);