From 13408931448f24fa97f8c2c879938bb4123fcce0 Mon Sep 17 00:00:00 2001 From: Hammer Date: Fri, 30 Jan 2026 01:21:26 +0000 Subject: [PATCH] feat: audit log page, meeting prep modal, communication style, error boundaries + toast - AuditLogPage: filterable table with expandable details (admin only) - MeetingPrepModal: AI-generated meeting briefs with health score, talking points, conversation starters - Communication Style section in Settings: tone, greeting, signoff, writing samples, avoid words - ErrorBoundary wrapping all page routes with Try Again button - Global toast system with API error interceptor (401/403/500) - ToastContainer with success/error/warning/info variants - Print CSS for meeting prep - Audit Log added to sidebar nav for admins - All 80 frontend tests pass, clean build --- src/App.tsx | 51 ++++-- src/components/ErrorBoundary.tsx | 74 ++++++++ src/components/Layout.tsx | 9 +- src/components/MeetingPrepModal.tsx | 230 +++++++++++++++++++++++++ src/components/Toast.tsx | 104 ++++++++++++ src/index.css | 8 + src/lib/api.ts | 60 ++++++- src/pages/AuditLogPage.tsx | 254 ++++++++++++++++++++++++++++ src/pages/ClientDetailPage.tsx | 14 ++ src/pages/SettingsPage.tsx | 182 +++++++++++++++++++- src/types/index.ts | 55 ++++++ 11 files changed, 1019 insertions(+), 22 deletions(-) create mode 100644 src/components/ErrorBoundary.tsx create mode 100644 src/components/MeetingPrepModal.tsx create mode 100644 src/components/Toast.tsx create mode 100644 src/pages/AuditLogPage.tsx diff --git a/src/App.tsx b/src/App.tsx index 108e07d..01d151e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,9 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { useAuthStore } from '@/stores/auth'; import Layout from '@/components/Layout'; import { PageLoader } from '@/components/LoadingSpinner'; +import ErrorBoundary from '@/components/ErrorBoundary'; +import { ToastContainer, toast } from '@/components/Toast'; +import { api } from '@/lib/api'; const LoginPage = lazy(() => import('@/pages/LoginPage')); const DashboardPage = lazy(() => import('@/pages/DashboardPage')); @@ -19,6 +22,7 @@ const SegmentsPage = lazy(() => import('@/pages/SegmentsPage')); const InvitePage = lazy(() => import('@/pages/InvitePage')); const ForgotPasswordPage = lazy(() => import('@/pages/ForgotPasswordPage')); const ResetPasswordPage = lazy(() => import('@/pages/ResetPasswordPage')); +const AuditLogPage = lazy(() => import('@/pages/AuditLogPage')); function ProtectedRoute({ children }: { children: React.ReactNode }) { const { isAuthenticated, isLoading } = useAuthStore(); @@ -27,6 +31,21 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) { return <>{children}; } +// Setup global API error interceptor +api.setErrorHandler((status, message) => { + if (status === 401) { + toast.error('Session expired. Please log in again.'); + } else if (status === 403) { + toast.error('Access denied: ' + message); + } else if (status >= 500) { + toast.error('Server error: ' + message); + } +}); + +function PageErrorBoundary({ children }: { children: React.ReactNode }) { + return {children}; +} + export default function App() { const { checkSession, isAuthenticated } = useAuthStore(); @@ -39,30 +58,32 @@ export default function App() { }> : + isAuthenticated ? : } /> - } /> - } /> - } /> + } /> + } /> + } /> }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + ); } diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..2a01f53 --- /dev/null +++ b/src/components/ErrorBoundary.tsx @@ -0,0 +1,74 @@ +import { Component, type ReactNode } from 'react'; +import { AlertTriangle, RefreshCw } from 'lucide-react'; + +interface Props { + children: ReactNode; + fallback?: ReactNode; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +export class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + console.error('[ErrorBoundary] Caught error:', error, errorInfo); + } + + handleReset = () => { + this.setState({ hasError: false, error: null }); + }; + + render() { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback; + } + + return ( +
+
+ +
+

+ Something went wrong +

+

+ An unexpected error occurred. Please try again or refresh the page. +

