diff --git a/src/App.tsx b/src/App.tsx index 01d151e..70eeb89 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,6 +23,7 @@ const InvitePage = lazy(() => import('@/pages/InvitePage')); const ForgotPasswordPage = lazy(() => import('@/pages/ForgotPasswordPage')); const ResetPasswordPage = lazy(() => import('@/pages/ResetPasswordPage')); const AuditLogPage = lazy(() => import('@/pages/AuditLogPage')); +const TagsPage = lazy(() => import('@/pages/TagsPage')); function ProtectedRoute({ children }: { children: React.ReactNode }) { const { isAuthenticated, isLoading } = useAuthStore(); @@ -76,6 +77,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index cff8974..4e056ba 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -1,15 +1,17 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { Link, useLocation, Outlet } from 'react-router-dom'; import { useAuthStore } from '@/stores/auth'; 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, - FileText, Bookmark, ScrollText, + FileText, Bookmark, ScrollText, Tag, } from 'lucide-react'; import NotificationBell from './NotificationBell'; import CommandPalette from './CommandPalette'; import ThemeToggle from './ThemeToggle'; +import OnboardingWizard from './OnboardingWizard'; const baseNavItems = [ { path: '/', label: 'Dashboard', icon: LayoutDashboard }, @@ -18,6 +20,7 @@ const baseNavItems = [ { path: '/emails', label: 'Emails', icon: Mail }, { path: '/network', label: 'Network', icon: Network }, { path: '/templates', label: 'Templates', icon: FileText }, + { path: '/tags', label: 'Tags', icon: Tag }, { path: '/segments', label: 'Segments', icon: Bookmark }, { path: '/reports', label: 'Reports', icon: BarChart3 }, { path: '/settings', label: 'Settings', icon: Settings }, @@ -31,16 +34,36 @@ const adminNavItems = [ export default function Layout() { const [collapsed, setCollapsed] = useState(false); const [mobileOpen, setMobileOpen] = useState(false); + const [showOnboarding, setShowOnboarding] = useState(false); const location = useLocation(); const { user, logout } = useAuthStore(); const navItems = user?.role === 'admin' ? [...baseNavItems, ...adminNavItems] : baseNavItems; + // Check onboarding status on mount + useEffect(() => { + let cancelled = false; + api.getOnboardingStatus() + .then((status) => { + if (!cancelled && !status.onboardingComplete) { + setShowOnboarding(true); + } + }) + .catch(() => { /* ignore - user might not have profile yet */ }); + return () => { cancelled = true; }; + }, []); + const handleLogout = async () => { await logout(); }; return (
+ {showOnboarding && ( + setShowOnboarding(false)} + /> + )} {/* Mobile overlay */} {mobileOpen && ( diff --git a/src/components/OnboardingWizard.tsx b/src/components/OnboardingWizard.tsx new file mode 100644 index 0000000..b7169b7 --- /dev/null +++ b/src/components/OnboardingWizard.tsx @@ -0,0 +1,371 @@ +import { useState } from 'react'; +import { api } from '@/lib/api'; +import { cn } from '@/lib/utils'; +import { + Sparkles, User, UserPlus, MessageSquare, Compass, + ChevronRight, ChevronLeft, X, Check, +} from 'lucide-react'; +import LoadingSpinner from '@/components/LoadingSpinner'; + +interface OnboardingWizardProps { + userName: string; + onComplete: () => void; +} + +const STEPS = [ + { id: 'welcome', label: 'Welcome', icon: Sparkles }, + { id: 'client', label: 'First Client', icon: UserPlus }, + { id: 'style', label: 'Communication', icon: MessageSquare }, + { id: 'tour', label: 'Quick Tour', icon: Compass }, +] as const; + +const TONES = [ + { value: 'formal' as const, label: 'Formal', desc: 'Professional and polished' }, + { value: 'friendly' as const, label: 'Friendly', desc: 'Warm and approachable' }, + { value: 'casual' as const, label: 'Casual', desc: 'Relaxed and conversational' }, +]; + +const TOUR_FEATURES = [ + { icon: '👥', title: 'Clients', desc: 'Manage your contacts with detailed profiles, tags, and pipeline stages.' }, + { icon: '📧', title: 'AI Emails', desc: 'Generate personalized emails with AI that matches your writing style.' }, + { icon: '📅', title: 'Events', desc: 'Never miss a birthday, anniversary, or follow-up reminder.' }, + { icon: '🔗', title: 'Network', desc: 'Discover connections between your clients for intro opportunities.' }, + { icon: '📊', title: 'Reports', desc: 'Track engagement metrics and grow your network intentionally.' }, +]; + +export default function OnboardingWizard({ userName, onComplete }: OnboardingWizardProps) { + const [step, setStep] = useState(0); + const [saving, setSaving] = useState(false); + + // Step 1: Profile + const [name, setName] = useState(userName || ''); + const [title, setTitle] = useState(''); + const [company, setCompany] = useState(''); + + // Step 2: First client + const [clientFirstName, setClientFirstName] = useState(''); + const [clientLastName, setClientLastName] = useState(''); + const [clientEmail, setClientEmail] = useState(''); + const [clientCompany, setClientCompany] = useState(''); + const [clientAdded, setClientAdded] = useState(false); + + // Step 3: Communication style + const [tone, setTone] = useState<'formal' | 'friendly' | 'casual'>('friendly'); + const [greeting, setGreeting] = useState(''); + const [signoff, setSignoff] = useState(''); + + const handleSkip = async () => { + setSaving(true); + try { + await api.completeOnboarding(); + onComplete(); + } catch (err) { + console.error('Failed to complete onboarding:', err); + onComplete(); + } finally { + setSaving(false); + } + }; + + const handleNext = async () => { + if (step === 0) { + // Save profile + if (name || title || company) { + try { + await api.updateProfile({ name: name || undefined, title: title || undefined, company: company || undefined }); + } catch (err) { + console.error(err); + } + } + setStep(1); + } else if (step === 1) { + // Add client if filled + if (clientFirstName && clientLastName && !clientAdded) { + try { + await api.createClient({ + firstName: clientFirstName, + lastName: clientLastName, + email: clientEmail || undefined, + company: clientCompany || undefined, + }); + setClientAdded(true); + } catch (err) { + console.error(err); + } + } + setStep(2); + } else if (step === 2) { + // Save communication style + try { + await api.updateCommunicationStyle({ + tone, + greeting: greeting || undefined, + signoff: signoff || undefined, + }); + } catch (err) { + console.error(err); + } + setStep(3); + } else if (step === 3) { + // Complete + await handleSkip(); + } + }; + + const currentStep = STEPS[step]; + + return ( +
+
+ {/* Progress bar */} +
+
+
+ + {/* Step indicators */} +
+ {STEPS.map((s, i) => { + const Icon = s.icon; + return ( +
+ {i < step ? : } + {s.label} +
+ ); + })} +
+ + {/* Content */} +
+ {step === 0 && ( +
+
+
+ +
+

Welcome to NetworkCRM!

+

Let's get you set up. This only takes a minute.

+
+
+
+ + setName(e.target.value)} + placeholder="Full name" + className="w-full px-3 py-2.5 bg-white dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+
+ + setTitle(e.target.value)} + placeholder="e.g., Financial Advisor" + className="w-full px-3 py-2.5 bg-white dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+ + setCompany(e.target.value)} + placeholder="e.g., ABC Financial" + className="w-full px-3 py-2.5 bg-white dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+
+
+ )} + + {step === 1 && ( +
+
+
+ +
+

Add Your First Client

+

Get started by adding someone from your network.

+
+ {clientAdded ? ( +
+ +

+ {clientFirstName} {clientLastName} added! +

+
+ ) : ( +
+
+
+ + setClientFirstName(e.target.value)} + className="w-full px-3 py-2.5 bg-white dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+ + setClientLastName(e.target.value)} + className="w-full px-3 py-2.5 bg-white dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+
+ + setClientEmail(e.target.value)} + className="w-full px-3 py-2.5 bg-white dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+ + setClientCompany(e.target.value)} + className="w-full px-3 py-2.5 bg-white dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+ )} +
+ )} + + {step === 2 && ( +
+
+
+ +
+

Communication Style

+

Tell us how you communicate so AI emails match your voice.

+
+
+
+ +
+ {TONES.map((t) => ( + + ))} +
+
+
+ + setGreeting(e.target.value)} + placeholder="e.g., Hi, Hello, Dear" + className="w-full px-3 py-2.5 bg-white dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+ + setSignoff(e.target.value)} + placeholder="e.g., Best regards, Cheers, Warm wishes" + className="w-full px-3 py-2.5 bg-white dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+
+ )} + + {step === 3 && ( +
+
+
+ +
+

You're All Set!

+

Here's what you can do with NetworkCRM.

+
+
+ {TOUR_FEATURES.map((feature) => ( +
+ {feature.icon} +
+

{feature.title}

+

{feature.desc}

+
+
+ ))} +
+
+ )} +
+ + {/* Footer */} +
+ +
+ {step > 0 && ( + + )} + +
+
+
+
+ ); +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 301400b..3250b7f 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -620,6 +620,55 @@ class ApiClient { }); } + // Tags Management + async getTags(): Promise<{ name: string; count: number }[]> { + return this.fetch('/tags'); + } + + async renameTag(oldName: string, newName: string): Promise<{ success: boolean; updated: number }> { + return this.fetch('/tags/rename', { + method: 'PUT', + body: JSON.stringify({ oldName, newName }), + }); + } + + async deleteTag(name: string): Promise<{ success: boolean; removed: number }> { + return this.fetch(`/tags/${encodeURIComponent(name)}`, { method: 'DELETE' }); + } + + async mergeTags(sourceTags: string[], targetTag: string): Promise<{ success: boolean; updated: number }> { + return this.fetch('/tags/merge', { + method: 'POST', + body: JSON.stringify({ sourceTags, targetTag }), + }); + } + + // Onboarding + async getOnboardingStatus(): Promise<{ onboardingComplete: boolean }> { + return this.fetch('/profile/onboarding-status'); + } + + async completeOnboarding(): Promise<{ success: boolean }> { + return this.fetch('/profile/complete-onboarding', { method: 'POST' }); + } + + // Paginated clients + async getClientsPaginated(params?: { search?: string; tag?: string; page?: number; limit?: number }): Promise<{ + data: Client[]; + total: number; + page: number; + limit: number; + totalPages: number; + }> { + const searchParams = new URLSearchParams(); + if (params?.search) searchParams.set('search', params.search); + if (params?.tag) searchParams.set('tag', params.tag); + 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(`/clients${query ? `?${query}` : ''}`); + } + async exportClientsCSV(): Promise { const token = this.getToken(); const headers: HeadersInit = token ? { Authorization: `Bearer ${token}` } : {}; diff --git a/src/pages/ClientsPage.tsx b/src/pages/ClientsPage.tsx index a44c2ef..2f66ae0 100644 --- a/src/pages/ClientsPage.tsx +++ b/src/pages/ClientsPage.tsx @@ -1,7 +1,7 @@ -import { useEffect, useState, useMemo } from 'react'; -import { Link, useLocation } from 'react-router-dom'; +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 } from 'lucide-react'; +import { Search, Plus, Users, X, Upload, LayoutGrid, List, Kanban, ChevronLeft, ChevronRight } from 'lucide-react'; import { cn, getRelativeTime, getInitials } from '@/lib/utils'; import Badge, { StageBadge } from '@/components/Badge'; import EmptyState from '@/components/EmptyState'; @@ -10,8 +10,11 @@ import Modal from '@/components/Modal'; import ClientForm from '@/components/ClientForm'; import CSVImportModal from '@/components/CSVImportModal'; +const PAGE_SIZES = [25, 50, 100]; + export default function ClientsPage() { const location = useLocation(); + const [searchParams, setSearchParams] = useSearchParams(); const { clients, isLoading, searchQuery, selectedTag, setSearchQuery, setSelectedTag, fetchClients, createClient } = useClientsStore(); const [showCreate, setShowCreate] = useState(false); const [showImport, setShowImport] = useState(false); @@ -20,6 +23,31 @@ export default function ClientsPage() { (localStorage.getItem('clients-view') as 'grid' | 'pipeline') || 'grid' ); + // Pagination state from URL + const currentPage = parseInt(searchParams.get('page') || '1', 10) || 1; + const pageSize = parseInt(searchParams.get('pageSize') || '50', 10) || 50; + + const setPage = useCallback((page: number) => { + const params = new URLSearchParams(searchParams); + params.set('page', String(page)); + setSearchParams(params, { replace: true }); + }, [searchParams, setSearchParams]); + + const setPageSize = useCallback((size: number) => { + const params = new URLSearchParams(searchParams); + params.set('pageSize', String(size)); + params.set('page', '1'); // reset to first page + setSearchParams(params, { replace: true }); + }, [searchParams, setSearchParams]); + + // Initialize selected tag from URL + useEffect(() => { + const urlTag = searchParams.get('tag'); + if (urlTag && urlTag !== selectedTag) { + setSelectedTag(urlTag); + } + }, []); + useEffect(() => { fetchClients(); }, [fetchClients]); @@ -49,6 +77,22 @@ export default function ClientsPage() { return result; }, [clients, searchQuery, selectedTag]); + // Pagination for grid view + const totalClients = filteredClients.length; + const totalPages = Math.ceil(totalClients / pageSize); + const paginatedClients = useMemo(() => { + if (viewMode === 'pipeline') return filteredClients; // pipeline shows all + const start = (currentPage - 1) * pageSize; + return filteredClients.slice(start, start + pageSize); + }, [filteredClients, currentPage, pageSize, viewMode]); + + // Reset page when filters change + useEffect(() => { + if (currentPage > 1) { + setPage(1); + } + }, [searchQuery, selectedTag]); + // Pipeline columns const pipelineStages = ['lead', 'prospect', 'onboarding', 'active', 'inactive'] as const; const pipelineColumns = useMemo(() => { @@ -95,7 +139,9 @@ export default function ClientsPage() {
-

