From 22bf4778fd2847fae3ed30bdc97b87b513f13d7f Mon Sep 17 00:00:00 2001 From: Hammer Date: Fri, 30 Jan 2026 01:07:41 +0000 Subject: [PATCH] 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 --- src/App.tsx | 4 + src/components/Layout.tsx | 3 + src/lib/api.ts | 46 ++++- src/pages/SegmentsPage.tsx | 352 ++++++++++++++++++++++++++++++++++++ src/pages/TemplatesPage.tsx | 247 +++++++++++++++++++++++++ src/types/index.ts | 58 ++++++ 6 files changed, 709 insertions(+), 1 deletion(-) create mode 100644 src/pages/SegmentsPage.tsx create mode 100644 src/pages/TemplatesPage.tsx diff --git a/src/App.tsx b/src/App.tsx index 544b7a3..108e07d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { } /> } /> } /> + } /> + } /> } /> } /> diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 3577997..368050a 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -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 }, ]; diff --git a/src/lib/api.ts b/src/lib/api.ts index 6a6b9ab..693be7e 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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 { + const params = category ? `?category=${encodeURIComponent(category)}` : ''; + return this.fetch(`/templates${params}`); + } + async getTemplate(id: string): Promise { + return this.fetch(`/templates/${id}`); + } + async createTemplate(data: EmailTemplateCreate): Promise { + return this.fetch('/templates', { method: 'POST', body: JSON.stringify(data) }); + } + async updateTemplate(id: string, data: Partial): Promise { + return this.fetch(`/templates/${id}`, { method: 'PUT', body: JSON.stringify(data) }); + } + async useTemplate(id: string, variables?: Record): 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 { + return this.fetch('/segments'); + } + async getSegment(id: string): Promise { + 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 { + return this.fetch('/segments/filter-options'); + } + async createSegment(data: { name: string; description?: string; filters: SegmentFilters; color?: string; pinned?: boolean }): Promise { + 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 { + 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 { const token = this.getToken(); const headers: HeadersInit = token ? { Authorization: `Bearer ${token}` } : {}; diff --git a/src/pages/SegmentsPage.tsx b/src/pages/SegmentsPage.tsx new file mode 100644 index 0000000..3efee31 --- /dev/null +++ b/src/pages/SegmentsPage.tsx @@ -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 ( +
+ + + {open && ( +
+ {options.map(opt => ( + + ))} + {options.length === 0 &&

No options

} +
+ )} +
+ ); +} + +function FilterPanel({ filters, onChange, options }: { + filters: SegmentFilters; onChange: (f: SegmentFilters) => void; options: FilterOptions; +}) { + return ( +
+

+ Filters +

+
+ onChange({ ...filters, stages: v.length ? v : undefined })} /> + onChange({ ...filters, industries: v.length ? v : undefined })} /> + onChange({ ...filters, tags: v.length ? v : undefined })} /> + onChange({ ...filters, states: v.length ? v : undefined })} /> + onChange({ ...filters, cities: v.length ? v : undefined })} /> +
+ + 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" /> +
+
+ + 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" /> +
+
+ + + +
+
+
+ + 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" /> +
+
+ ); +} + +export default function SegmentsPage() { + const [segments, setSegments] = useState([]); + const [options, setOptions] = useState({ industries: [], cities: [], states: [], tags: [], stages: [] }); + const [loading, setLoading] = useState(true); + + // Builder state + const [showBuilder, setShowBuilder] = useState(false); + const [editingSegment, setEditingSegment] = useState(null); + const [filters, setFilters] = useState({}); + const [previewClients, setPreviewClients] = useState([]); + const [previewCount, setPreviewCount] = useState(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 ; + + return ( +
+
+
+

Client Segments

+

+ Save filtered views of your client base for quick access +

+
+ +
+ + {/* Saved Segments */} + {segments.length === 0 ? ( + + ) : ( +
+ {segments.map(s => ( +
+
+
+
+

{s.name}

+ {s.pinned && } +
+
+ + + +
+
+ {s.description &&

{s.description}

} + {/* Filter badges */} +
+ {s.filters.stages?.map(st => ( + {st} + ))} + {s.filters.tags?.map(tg => ( + #{tg} + ))} + {s.filters.industries?.map(ind => ( + {ind} + ))} + {s.filters.hasEmail && 📧 Has email} + {s.filters.hasPhone && 📞 Has phone} +
+ +
+ ))} +
+ )} + + {/* Segment Builder Modal */} + setShowBuilder(false)} title={editingSegment ? 'Edit Segment' : 'New Segment'} size="xl"> +
+
+
+ + 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" /> +
+
+ + 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" /> +
+
+ +
+ {SEGMENT_COLORS.map(c => ( +
+
+
+ + + +
+ + {previewCount !== null && ( + + {previewCount} client{previewCount !== 1 ? 's' : ''} match + + )} +
+ + {/* Preview results */} + {previewClients.length > 0 && ( +
+ + + + + + + + + + + {previewClients.slice(0, 20).map(c => ( + + + + + + + ))} + +
NameEmailCompanyStage
{c.firstName} {c.lastName}{c.email || '—'}{c.company || '—'} + {c.stage || 'lead'} +
+ {previewClients.length > 20 && ( +

Showing 20 of {previewClients.length}

+ )} +
+ )} + +
+ + +
+
+
+
+ ); +} diff --git a/src/pages/TemplatesPage.tsx b/src/pages/TemplatesPage.tsx new file mode 100644 index 0000000..fdb9ae0 --- /dev/null +++ b/src/pages/TemplatesPage.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [filterCategory, setFilterCategory] = useState(''); + const [showEditor, setShowEditor] = useState(false); + const [editingTemplate, setEditingTemplate] = useState(null); + const [saving, setSaving] = useState(false); + + // Form state + const [form, setForm] = useState({ + 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 ; + + return ( +
+
+
+

Email Templates

+

+ Reusable templates with placeholders for quick email drafting +

+
+ +
+ + {/* Category Filter */} +
+ + {CATEGORIES.map(cat => ( + + ))} +
+ + {/* Templates Grid */} + {templates.length === 0 ? ( + + ) : ( +
+ {templates.map(t => ( +
+
+
+ + {getCategoryLabel(t.category)} + + {t.isDefault && } +
+
+ + + +
+
+

{t.name}

+

Subject: {t.subject}

+

{t.content}

+
+ Used {t.usageCount} time{t.usageCount !== 1 ? 's' : ''} +
+
+ ))} +
+ )} + + {/* Template Editor Modal */} + setShowEditor(false)} title={editingTemplate ? 'Edit Template' : 'New Template'} size="lg"> +
+
+
+ + 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" /> +
+
+ + +
+
+ +
+ + 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" /> +
+ +
+
+ +
+ {PLACEHOLDERS.map(p => ( + + ))} +
+
+