diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 62d75d3..f702510 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -42,8 +42,8 @@ jobs: steps: - name: Deploy to Dokploy run: | - curl -fsSL -X POST "${{ secrets.DOKPLOY_URL }}/api/application.deploy" \ + curl -fsSL -X POST "${{ secrets.DOKPLOY_URL }}/api/compose.deploy" \ -H "Content-Type: application/json" \ -H "x-api-key: ${{ secrets.DOKPLOY_API_TOKEN }}" \ - -d '{"applicationId": "${{ secrets.DOKPLOY_APP_ID }}"}' + -d '{"composeId": "${{ secrets.DOKPLOY_COMPOSE_ID }}"}' echo "Deploy triggered on Dokploy" diff --git a/src/App.tsx b/src/App.tsx index 55ba129..ed506b0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,7 +4,8 @@ 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 { ToastContainer } from '@/components/Toast'; +import { toast } from '@/lib/toast'; import { api } from '@/lib/api'; const LoginPage = lazy(() => import('@/pages/LoginPage')); diff --git a/src/components/BulkEmailModal.tsx b/src/components/BulkEmailModal.tsx index 1de0b64..4514ca5 100644 --- a/src/components/BulkEmailModal.tsx +++ b/src/components/BulkEmailModal.tsx @@ -3,7 +3,7 @@ import { api } from '@/lib/api'; import type { Client, BulkEmailResult } from '@/types'; import Modal from '@/components/Modal'; import LoadingSpinner from '@/components/LoadingSpinner'; -import { Sparkles, Send, CheckCircle2, XCircle, Search, X, Users, ChevronLeft, ChevronRight } from 'lucide-react'; +import { Sparkles, Send, CheckCircle2, XCircle, Search, Users, ChevronLeft, ChevronRight } from 'lucide-react'; import { cn } from '@/lib/utils'; interface BulkEmailModalProps { @@ -230,7 +230,7 @@ export default function BulkEmailModal({ isOpen, onClose, clients, onComplete }: setProvider(e.target.value as any)} + onChange={(e) => setProvider(e.target.value as 'anthropic' | 'openai')} className={inputClass} > diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index a3129c4..76a4a47 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -5,7 +5,7 @@ import { cn } from '@/lib/utils'; import { api } from '@/lib/api'; import { LayoutDashboard, Users, Calendar, Mail, Settings, Shield, - LogOut, Menu, X, ChevronLeft, Network, BarChart3, Search, Command, + LogOut, Menu, X, ChevronLeft, Network, BarChart3, Search, FileText, Bookmark, ScrollText, Tag, Zap, Database, } from 'lucide-react'; import NotificationBell from './NotificationBell'; diff --git a/src/components/MeetingPrepModal.tsx b/src/components/MeetingPrepModal.tsx index 75ab020..59f8e79 100644 --- a/src/components/MeetingPrepModal.tsx +++ b/src/components/MeetingPrepModal.tsx @@ -36,15 +36,29 @@ export default function MeetingPrepModal({ isOpen, onClose, clientId, clientName 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)); - } + if (!isOpen || !clientId) return; + let cancelled = false; + const fetchPrep = async () => { + try { + const data = await api.getMeetingPrep(clientId); + if (!cancelled) { + setPrep(data); + setError(''); + } + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : 'Failed to generate meeting prep'); + setPrep(null); + } + } finally { + if (!cancelled) setLoading(false); + } + }; + setLoading(true); + setError(''); + setPrep(null); + fetchPrep(); + return () => { cancelled = true; }; }, [isOpen, clientId]); const handlePrint = () => { diff --git a/src/components/NotificationBell.tsx b/src/components/NotificationBell.tsx index bb0659e..3b77410 100644 --- a/src/components/NotificationBell.tsx +++ b/src/components/NotificationBell.tsx @@ -2,7 +2,7 @@ import { useEffect, useState, useRef } from 'react'; import { Link } from 'react-router-dom'; import { api } from '@/lib/api'; import type { Notification } from '@/types'; -import { Bell, Clock, X, CheckCheck, Trash2, User } from 'lucide-react'; +import { Bell, Clock, X, CheckCheck, User } from 'lucide-react'; import { cn } from '@/lib/utils'; const typeColors: Record = { @@ -28,7 +28,18 @@ export default function NotificationBell() { const [open, setOpen] = useState(false); const ref = useRef(null); + const fetchNotifications = async () => { + try { + const data = await api.getNotifications({ limit: 30 }); + setNotifications(data.notifications || []); + setUnreadCount(data.unreadCount || 0); + } catch { + /* silently handled - API might not have notifications table yet */ + } + }; + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- initial fetch on mount is intentional fetchNotifications(); const interval = setInterval(fetchNotifications, 60 * 1000); // poll every minute return () => clearInterval(interval); @@ -44,22 +55,12 @@ export default function NotificationBell() { return () => document.removeEventListener('mousedown', handleClick); }, [open]); - const fetchNotifications = async () => { - try { - const data = await api.getNotifications({ limit: 30 }); - setNotifications(data.notifications || []); - setUnreadCount(data.unreadCount || 0); - } catch { - // Silently fail - API might not have notifications table yet - } - }; - const markRead = async (id: string) => { try { await api.markNotificationRead(id); setNotifications(prev => prev.map(n => n.id === id ? { ...n, read: true } : n)); setUnreadCount(prev => Math.max(0, prev - 1)); - } catch {} + } catch { /* silently handled */ } }; const markAllRead = async () => { @@ -67,7 +68,7 @@ export default function NotificationBell() { await api.markAllNotificationsRead(); setNotifications(prev => prev.map(n => ({ ...n, read: true }))); setUnreadCount(0); - } catch {} + } catch { /* silently handled */ } }; const remove = async (id: string) => { @@ -76,7 +77,7 @@ export default function NotificationBell() { const wasUnread = notifications.find(n => n.id === id && !n.read); setNotifications(prev => prev.filter(n => n.id !== id)); if (wasUnread) setUnreadCount(prev => Math.max(0, prev - 1)); - } catch {} + } catch { /* silently handled */ } }; return ( diff --git a/src/components/OnboardingWizard.tsx b/src/components/OnboardingWizard.tsx index b7169b7..c92ecfa 100644 --- a/src/components/OnboardingWizard.tsx +++ b/src/components/OnboardingWizard.tsx @@ -2,8 +2,8 @@ import { useState } from 'react'; import { api } from '@/lib/api'; import { cn } from '@/lib/utils'; import { - Sparkles, User, UserPlus, MessageSquare, Compass, - ChevronRight, ChevronLeft, X, Check, + Sparkles, UserPlus, MessageSquare, Compass, + ChevronRight, ChevronLeft, Check, } from 'lucide-react'; import LoadingSpinner from '@/components/LoadingSpinner'; @@ -112,8 +112,6 @@ export default function OnboardingWizard({ userName, onComplete }: OnboardingWiz } }; - const currentStep = STEPS[step]; - return (
diff --git a/src/components/Toast.tsx b/src/components/Toast.tsx index f1e972f..78e5255 100644 --- a/src/components/Toast.tsx +++ b/src/components/Toast.tsx @@ -1,42 +1,11 @@ import { useEffect, useState, useCallback } from 'react'; import { X, CheckCircle2, AlertCircle, AlertTriangle, Info } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { toastListeners, toasts as globalToasts, notifyListeners } from '@/lib/toast'; +import type { ToastItem } from '@/lib/toast'; -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); +// Types and functions re-exported from @/lib/toast for backward compat +// Import directly from @/lib/toast for non-component usage const iconMap = { success: CheckCircle2, @@ -65,12 +34,14 @@ export function ToastContainer() { useEffect(() => { toastListeners.push(setItems); return () => { - toastListeners = toastListeners.filter(fn => fn !== setItems); + const idx = toastListeners.indexOf(setItems); + if (idx >= 0) toastListeners.splice(idx, 1); }; }, []); const dismiss = useCallback((id: string) => { - toasts = toasts.filter(t => t.id !== id); + const idx = globalToasts.findIndex(t => t.id === id); + if (idx >= 0) globalToasts.splice(idx, 1); notifyListeners(); }, []); diff --git a/src/lib/api.ts b/src/lib/api.ts index f1b02e9..67ec0bc 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -440,27 +440,27 @@ class ApiClient { } // Reports & Analytics - async getReportsOverview(): Promise { + async getReportsOverview(): Promise { return this.fetch('/reports/overview'); } - async getReportsGrowth(): Promise { + async getReportsGrowth(): Promise { return this.fetch('/reports/growth'); } - async getReportsIndustries(): Promise { + async getReportsIndustries(): Promise { return this.fetch('/reports/industries'); } - async getReportsTags(): Promise { + async getReportsTags(): Promise { return this.fetch('/reports/tags'); } - async getReportsEngagement(): Promise { + async getReportsEngagement(): Promise { return this.fetch('/reports/engagement'); } - async getNotificationsLegacy(): Promise { + async getNotificationsLegacy(): Promise { return this.fetch('/reports/notifications'); } @@ -880,7 +880,7 @@ export interface DuplicateClient { export interface MergeResult { success: boolean; - client: any; + client: unknown; merged: { fromId: string; fromName: string; diff --git a/src/lib/toast.ts b/src/lib/toast.ts new file mode 100644 index 0000000..7661843 --- /dev/null +++ b/src/lib/toast.ts @@ -0,0 +1,36 @@ +export type ToastType = 'success' | 'error' | 'warning' | 'info'; + +export interface ToastItem { + id: string; + type: ToastType; + message: string; + duration?: number; +} + +// Global toast state +export const toastListeners: ((toasts: ToastItem[]) => void)[] = []; +export const toasts: ToastItem[] = []; + +export 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.push({ id, type, message, duration }); + notifyListeners(); + + if (duration > 0) { + setTimeout(() => { + const idx = toasts.findIndex(t => t.id === id); + if (idx >= 0) toasts.splice(idx, 1); + 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); diff --git a/src/lib/utils.test.ts b/src/lib/utils.test.ts index 5115bdd..8f40da8 100644 --- a/src/lib/utils.test.ts +++ b/src/lib/utils.test.ts @@ -7,7 +7,8 @@ describe('cn', () => { }); it('handles conditional classes', () => { - expect(cn('base', false && 'hidden', 'visible')).toBe('base visible'); + const isHidden = false; + expect(cn('base', isHidden && 'hidden', 'visible')).toBe('base visible'); }); it('merges tailwind conflicts', () => { diff --git a/src/pages/AuditLogPage.tsx b/src/pages/AuditLogPage.tsx index 602ba3f..da13236 100644 --- a/src/pages/AuditLogPage.tsx +++ b/src/pages/AuditLogPage.tsx @@ -3,10 +3,9 @@ import { api } from '@/lib/api'; import type { AuditLog, User } from '@/types'; import { Shield, Search, ChevronDown, ChevronRight, ChevronLeft, - Filter, Calendar, User as UserIcon, Activity, + Filter, 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', diff --git a/src/pages/ClientDetailPage.tsx b/src/pages/ClientDetailPage.tsx index 4081e40..b0360dd 100644 --- a/src/pages/ClientDetailPage.tsx +++ b/src/pages/ClientDetailPage.tsx @@ -2,11 +2,11 @@ import { useEffect, useState } from 'react'; import { useParams, useNavigate, Link } from 'react-router-dom'; import { useClientsStore } from '@/stores/clients'; import { api } from '@/lib/api'; -import type { Event, Email, ActivityItem } from '@/types'; +import type { Event, Email, ActivityItem, ClientCreate } from '@/types'; import { ArrowLeft, Edit3, Trash2, Phone, Mail, MapPin, Building2, Briefcase, Gift, Heart, Star, Users, Calendar, Send, - CheckCircle2, Sparkles, Clock, Activity, FileText, UserPlus, RefreshCw, Pin, + CheckCircle2, Sparkles, Clock, Activity, FileText, UserPlus, RefreshCw, } from 'lucide-react'; import { usePinnedClients } from '@/hooks/usePinnedClients'; import { cn, formatDate, getRelativeTime, getInitials } from '@/lib/utils'; @@ -30,7 +30,7 @@ export default function ClientDetailPage() { const [emails, setEmails] = useState([]); const [activities, setActivities] = useState([]); const [activeTab, setActiveTab] = useState<'info' | 'notes' | 'activity' | 'events' | 'emails'>('info'); - const [interactions, setInteractions] = useState([]); + const [, setInteractions] = useState([]); const [showEdit, setShowEdit] = useState(false); const [showCompose, setShowCompose] = useState(false); const [showLogInteraction, setShowLogInteraction] = useState(false); @@ -68,7 +68,7 @@ export default function ClientDetailPage() { await markContacted(client.id); }; - const handleUpdate = async (data: any) => { + const handleUpdate = async (data: ClientCreate) => { await updateClient(client.id, data); setShowEdit(false); }; @@ -110,7 +110,7 @@ export default function ClientDetailPage() { const stages = ['lead', 'prospect', 'onboarding', 'active', 'inactive']; const currentIdx = stages.indexOf(client.stage || 'lead'); const nextStage = stages[(currentIdx + 1) % stages.length]; - await updateClient(client.id, { stage: nextStage } as any); + await updateClient(client.id, { stage: nextStage as ClientCreate['stage'] }); }} /> {client.tags && client.tags.length > 0 && ( client.tags.map((tag) => {tag}) diff --git a/src/pages/ClientsPage.tsx b/src/pages/ClientsPage.tsx index 2f66ae0..1451b3d 100644 --- a/src/pages/ClientsPage.tsx +++ b/src/pages/ClientsPage.tsx @@ -1,7 +1,8 @@ import { useEffect, useState, useMemo, useCallback } from 'react'; import { Link, useLocation, useSearchParams } from 'react-router-dom'; import { useClientsStore } from '@/stores/clients'; -import { Search, Plus, Users, X, Upload, LayoutGrid, List, Kanban, ChevronLeft, ChevronRight } from 'lucide-react'; +import type { ClientCreate } from '@/types'; +import { Search, Plus, Users, X, Upload, LayoutGrid, Kanban, ChevronLeft, ChevronRight } from 'lucide-react'; import { cn, getRelativeTime, getInitials } from '@/lib/utils'; import Badge, { StageBadge } from '@/components/Badge'; import EmptyState from '@/components/EmptyState'; @@ -121,7 +122,7 @@ export default function ClientsPage() { return Array.from(tags).sort(); }, [clients]); - const handleCreate = async (data: any) => { + const handleCreate = async (data: ClientCreate) => { setCreating(true); try { await createClient(data); diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx index aeb97cf..a97c649 100644 --- a/src/pages/DashboardPage.tsx +++ b/src/pages/DashboardPage.tsx @@ -3,7 +3,7 @@ import { Link } from 'react-router-dom'; import { api } from '@/lib/api'; import type { Client, Event, Email, InsightsData } from '@/types'; import type { Interaction } from '@/types'; -import { Users, Calendar, Mail, Plus, ArrowRight, Gift, Heart, Clock, AlertTriangle, Sparkles, UserCheck, PhoneForwarded, Star, Phone, FileText, MoreHorizontal } from 'lucide-react'; +import { Users, Calendar, Mail, Plus, ArrowRight, Gift, Heart, Clock, AlertTriangle, Sparkles, PhoneForwarded, Star, Phone, FileText, MoreHorizontal } from 'lucide-react'; import { formatDate, getDaysUntil, getInitials } from '@/lib/utils'; import { EventTypeBadge } from '@/components/Badge'; import { PageLoader } from '@/components/LoadingSpinner'; diff --git a/src/pages/EmailsPage.tsx b/src/pages/EmailsPage.tsx index ae2e72c..c7b73c1 100644 --- a/src/pages/EmailsPage.tsx +++ b/src/pages/EmailsPage.tsx @@ -32,7 +32,7 @@ export default function EmailsPage() { await generateEmail(composeForm.clientId, composeForm.purpose, composeForm.provider); setShowCompose(false); setComposeForm({ clientId: '', purpose: '', provider: 'anthropic' }); - } catch {} + } catch { /* silently handled */ } }; const handleGenerateBirthday = async () => { @@ -40,7 +40,7 @@ export default function EmailsPage() { await generateBirthdayEmail(composeForm.clientId, composeForm.provider); setShowCompose(false); setComposeForm({ clientId: '', purpose: '', provider: 'anthropic' }); - } catch {} + } catch { /* silently handled */ } }; const startEdit = (email: typeof emails[0]) => { @@ -223,7 +223,7 @@ export default function EmailsPage() { setForm({ ...form, type: e.target.value as any })} className={inputClass}> +