+ {this.state.error && ( +
+ + Error details + +
+                {this.state.error.message}
+              
+
+ )} + +
+ ); + } + + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 368050a..cff8974 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -5,7 +5,7 @@ import { cn } from '@/lib/utils'; import { LayoutDashboard, Users, Calendar, Mail, Settings, Shield, LogOut, Menu, X, ChevronLeft, Network, BarChart3, Search, Command, - FileText, Bookmark, + FileText, Bookmark, ScrollText, } from 'lucide-react'; import NotificationBell from './NotificationBell'; import CommandPalette from './CommandPalette'; @@ -23,14 +23,17 @@ const baseNavItems = [ { path: '/settings', label: 'Settings', icon: Settings }, ]; -const adminNavItem = { path: '/admin', label: 'Admin', icon: Shield }; +const adminNavItems = [ + { path: '/admin', label: 'Admin', icon: Shield }, + { path: '/audit-log', label: 'Audit Log', icon: ScrollText }, +]; export default function Layout() { const [collapsed, setCollapsed] = useState(false); const [mobileOpen, setMobileOpen] = useState(false); const location = useLocation(); const { user, logout } = useAuthStore(); - const navItems = user?.role === 'admin' ? [...baseNavItems, adminNavItem] : baseNavItems; + const navItems = user?.role === 'admin' ? [...baseNavItems, ...adminNavItems] : baseNavItems; const handleLogout = async () => { await logout(); diff --git a/src/components/MeetingPrepModal.tsx b/src/components/MeetingPrepModal.tsx new file mode 100644 index 0000000..75ab020 --- /dev/null +++ b/src/components/MeetingPrepModal.tsx @@ -0,0 +1,230 @@ +import { useState, useEffect } from 'react'; +import { api } from '@/lib/api'; +import type { MeetingPrep } from '@/types'; +import Modal from './Modal'; +import { + Briefcase, Heart, MessageSquare, CheckSquare, Calendar, + FileText, Star, Printer, Sparkles, TrendingUp, Clock, AlertCircle, +} from 'lucide-react'; +import LoadingSpinner from './LoadingSpinner'; +import { cn, formatDate } from '@/lib/utils'; + +interface Props { + isOpen: boolean; + onClose: () => void; + clientId: string; + clientName: string; +} + +function HealthScoreBadge({ score }: { score: number }) { + const color = score >= 80 ? 'text-emerald-600 bg-emerald-100 dark:bg-emerald-900/30 dark:text-emerald-400' + : score >= 50 ? 'text-amber-600 bg-amber-100 dark:bg-amber-900/30 dark:text-amber-400' + : 'text-red-600 bg-red-100 dark:bg-red-900/30 dark:text-red-400'; + const label = score >= 80 ? 'Strong' : score >= 50 ? 'Moderate' : 'Needs Attention'; + + return ( +
+ + {score}/100 · {label} +
+ ); +} + +export default function MeetingPrepModal({ isOpen, onClose, clientId, clientName }: Props) { + const [prep, setPrep] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + useEffect(() => { + if (isOpen && clientId) { + setLoading(true); + setError(''); + setPrep(null); + api.getMeetingPrep(clientId) + .then(setPrep) + .catch(err => setError(err.message || 'Failed to generate meeting prep')) + .finally(() => setLoading(false)); + } + }, [isOpen, clientId]); + + const handlePrint = () => { + window.print(); + }; + + return ( + + {loading ? ( +
+ +

+ + Preparing your meeting brief... +

+
+ ) : error ? ( +
+ +

{error}

+
+ ) : prep ? ( +
+ {/* Print button */} +
+ +
+ + {/* Client Summary + Health */} +
+
+
+

{prep.client.name}

+

+ {prep.client.role !== 'N/A' && `${prep.client.role} at `} + {prep.client.company !== 'N/A' ? prep.client.company : ''} + {prep.client.industry !== 'N/A' && ` · ${prep.client.industry}`} +

+

+ + Last contact: {prep.client.daysSinceLastContact === 999 ? 'Never' : `${prep.client.daysSinceLastContact} days ago`} +

+
+ +
+ {prep.aiTalkingPoints.summary && ( +

+ {prep.aiTalkingPoints.summary} +

+ )} +
+ + {/* AI Talking Points */} +
+ {/* Suggested Topics */} +
+

+ + Suggested Topics +

+
    + {prep.aiTalkingPoints.suggestedTopics.map((topic, i) => ( +
  • + + {i + 1} + + {topic} +
  • + ))} +
+
+ + {/* Conversation Starters */} +
+

+ + Conversation Starters +

+
    + {prep.aiTalkingPoints.conversationStarters.map((starter, i) => ( +
  • + "{starter}" +
  • + ))} +
+
+ + {/* Follow-up Items */} +
+

+ + Follow-up Items +

+
    + {prep.aiTalkingPoints.followUpItems.map((item, i) => ( +
  • + + {item} +
  • + ))} +
+
+ + {/* Important Dates */} +
+

+ + Important Dates +

+ {prep.importantDates.length > 0 ? ( +
    + {prep.importantDates.map((d, i) => ( +
  • + {d.type === 'birthday' ? : } + {d.label} +
  • + ))} +
+ ) : ( +

No notable upcoming dates

+ )} + {prep.upcomingEvents.length > 0 && ( +
+

Upcoming Events

+ {prep.upcomingEvents.map(e => ( +
+ {e.title} · {formatDate(e.date)} +
+ ))} +
+ )} +
+
+ + {/* Recent Notes */} + {prep.notes.length > 0 && ( +
+

+ + Recent Notes +

+
+ {prep.notes.map(note => ( +
+ {note.content} +
{formatDate(note.createdAt)}
+
+ ))} +
+
+ )} + + {/* Recent Interactions */} + {prep.recentInteractions.length > 0 && ( +
+

+ + Recent Interactions +

+
+ {prep.recentInteractions.map((interaction, i) => ( +
+ + {interaction.type} + + {interaction.title} + {formatDate(interaction.date)} +
+ ))} +
+
+ )} +
+ ) : null} +
+ ); +} diff --git a/src/components/Toast.tsx b/src/components/Toast.tsx new file mode 100644 index 0000000..f1e972f --- /dev/null +++ b/src/components/Toast.tsx @@ -0,0 +1,104 @@ +import { useEffect, useState, useCallback } from 'react'; +import { X, CheckCircle2, AlertCircle, AlertTriangle, Info } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +export type ToastType = 'success' | 'error' | 'warning' | 'info'; + +interface ToastItem { + id: string; + type: ToastType; + message: string; + duration?: number; +} + +// Global toast state +let toastListeners: ((toasts: ToastItem[]) => void)[] = []; +let toasts: ToastItem[] = []; + +function notifyListeners() { + toastListeners.forEach(fn => fn([...toasts])); +} + +export function showToast(type: ToastType, message: string, duration = 5000) { + const id = Math.random().toString(36).slice(2); + toasts = [...toasts, { id, type, message, duration }]; + notifyListeners(); + + if (duration > 0) { + setTimeout(() => { + toasts = toasts.filter(t => t.id !== id); + notifyListeners(); + }, duration); + } +} + +export function toast(message: string) { showToast('info', message); } +toast.success = (msg: string) => showToast('success', msg); +toast.error = (msg: string) => showToast('error', msg, 7000); +toast.warning = (msg: string) => showToast('warning', msg); +toast.info = (msg: string) => showToast('info', msg); + +const iconMap = { + success: CheckCircle2, + error: AlertCircle, + warning: AlertTriangle, + info: Info, +}; + +const colorMap = { + success: 'bg-emerald-50 dark:bg-emerald-900/30 border-emerald-200 dark:border-emerald-800 text-emerald-800 dark:text-emerald-200', + error: 'bg-red-50 dark:bg-red-900/30 border-red-200 dark:border-red-800 text-red-800 dark:text-red-200', + warning: 'bg-amber-50 dark:bg-amber-900/30 border-amber-200 dark:border-amber-800 text-amber-800 dark:text-amber-200', + info: 'bg-blue-50 dark:bg-blue-900/30 border-blue-200 dark:border-blue-800 text-blue-800 dark:text-blue-200', +}; + +const iconColorMap = { + success: 'text-emerald-500', + error: 'text-red-500', + warning: 'text-amber-500', + info: 'text-blue-500', +}; + +export function ToastContainer() { + const [items, setItems] = useState([]); + + useEffect(() => { + toastListeners.push(setItems); + return () => { + toastListeners = toastListeners.filter(fn => fn !== setItems); + }; + }, []); + + const dismiss = useCallback((id: string) => { + toasts = toasts.filter(t => t.id !== id); + notifyListeners(); + }, []); + + if (items.length === 0) return null; + + return ( +
+ {items.map(item => { + const Icon = iconMap[item.type]; + return ( +
+ +

{item.message}

+ +
+ ); + })} +
+ ); +} diff --git a/src/index.css b/src/index.css index b62c980..d8e441f 100644 --- a/src/index.css +++ b/src/index.css @@ -68,3 +68,11 @@ html.dark body { .animate-slide-up { animation: slideUp 0.15s ease-out; } + +/* Print styles for meeting prep */ +@media print { + body { background: white !important; color: black !important; } + [data-print-hide] { display: none !important; } + aside, header, nav { display: none !important; } + main { padding: 0 !important; overflow: visible !important; } +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 693be7e..301400b 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, EmailTemplate, EmailTemplateCreate, ClientSegment, SegmentFilters, FilterOptions } 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, AuditLogsResponse, MeetingPrep, CommunicationStyle } from '@/types'; const API_BASE = import.meta.env.PROD ? 'https://api.thenetwork.donovankelly.xyz/api' @@ -28,6 +28,13 @@ class ApiClient { return token ? { Authorization: `Bearer ${token}` } : {}; } + // Global error handler callback + private onApiError: ((status: number, message: string) => void) | null = null; + + setErrorHandler(handler: (status: number, message: string) => void) { + this.onApiError = handler; + } + private async fetch(path: string, options: RequestInit = {}): Promise { const headers: HeadersInit = { 'Content-Type': 'application/json', @@ -43,7 +50,14 @@ class ApiClient { if (!response.ok) { const error = await response.json().catch(() => ({ error: 'Unknown error' })); - throw new Error(error.error || error.message || 'Request failed'); + const message = error.error || error.message || 'Request failed'; + + // Fire global error handler + if (this.onApiError) { + this.onApiError(response.status, message); + } + + throw new Error(message); } const text = await response.text(); @@ -564,6 +578,48 @@ class ApiClient { return this.fetch(`/segments/${id}`, { method: 'DELETE' }); } + // Audit Logs (admin) + async getAuditLogs(params?: { + entityType?: string; + action?: string; + userId?: string; + startDate?: string; + endDate?: string; + search?: string; + page?: number; + limit?: number; + }): Promise { + const searchParams = new URLSearchParams(); + if (params?.entityType) searchParams.set('entityType', params.entityType); + if (params?.action) searchParams.set('action', params.action); + if (params?.userId) searchParams.set('userId', params.userId); + if (params?.startDate) searchParams.set('startDate', params.startDate); + if (params?.endDate) searchParams.set('endDate', params.endDate); + if (params?.search) searchParams.set('search', params.search); + if (params?.page) searchParams.set('page', String(params.page)); + if (params?.limit) searchParams.set('limit', String(params.limit)); + const query = searchParams.toString(); + return this.fetch(`/audit-logs${query ? `?${query}` : ''}`); + } + + // Meeting Prep + async getMeetingPrep(clientId: string, provider?: string): Promise { + const query = provider ? `?provider=${provider}` : ''; + return this.fetch(`/clients/${clientId}/meeting-prep${query}`); + } + + // Communication Style + async getCommunicationStyle(): Promise { + return this.fetch('/profile/communication-style'); + } + + async updateCommunicationStyle(style: Partial): Promise { + return this.fetch('/profile/communication-style', { + method: 'PATCH', + body: JSON.stringify(style), + }); + } + async exportClientsCSV(): Promise { const token = this.getToken(); const headers: HeadersInit = token ? { Authorization: `Bearer ${token}` } : {}; diff --git a/src/pages/AuditLogPage.tsx b/src/pages/AuditLogPage.tsx new file mode 100644 index 0000000..602ba3f --- /dev/null +++ b/src/pages/AuditLogPage.tsx @@ -0,0 +1,254 @@ +import { useEffect, useState, useCallback } from 'react'; +import { api } from '@/lib/api'; +import type { AuditLog, User } from '@/types'; +import { + Shield, Search, ChevronDown, ChevronRight, ChevronLeft, + Filter, Calendar, User as UserIcon, Activity, +} from 'lucide-react'; +import { PageLoader } from '@/components/LoadingSpinner'; +import { formatDate } from '@/lib/utils'; + +const ACTION_COLORS: Record = { + create: 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300', + update: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300', + delete: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300', + view: 'bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300', + send: 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300', + login: 'bg-cyan-100 dark:bg-cyan-900/30 text-cyan-700 dark:text-cyan-300', + logout: 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300', + password_change: 'bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300', +}; + +const ENTITY_TYPES = ['client', 'email', 'event', 'template', 'segment', 'user', 'auth', 'interaction', 'note', 'notification', 'invite', 'profile']; +const ACTIONS = ['create', 'update', 'delete', 'view', 'send', 'login', 'logout', 'password_change']; + +export default function AuditLogPage() { + const [logs, setLogs] = useState([]); + const [loading, setLoading] = useState(true); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [expandedId, setExpandedId] = useState(null); + + // Filters + const [entityType, setEntityType] = useState(''); + const [action, setAction] = useState(''); + const [search, setSearch] = useState(''); + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); + const [users, setUsers] = useState([]); + const [userId, setUserId] = useState(''); + + const fetchLogs = useCallback(async () => { + setLoading(true); + try { + const data = await api.getAuditLogs({ + entityType: entityType || undefined, + action: action || undefined, + userId: userId || undefined, + startDate: startDate || undefined, + endDate: endDate || undefined, + search: search || undefined, + page, + limit: 25, + }); + setLogs(data.logs); + setTotal(data.total); + setTotalPages(data.totalPages); + } catch (err) { + console.error('Failed to fetch audit logs:', err); + } finally { + setLoading(false); + } + }, [entityType, action, userId, startDate, endDate, search, page]); + + useEffect(() => { + fetchLogs(); + }, [fetchLogs]); + + useEffect(() => { + api.getUsers().then(setUsers).catch(() => {}); + }, []); + + // Reset page on filter change + useEffect(() => { + setPage(1); + }, [entityType, action, userId, startDate, endDate, search]); + + const inputClass = '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:outline-none focus:ring-2 focus:ring-blue-500'; + const selectClass = `${inputClass} appearance-none pr-8`; + + return ( +
+ {/* Header */} +
+
+