Clients

+

+ Clients{clients.length > 0 && ({clients.length})} +

{clients.length} contacts in your network

@@ -278,7 +324,7 @@ export default function ClientsPage() { ) : ( /* Grid View */
- {filteredClients.map((client) => ( + {paginatedClients.map((client) => ( )} + {/* Pagination Controls (grid view only) */} + {viewMode === 'grid' && totalPages > 1 && ( +
+
+ Show + + per page +
+
+ + Page {currentPage} of {totalPages} + +
+ + +
+
+
+ )} + {/* Create Modal */} setShowCreate(false)} title="Add Client" size="lg"> diff --git a/src/pages/TagsPage.tsx b/src/pages/TagsPage.tsx new file mode 100644 index 0000000..bd0647e --- /dev/null +++ b/src/pages/TagsPage.tsx @@ -0,0 +1,329 @@ +import { useEffect, useState, useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { api } from '@/lib/api'; +import { Tag, Pencil, Trash2, Merge, Plus, Users } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import EmptyState from '@/components/EmptyState'; +import { PageLoader } from '@/components/LoadingSpinner'; +import LoadingSpinner from '@/components/LoadingSpinner'; +import Modal from '@/components/Modal'; + +interface TagInfo { + name: string; + count: number; +} + +// Hash-based color palette +const TAG_COLORS = [ + { bg: 'bg-blue-100 dark:bg-blue-900/40', text: 'text-blue-700 dark:text-blue-300', border: 'border-blue-200 dark:border-blue-800' }, + { bg: 'bg-emerald-100 dark:bg-emerald-900/40', text: 'text-emerald-700 dark:text-emerald-300', border: 'border-emerald-200 dark:border-emerald-800' }, + { bg: 'bg-purple-100 dark:bg-purple-900/40', text: 'text-purple-700 dark:text-purple-300', border: 'border-purple-200 dark:border-purple-800' }, + { bg: 'bg-amber-100 dark:bg-amber-900/40', text: 'text-amber-700 dark:text-amber-300', border: 'border-amber-200 dark:border-amber-800' }, + { bg: 'bg-pink-100 dark:bg-pink-900/40', text: 'text-pink-700 dark:text-pink-300', border: 'border-pink-200 dark:border-pink-800' }, + { bg: 'bg-cyan-100 dark:bg-cyan-900/40', text: 'text-cyan-700 dark:text-cyan-300', border: 'border-cyan-200 dark:border-cyan-800' }, + { bg: 'bg-orange-100 dark:bg-orange-900/40', text: 'text-orange-700 dark:text-orange-300', border: 'border-orange-200 dark:border-orange-800' }, + { bg: 'bg-indigo-100 dark:bg-indigo-900/40', text: 'text-indigo-700 dark:text-indigo-300', border: 'border-indigo-200 dark:border-indigo-800' }, + { bg: 'bg-rose-100 dark:bg-rose-900/40', text: 'text-rose-700 dark:text-rose-300', border: 'border-rose-200 dark:border-rose-800' }, + { bg: 'bg-teal-100 dark:bg-teal-900/40', text: 'text-teal-700 dark:text-teal-300', border: 'border-teal-200 dark:border-teal-800' }, +]; + +function hashColor(str: string) { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + } + return TAG_COLORS[Math.abs(hash) % TAG_COLORS.length]; +} + +export default function TagsPage() { + const navigate = useNavigate(); + const [tags, setTags] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // Rename modal + const [renameTag, setRenameTag] = useState(null); + const [newName, setNewName] = useState(''); + const [renaming, setRenaming] = useState(false); + + // Delete confirm + const [deleteTag, setDeleteTag] = useState(null); + const [deleting, setDeleting] = useState(false); + + // Merge modal + const [showMerge, setShowMerge] = useState(false); + const [mergeSelected, setMergeSelected] = useState>(new Set()); + const [mergeTarget, setMergeTarget] = useState(''); + const [merging, setMerging] = useState(false); + + const fetchTags = async () => { + setIsLoading(true); + try { + const data = await api.getTags(); + setTags(data); + } catch (err: any) { + setError(err.message); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { fetchTags(); }, []); + + const handleRename = async () => { + if (!renameTag || !newName.trim()) return; + setRenaming(true); + try { + await api.renameTag(renameTag.name, newName.trim()); + setRenameTag(null); + setNewName(''); + await fetchTags(); + } catch (err: any) { + setError(err.message); + } finally { + setRenaming(false); + } + }; + + const handleDelete = async () => { + if (!deleteTag) return; + setDeleting(true); + try { + await api.deleteTag(deleteTag.name); + setDeleteTag(null); + await fetchTags(); + } catch (err: any) { + setError(err.message); + } finally { + setDeleting(false); + } + }; + + const handleMerge = async () => { + if (mergeSelected.size < 2 || !mergeTarget.trim()) return; + setMerging(true); + try { + const sourceTags = Array.from(mergeSelected).filter(t => t !== mergeTarget.trim()); + await api.mergeTags(sourceTags, mergeTarget.trim()); + setShowMerge(false); + setMergeSelected(new Set()); + setMergeTarget(''); + await fetchTags(); + } catch (err: any) { + setError(err.message); + } finally { + setMerging(false); + } + }; + + if (isLoading) return ; + + return ( +
+
+
+

Tags

+

+ {tags.length} tag{tags.length !== 1 ? 's' : ''} across your clients +

+
+ {tags.length >= 2 && ( + + )} +
+ + {error && ( +
+ {error} + +
+ )} + + {tags.length === 0 ? ( + + ) : ( +
+ {tags.map((tag) => { + const color = hashColor(tag.name); + return ( +
+
navigate(`/clients?tag=${encodeURIComponent(tag.name)}`)} + > +
+

{tag.name}

+

+ + {tag.count} client{tag.count !== 1 ? 's' : ''} +

+
+
+
+ + +
+
+ ); + })} +
+ )} + + {/* Rename Modal */} + setRenameTag(null)} title="Rename Tag"> +
+
+ + setNewName(e.target.value)} + placeholder="New tag name" + className="w-full px-3 py-2 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500" + autoFocus + onKeyDown={(e) => e.key === 'Enter' && handleRename()} + /> +
+

+ This will rename the tag across all {renameTag?.count} client{renameTag?.count !== 1 ? 's' : ''}. +

+
+ + +
+
+
+ + {/* Delete Confirm Modal */} + setDeleteTag(null)} title="Delete Tag"> +
+

+ Are you sure you want to delete the tag "{deleteTag?.name}"? + It will be removed from {deleteTag?.count} client{deleteTag?.count !== 1 ? 's' : ''}. +

+
+ + +
+
+
+ + {/* Merge Modal */} + { setShowMerge(false); setMergeSelected(new Set()); setMergeTarget(''); }} title="Merge Tags" size="lg"> +
+

+ Select tags to merge, then choose or type the target tag name. +

+
+ {tags.map((tag) => { + const selected = mergeSelected.has(tag.name); + const color = hashColor(tag.name); + return ( + + ); + })} +
+ {mergeSelected.size >= 2 && ( +
+ + setMergeTarget(e.target.value)} + placeholder="Target tag name" + className="w-full px-3 py-2 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ )} +
+ + +
+
+
+
+ ); +}