feat: email templates page + client segments page with advanced filters
Some checks failed
CI/CD / test (push) Failing after 21s
CI/CD / deploy (push) Has been skipped

- 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:
2026-01-30 01:07:41 +00:00
parent 691e8170f3
commit 22bf4778fd
6 changed files with 709 additions and 1 deletions

View File

@@ -14,6 +14,8 @@ const SettingsPage = lazy(() => import('@/pages/SettingsPage'));
const AdminPage = lazy(() => import('@/pages/AdminPage'));
const NetworkPage = lazy(() => import('@/pages/NetworkPage'));
const ReportsPage = lazy(() => import('@/pages/ReportsPage'));
const TemplatesPage = lazy(() => import('@/pages/TemplatesPage'));
const SegmentsPage = lazy(() => import('@/pages/SegmentsPage'));
const InvitePage = lazy(() => import('@/pages/InvitePage'));
const ForgotPasswordPage = lazy(() => import('@/pages/ForgotPasswordPage'));
const ResetPasswordPage = lazy(() => import('@/pages/ResetPasswordPage'));
@@ -54,6 +56,8 @@ export default function App() {
<Route path="emails" element={<EmailsPage />} />
<Route path="network" element={<NetworkPage />} />
<Route path="reports" element={<ReportsPage />} />
<Route path="templates" element={<TemplatesPage />} />
<Route path="segments" element={<SegmentsPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="admin" element={<AdminPage />} />
</Route>

View File

@@ -5,6 +5,7 @@ import { cn } from '@/lib/utils';
import {
LayoutDashboard, Users, Calendar, Mail, Settings, Shield,
LogOut, Menu, X, ChevronLeft, Network, BarChart3, Search, Command,
FileText, Bookmark,
} from 'lucide-react';
import NotificationBell from './NotificationBell';
import CommandPalette from './CommandPalette';
@@ -16,6 +17,8 @@ const baseNavItems = [
{ path: '/events', label: 'Events', icon: Calendar },
{ path: '/emails', label: 'Emails', icon: Mail },
{ path: '/network', label: 'Network', icon: Network },
{ path: '/templates', label: 'Templates', icon: FileText },
{ path: '/segments', label: 'Segments', icon: Bookmark },
{ path: '/reports', label: 'Reports', icon: BarChart3 },
{ path: '/settings', label: 'Settings', icon: Settings },
];

View File

@@ -1,4 +1,4 @@
import type { Profile, Client, ClientCreate, ClientNote, Event, EventCreate, Email, EmailGenerate, User, Invite, ActivityItem, InsightsData, ImportPreview, ImportResult, NetworkMatch, NetworkStats, Notification, Interaction, BulkEmailResult } from '@/types';
import type { Profile, Client, ClientCreate, ClientNote, Event, EventCreate, Email, EmailGenerate, User, Invite, ActivityItem, InsightsData, ImportPreview, ImportResult, NetworkMatch, NetworkStats, Notification, Interaction, BulkEmailResult, EmailTemplate, EmailTemplateCreate, ClientSegment, SegmentFilters, FilterOptions } from '@/types';
const API_BASE = import.meta.env.PROD
? 'https://api.thenetwork.donovankelly.xyz/api'
@@ -520,6 +520,50 @@ class ApiClient {
});
}
// Email Templates
async getTemplates(category?: string): Promise<EmailTemplate[]> {
const params = category ? `?category=${encodeURIComponent(category)}` : '';
return this.fetch(`/templates${params}`);
}
async getTemplate(id: string): Promise<EmailTemplate> {
return this.fetch(`/templates/${id}`);
}
async createTemplate(data: EmailTemplateCreate): Promise<EmailTemplate> {
return this.fetch('/templates', { method: 'POST', body: JSON.stringify(data) });
}
async updateTemplate(id: string, data: Partial<EmailTemplateCreate>): Promise<EmailTemplate> {
return this.fetch(`/templates/${id}`, { method: 'PUT', body: JSON.stringify(data) });
}
async useTemplate(id: string, variables?: Record<string, string>): Promise<{ subject: string; content: string; templateId: string; templateName: string }> {
return this.fetch(`/templates/${id}/use`, { method: 'POST', body: JSON.stringify({ variables }) });
}
async deleteTemplate(id: string): Promise<{ success: boolean }> {
return this.fetch(`/templates/${id}`, { method: 'DELETE' });
}
// Client Segments
async getSegments(): Promise<ClientSegment[]> {
return this.fetch('/segments');
}
async getSegment(id: string): Promise<ClientSegment> {
return this.fetch(`/segments/${id}`);
}
async previewSegment(filters: SegmentFilters): Promise<{ count: number; clients: Client[] }> {
return this.fetch('/segments/preview', { method: 'POST', body: JSON.stringify({ filters }) });
}
async getFilterOptions(): Promise<FilterOptions> {
return this.fetch('/segments/filter-options');
}
async createSegment(data: { name: string; description?: string; filters: SegmentFilters; color?: string; pinned?: boolean }): Promise<ClientSegment> {
return this.fetch('/segments', { method: 'POST', body: JSON.stringify(data) });
}
async updateSegment(id: string, data: Partial<{ name: string; description: string; filters: SegmentFilters; color: string; pinned: boolean }>): Promise<ClientSegment> {
return this.fetch(`/segments/${id}`, { method: 'PUT', body: JSON.stringify(data) });
}
async deleteSegment(id: string): Promise<{ success: boolean }> {
return this.fetch(`/segments/${id}`, { method: 'DELETE' });
}
async exportClientsCSV(): Promise<void> {
const token = this.getToken();
const headers: HeadersInit = token ? { Authorization: `Bearer ${token}` } : {};

352
src/pages/SegmentsPage.tsx Normal file
View 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
View 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>
);
}

View File

@@ -244,6 +244,64 @@ export interface BulkEmailResult {
generated: number;
}
export interface EmailTemplate {
id: string;
userId: string;
name: string;
category: string;
subject: string;
content: string;
isDefault: boolean;
usageCount: number;
createdAt: string;
updatedAt: string;
}
export interface EmailTemplateCreate {
name: string;
category: string;
subject: string;
content: string;
isDefault?: boolean;
}
export interface SegmentFilters {
stages?: string[];
tags?: string[];
industries?: string[];
cities?: string[];
states?: string[];
lastContactedBefore?: string;
lastContactedAfter?: string;
createdBefore?: string;
createdAfter?: string;
hasEmail?: boolean;
hasPhone?: boolean;
search?: string;
}
export interface ClientSegment {
id: string;
userId: string;
name: string;
description?: string;
filters: SegmentFilters;
color: string;
pinned: boolean;
createdAt: string;
updatedAt: string;
clientCount?: number;
clients?: Client[];
}
export interface FilterOptions {
industries: string[];
cities: string[];
states: string[];
tags: string[];
stages: string[];
}
export interface Invite {
id: string;
email: string;