+ + Audit Log +

+

+ {total} total entries · Compliance audit trail +

+
+
+ + {/* Filters */} +
+
+ + Filters +
+
+
+ + setSearch(e.target.value)} + className={`${inputClass} pl-9 w-full`} + /> +
+ + + + setStartDate(e.target.value)} + placeholder="Start date" + className={`${inputClass} w-full`} + /> + setEndDate(e.target.value)} + placeholder="End date" + className={`${inputClass} w-full`} + /> +
+
+ + {/* Table */} +
+ {loading ? ( +
+ ) : logs.length === 0 ? ( +
+ + No audit logs found +
+ ) : ( +
+ + + + + + + + + + + + + {logs.map(log => ( + <> + setExpandedId(expandedId === log.id ? null : log.id)} + > + + + + + + + + {expandedId === log.id && log.details && ( + + + + )} + + ))} + +
TimeUserActionEntityIP Address
+ {log.details ? ( + expandedId === log.id + ? + : + ) : } + + {new Date(log.createdAt).toLocaleString()} + +
+
+ +
+ {log.userName || 'System'} +
+
+ + {log.action} + + + {log.entityType} + {log.entityId && ( + {log.entityId.slice(0, 8)}... + )} + + {log.ipAddress || '—'} +
+
Details
+
+                            {JSON.stringify(log.details, null, 2)}
+                          
+ {log.userAgent && ( +
+ UA: {log.userAgent} +
+ )} +
+
+ )} + + {/* Pagination */} + {totalPages > 1 && ( +
+ + Page {page} of {totalPages} ({total} entries) + +
+ + +
+
+ )} +
+
+ ); +} diff --git a/src/pages/ClientDetailPage.tsx b/src/pages/ClientDetailPage.tsx index 85f6df6..a0bebc3 100644 --- a/src/pages/ClientDetailPage.tsx +++ b/src/pages/ClientDetailPage.tsx @@ -17,6 +17,7 @@ import ClientForm from '@/components/ClientForm'; import EmailComposeModal from '@/components/EmailComposeModal'; import ClientNotes from '@/components/ClientNotes'; import LogInteractionModal from '@/components/LogInteractionModal'; +import MeetingPrepModal from '@/components/MeetingPrepModal'; import type { Interaction } from '@/types'; export default function ClientDetailPage() { @@ -31,6 +32,7 @@ export default function ClientDetailPage() { const [showEdit, setShowEdit] = useState(false); const [showCompose, setShowCompose] = useState(false); const [showLogInteraction, setShowLogInteraction] = useState(false); + const [showMeetingPrep, setShowMeetingPrep] = useState(false); const [deleting, setDeleting] = useState(false); const { togglePin, isPinned } = usePinnedClients(); @@ -120,6 +122,10 @@ export default function ClientDetailPage() { Log Interaction + + ))} + + + + {/* Greeting & Sign-off */} +
+
+ + setCommStyle({ ...commStyle, greeting: e.target.value })} + placeholder="e.g., Hi, Hello, Dear" + className={inputClass} + /> +
+
+ + setCommStyle({ ...commStyle, signoff: e.target.value })} + placeholder="e.g., Best regards, Cheers, Warm regards" + className={inputClass} + /> +
+
+ + {/* Writing Samples */} +
+ +

Paste examples of your actual emails so AI can match your style

+ {[0, 1, 2].map(i => ( +