feat: email templates page + client segments page with advanced filters
- Templates page: create/edit/delete/duplicate templates, category filters, placeholder insertion buttons, usage tracking - Segments page: create/edit/delete segments with multi-criteria filter builder, preview matching clients, color picker, pin favorites - Filter panel: multi-select dropdowns for stage/industry/tags/city/state, date range pickers, contact info toggles, search - Added Templates + Segments to sidebar nav - Both pages support dark mode
This commit is contained in:
352
src/pages/SegmentsPage.tsx
Normal file
352
src/pages/SegmentsPage.tsx
Normal file
@@ -0,0 +1,352 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
// import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '@/lib/api';
|
||||
import type { ClientSegment, SegmentFilters, FilterOptions, Client } from '@/types';
|
||||
import { Plus, Filter, Users, Bookmark, Pin, Trash2, Pencil, Eye, Save, X, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import LoadingSpinner, { PageLoader } from '@/components/LoadingSpinner';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import Modal from "@/components/Modal";
|
||||
|
||||
const SEGMENT_COLORS = [
|
||||
'#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6',
|
||||
'#ec4899', '#06b6d4', '#f97316', '#6366f1', '#14b8a6',
|
||||
];
|
||||
|
||||
function MultiSelect({ label, options, selected, onChange }: {
|
||||
label: string; options: string[]; selected: string[]; onChange: (v: string[]) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div className="relative">
|
||||
<label className="block text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">{label}</label>
|
||||
<button onClick={() => setOpen(!open)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100">
|
||||
<span className="truncate">{selected.length ? `${selected.length} selected` : `Any ${label.toLowerCase()}`}</span>
|
||||
{open ? <ChevronUp className="w-3.5 h-3.5 ml-1" /> : <ChevronDown className="w-3.5 h-3.5 ml-1" />}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute z-20 mt-1 w-full bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg shadow-lg max-h-48 overflow-y-auto">
|
||||
{options.map(opt => (
|
||||
<label key={opt} className="flex items-center gap-2 px-3 py-1.5 hover:bg-slate-50 dark:hover:bg-slate-700 cursor-pointer text-sm text-slate-700 dark:text-slate-300">
|
||||
<input type="checkbox" checked={selected.includes(opt)}
|
||||
onChange={() => onChange(selected.includes(opt) ? selected.filter(s => s !== opt) : [...selected, opt])}
|
||||
className="rounded border-slate-300 dark:border-slate-600" />
|
||||
{opt}
|
||||
</label>
|
||||
))}
|
||||
{options.length === 0 && <p className="px-3 py-2 text-xs text-slate-400">No options</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterPanel({ filters, onChange, options }: {
|
||||
filters: SegmentFilters; onChange: (f: SegmentFilters) => void; options: FilterOptions;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-5 space-y-4">
|
||||
<h3 className="font-semibold text-slate-900 dark:text-slate-100 flex items-center gap-2">
|
||||
<Filter className="w-4 h-4" /> Filters
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
<MultiSelect label="Stage" options={options.stages} selected={filters.stages || []}
|
||||
onChange={v => onChange({ ...filters, stages: v.length ? v : undefined })} />
|
||||
<MultiSelect label="Industry" options={options.industries} selected={filters.industries || []}
|
||||
onChange={v => onChange({ ...filters, industries: v.length ? v : undefined })} />
|
||||
<MultiSelect label="Tags" options={options.tags} selected={filters.tags || []}
|
||||
onChange={v => onChange({ ...filters, tags: v.length ? v : undefined })} />
|
||||
<MultiSelect label="State" options={options.states} selected={filters.states || []}
|
||||
onChange={v => onChange({ ...filters, states: v.length ? v : undefined })} />
|
||||
<MultiSelect label="City" options={options.cities} selected={filters.cities || []}
|
||||
onChange={v => onChange({ ...filters, cities: v.length ? v : undefined })} />
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">Last Contacted After</label>
|
||||
<input type="date" value={filters.lastContactedAfter?.split('T')[0] || ''}
|
||||
onChange={e => onChange({ ...filters, lastContactedAfter: e.target.value || undefined })}
|
||||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">Last Contacted Before</label>
|
||||
<input type="date" value={filters.lastContactedBefore?.split('T')[0] || ''}
|
||||
onChange={e => onChange({ ...filters, lastContactedBefore: e.target.value || undefined })}
|
||||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs font-medium text-slate-500 dark:text-slate-400">Contact Info</label>
|
||||
<label className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
<input type="checkbox" checked={filters.hasEmail === true}
|
||||
onChange={e => onChange({ ...filters, hasEmail: e.target.checked ? true : undefined })}
|
||||
className="rounded border-slate-300" /> Has email
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
<input type="checkbox" checked={filters.hasPhone === true}
|
||||
onChange={e => onChange({ ...filters, hasPhone: e.target.checked ? true : undefined })}
|
||||
className="rounded border-slate-300" /> Has phone
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">Search</label>
|
||||
<input type="text" value={filters.search || ''} placeholder="Search by name, email, company..."
|
||||
onChange={e => onChange({ ...filters, search: e.target.value || undefined })}
|
||||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SegmentsPage() {
|
||||
const [segments, setSegments] = useState<ClientSegment[]>([]);
|
||||
const [options, setOptions] = useState<FilterOptions>({ industries: [], cities: [], states: [], tags: [], stages: [] });
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Builder state
|
||||
const [showBuilder, setShowBuilder] = useState(false);
|
||||
const [editingSegment, setEditingSegment] = useState<ClientSegment | null>(null);
|
||||
const [filters, setFilters] = useState<SegmentFilters>({});
|
||||
const [previewClients, setPreviewClients] = useState<Client[]>([]);
|
||||
const [previewCount, setPreviewCount] = useState<number | null>(null);
|
||||
const [previewing, setPreviewing] = useState(false);
|
||||
const [segmentName, setSegmentName] = useState('');
|
||||
const [segmentDesc, setSegmentDesc] = useState('');
|
||||
const [segmentColor, setSegmentColor] = useState('#3b82f6');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
api.getSegments().then(setSegments),
|
||||
api.getFilterOptions().then(setOptions),
|
||||
]).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handlePreview = async () => {
|
||||
setPreviewing(true);
|
||||
try {
|
||||
const result = await api.previewSegment(filters);
|
||||
setPreviewClients(result.clients);
|
||||
setPreviewCount(result.count);
|
||||
} finally {
|
||||
setPreviewing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openNew = () => {
|
||||
setEditingSegment(null);
|
||||
setFilters({});
|
||||
setSegmentName('');
|
||||
setSegmentDesc('');
|
||||
setSegmentColor('#3b82f6');
|
||||
setPreviewClients([]);
|
||||
setPreviewCount(null);
|
||||
setShowBuilder(true);
|
||||
};
|
||||
|
||||
const openEdit = (s: ClientSegment) => {
|
||||
setEditingSegment(s);
|
||||
setFilters(s.filters);
|
||||
setSegmentName(s.name);
|
||||
setSegmentDesc(s.description || '');
|
||||
setSegmentColor(s.color);
|
||||
setPreviewClients([]);
|
||||
setPreviewCount(null);
|
||||
setShowBuilder(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editingSegment) {
|
||||
await api.updateSegment(editingSegment.id, { name: segmentName, description: segmentDesc, filters, color: segmentColor });
|
||||
} else {
|
||||
await api.createSegment({ name: segmentName, description: segmentDesc, filters, color: segmentColor });
|
||||
}
|
||||
setShowBuilder(false);
|
||||
const updated = await api.getSegments();
|
||||
setSegments(updated);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Delete this segment?')) return;
|
||||
await api.deleteSegment(id);
|
||||
setSegments(segments.filter(s => s.id !== id));
|
||||
};
|
||||
|
||||
const togglePin = async (s: ClientSegment) => {
|
||||
const updated = await api.updateSegment(s.id, { pinned: !s.pinned });
|
||||
setSegments(segments.map(seg => seg.id === s.id ? updated : seg));
|
||||
};
|
||||
|
||||
const activeFilterCount = Object.values(filters).filter(v => v !== undefined && v !== '' && (!Array.isArray(v) || v.length > 0)).length;
|
||||
|
||||
if (loading) return <PageLoader />;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Client Segments</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
||||
Save filtered views of your client base for quick access
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={openNew}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors">
|
||||
<Plus className="w-4 h-4" /> New Segment
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Saved Segments */}
|
||||
{segments.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Bookmark}
|
||||
title="No segments saved"
|
||||
description="Create segments to quickly filter your client list by stage, tags, location, and more"
|
||||
action={{ label: 'Create Segment', onClick: openNew }}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{segments.map(s => (
|
||||
<div key={s.id} className="group bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-5 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: s.color }} />
|
||||
<h3 className="font-semibold text-slate-900 dark:text-slate-100">{s.name}</h3>
|
||||
{s.pinned && <Pin className="w-3.5 h-3.5 text-amber-500 fill-amber-500" />}
|
||||
</div>
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button onClick={() => togglePin(s)} title={s.pinned ? 'Unpin' : 'Pin'}
|
||||
className="p-1.5 text-slate-400 hover:text-amber-500">
|
||||
<Pin className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={() => openEdit(s)} title="Edit"
|
||||
className="p-1.5 text-slate-400 hover:text-blue-600 dark:hover:text-blue-400">
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={() => handleDelete(s.id)} title="Delete"
|
||||
className="p-1.5 text-slate-400 hover:text-red-600 dark:hover:text-red-400">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{s.description && <p className="text-sm text-slate-500 dark:text-slate-400 mb-3">{s.description}</p>}
|
||||
{/* Filter badges */}
|
||||
<div className="flex flex-wrap gap-1 mb-3">
|
||||
{s.filters.stages?.map(st => (
|
||||
<span key={st} className="px-2 py-0.5 bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 rounded text-xs">{st}</span>
|
||||
))}
|
||||
{s.filters.tags?.map(tg => (
|
||||
<span key={tg} className="px-2 py-0.5 bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300 rounded text-xs">#{tg}</span>
|
||||
))}
|
||||
{s.filters.industries?.map(ind => (
|
||||
<span key={ind} className="px-2 py-0.5 bg-purple-100 dark:bg-purple-900/40 text-purple-700 dark:text-purple-300 rounded text-xs">{ind}</span>
|
||||
))}
|
||||
{s.filters.hasEmail && <span className="px-2 py-0.5 bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 rounded text-xs">📧 Has email</span>}
|
||||
{s.filters.hasPhone && <span className="px-2 py-0.5 bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 rounded text-xs">📞 Has phone</span>}
|
||||
</div>
|
||||
<button onClick={async () => {
|
||||
const result = await api.getSegment(s.id);
|
||||
// Navigate to clients page — for now show count
|
||||
alert(`${result.clientCount} clients match this segment`);
|
||||
}}
|
||||
className="flex items-center gap-1.5 text-sm text-blue-600 dark:text-blue-400 hover:underline">
|
||||
<Eye className="w-3.5 h-3.5" /> View clients
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Segment Builder Modal */}
|
||||
<Modal isOpen={showBuilder} onClose={() => setShowBuilder(false)} title={editingSegment ? 'Edit Segment' : 'New Segment'} size="xl">
|
||||
<div className="space-y-5">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Segment Name</label>
|
||||
<input value={segmentName} onChange={e => setSegmentName(e.target.value)}
|
||||
placeholder="e.g., High-value active clients"
|
||||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Description</label>
|
||||
<input value={segmentDesc} onChange={e => setSegmentDesc(e.target.value)}
|
||||
placeholder="Optional description"
|
||||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Color</label>
|
||||
<div className="flex gap-2">
|
||||
{SEGMENT_COLORS.map(c => (
|
||||
<button key={c} onClick={() => setSegmentColor(c)}
|
||||
className={`w-7 h-7 rounded-full border-2 transition-transform ${segmentColor === c ? 'border-slate-900 dark:border-white scale-110' : 'border-transparent'}`}
|
||||
style={{ backgroundColor: c }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FilterPanel filters={filters} onChange={setFilters} options={options} />
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={handlePreview} disabled={previewing}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-lg text-sm hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors">
|
||||
{previewing ? <LoadingSpinner size="sm" /> : <Eye className="w-4 h-4" />}
|
||||
Preview ({activeFilterCount} filter{activeFilterCount !== 1 ? 's' : ''})
|
||||
</button>
|
||||
{previewCount !== null && (
|
||||
<span className="text-sm font-medium text-slate-600 dark:text-slate-400">
|
||||
<Users className="w-4 h-4 inline mr-1" />{previewCount} client{previewCount !== 1 ? 's' : ''} match
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preview results */}
|
||||
{previewClients.length > 0 && (
|
||||
<div className="max-h-60 overflow-y-auto border border-slate-200 dark:border-slate-700 rounded-lg">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 dark:bg-slate-800 sticky top-0">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-slate-500 dark:text-slate-400">Name</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-slate-500 dark:text-slate-400">Email</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-slate-500 dark:text-slate-400">Company</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-slate-500 dark:text-slate-400">Stage</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100 dark:divide-slate-700">
|
||||
{previewClients.slice(0, 20).map(c => (
|
||||
<tr key={c.id} className="hover:bg-slate-50 dark:hover:bg-slate-800/50">
|
||||
<td className="px-3 py-2 text-slate-900 dark:text-slate-100">{c.firstName} {c.lastName}</td>
|
||||
<td className="px-3 py-2 text-slate-500 dark:text-slate-400">{c.email || '—'}</td>
|
||||
<td className="px-3 py-2 text-slate-500 dark:text-slate-400">{c.company || '—'}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className="px-2 py-0.5 rounded text-xs bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300">{c.stage || 'lead'}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{previewClients.length > 20 && (
|
||||
<p className="px-3 py-2 text-xs text-slate-400 text-center">Showing 20 of {previewClients.length}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button onClick={() => setShowBuilder(false)}
|
||||
className="px-4 py-2 text-sm text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button onClick={handleSave} disabled={saving || !segmentName}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors">
|
||||
{saving ? <LoadingSpinner size="sm" className="text-white" /> : <Save className="w-4 h-4" />}
|
||||
{editingSegment ? 'Update' : 'Save'} Segment
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
247
src/pages/TemplatesPage.tsx
Normal file
247
src/pages/TemplatesPage.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import type { EmailTemplate, EmailTemplateCreate } from '@/types';
|
||||
import { Plus, Pencil, Trash2, Star, Copy, FileText, X, Save } from 'lucide-react';
|
||||
import LoadingSpinner, { PageLoader } from '@/components/LoadingSpinner';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import Modal from "@/components/Modal";
|
||||
|
||||
const CATEGORIES = [
|
||||
{ value: 'follow-up', label: 'Follow-up', color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300' },
|
||||
{ value: 'birthday', label: 'Birthday', color: 'bg-pink-100 text-pink-700 dark:bg-pink-900/40 dark:text-pink-300' },
|
||||
{ value: 'introduction', label: 'Introduction', color: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' },
|
||||
{ value: 'check-in', label: 'Check-in', color: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300' },
|
||||
{ value: 'thank-you', label: 'Thank You', color: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300' },
|
||||
{ value: 'custom', label: 'Custom', color: 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-300' },
|
||||
];
|
||||
|
||||
function getCategoryStyle(category: string) {
|
||||
return CATEGORIES.find(c => c.value === category)?.color || CATEGORIES[5].color;
|
||||
}
|
||||
|
||||
function getCategoryLabel(category: string) {
|
||||
return CATEGORIES.find(c => c.value === category)?.label || category;
|
||||
}
|
||||
|
||||
const PLACEHOLDERS = [
|
||||
{ token: '{{firstName}}', desc: "Client's first name" },
|
||||
{ token: '{{lastName}}', desc: "Client's last name" },
|
||||
{ token: '{{company}}', desc: "Client's company" },
|
||||
{ token: '{{role}}', desc: "Client's role/title" },
|
||||
];
|
||||
|
||||
export default function TemplatesPage() {
|
||||
const [templates, setTemplates] = useState<EmailTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filterCategory, setFilterCategory] = useState<string>('');
|
||||
const [showEditor, setShowEditor] = useState(false);
|
||||
const [editingTemplate, setEditingTemplate] = useState<EmailTemplate | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Form state
|
||||
const [form, setForm] = useState<EmailTemplateCreate>({
|
||||
name: '', category: 'follow-up', subject: '', content: '', isDefault: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadTemplates();
|
||||
}, [filterCategory]);
|
||||
|
||||
const loadTemplates = async () => {
|
||||
try {
|
||||
const data = await api.getTemplates(filterCategory || undefined);
|
||||
setTemplates(data);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openNew = () => {
|
||||
setEditingTemplate(null);
|
||||
setForm({ name: '', category: 'follow-up', subject: '', content: '', isDefault: false });
|
||||
setShowEditor(true);
|
||||
};
|
||||
|
||||
const openEdit = (t: EmailTemplate) => {
|
||||
setEditingTemplate(t);
|
||||
setForm({ name: t.name, category: t.category, subject: t.subject, content: t.content, isDefault: t.isDefault });
|
||||
setShowEditor(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editingTemplate) {
|
||||
await api.updateTemplate(editingTemplate.id, form);
|
||||
} else {
|
||||
await api.createTemplate(form);
|
||||
}
|
||||
setShowEditor(false);
|
||||
await loadTemplates();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Delete this template?')) return;
|
||||
await api.deleteTemplate(id);
|
||||
loadTemplates();
|
||||
};
|
||||
|
||||
const handleDuplicate = async (t: EmailTemplate) => {
|
||||
await api.createTemplate({
|
||||
name: `${t.name} (Copy)`,
|
||||
category: t.category,
|
||||
subject: t.subject,
|
||||
content: t.content,
|
||||
});
|
||||
loadTemplates();
|
||||
};
|
||||
|
||||
const insertPlaceholder = (token: string) => {
|
||||
setForm(prev => ({ ...prev, content: prev.content + token }));
|
||||
};
|
||||
|
||||
if (loading) return <PageLoader />;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Email Templates</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
||||
Reusable templates with placeholders for quick email drafting
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={openNew}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors">
|
||||
<Plus className="w-4 h-4" /> New Template
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Category Filter */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<button onClick={() => setFilterCategory('')}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
|
||||
!filterCategory ? 'bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900' : 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-600'
|
||||
}`}>All</button>
|
||||
{CATEGORIES.map(cat => (
|
||||
<button key={cat.value} onClick={() => setFilterCategory(cat.value)}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
|
||||
filterCategory === cat.value ? 'bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900' : `${cat.color} hover:opacity-80`
|
||||
}`}>{cat.label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Templates Grid */}
|
||||
{templates.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
title="No templates yet"
|
||||
description="Create reusable email templates to speed up your workflow"
|
||||
action={{ label: 'Create Template', onClick: openNew }}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{templates.map(t => (
|
||||
<div key={t.id} className="group bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-5 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${getCategoryStyle(t.category)}`}>
|
||||
{getCategoryLabel(t.category)}
|
||||
</span>
|
||||
{t.isDefault && <Star className="w-3.5 h-3.5 text-amber-500 fill-amber-500" />}
|
||||
</div>
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button onClick={() => handleDuplicate(t)} title="Duplicate"
|
||||
className="p-1.5 text-slate-400 hover:text-blue-600 dark:hover:text-blue-400">
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={() => openEdit(t)} title="Edit"
|
||||
className="p-1.5 text-slate-400 hover:text-blue-600 dark:hover:text-blue-400">
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={() => handleDelete(t.id)} title="Delete"
|
||||
className="p-1.5 text-slate-400 hover:text-red-600 dark:hover:text-red-400">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="font-semibold text-slate-900 dark:text-slate-100 mb-1">{t.name}</h3>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mb-2 font-medium">Subject: {t.subject}</p>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300 line-clamp-3">{t.content}</p>
|
||||
<div className="mt-3 text-xs text-slate-400">
|
||||
Used {t.usageCount} time{t.usageCount !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Template Editor Modal */}
|
||||
<Modal isOpen={showEditor} onClose={() => setShowEditor(false)} title={editingTemplate ? 'Edit Template' : 'New Template'} size="lg">
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Name</label>
|
||||
<input value={form.name} onChange={e => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="e.g., Monthly Check-in"
|
||||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:ring-2 focus:ring-blue-500 focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Category</label>
|
||||
<select value={form.category} onChange={e => setForm({ ...form, category: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:ring-2 focus:ring-blue-500 focus:outline-none">
|
||||
{CATEGORIES.map(c => <option key={c.value} value={c.value}>{c.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Subject</label>
|
||||
<input value={form.subject} onChange={e => setForm({ ...form, subject: e.target.value })}
|
||||
placeholder="e.g., Checking in, {{firstName}}"
|
||||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:ring-2 focus:ring-blue-500 focus:outline-none" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">Content</label>
|
||||
<div className="flex gap-1">
|
||||
{PLACEHOLDERS.map(p => (
|
||||
<button key={p.token} onClick={() => insertPlaceholder(p.token)} title={p.desc}
|
||||
className="px-2 py-0.5 text-xs bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 rounded hover:bg-blue-100 dark:hover:bg-blue-900/40 transition-colors">
|
||||
{p.token}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<textarea value={form.content} onChange={e => setForm({ ...form, content: e.target.value })}
|
||||
rows={10} placeholder="Write your template content here. Use {{firstName}}, {{lastName}}, etc."
|
||||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:ring-2 focus:ring-blue-500 focus:outline-none font-mono" />
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
<input type="checkbox" checked={form.isDefault || false}
|
||||
onChange={e => setForm({ ...form, isDefault: e.target.checked })}
|
||||
className="rounded border-slate-300 dark:border-slate-600" />
|
||||
Set as default for this category
|
||||
</label>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button onClick={() => setShowEditor(false)}
|
||||
className="px-4 py-2 text-sm text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button onClick={handleSave} disabled={saving || !form.name || !form.subject || !form.content}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors">
|
||||
{saving ? <LoadingSpinner size="sm" className="text-white" /> : <Save className="w-4 h-4" />}
|
||||
{editingTemplate ? 'Update' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user