feat: Network Matching page - AI-powered client connection suggestions

- New /network page with match cards, score badges, category filters
- Network stats dashboard (clients, matches, avg score, top connectors)
- Category-based filtering (industry, interests, location, business, social)
- Adjustable minimum score threshold
- AI introduction generation button per match
- Added Network to sidebar navigation
- Types: NetworkMatch, NetworkStats
This commit is contained in:
2026-01-29 12:35:33 +00:00
parent b6de50ba5e
commit 0b7bddb81c
5 changed files with 360 additions and 0 deletions

View File

@@ -10,6 +10,7 @@ import EventsPage from '@/pages/EventsPage';
import EmailsPage from '@/pages/EmailsPage'; import EmailsPage from '@/pages/EmailsPage';
import SettingsPage from '@/pages/SettingsPage'; import SettingsPage from '@/pages/SettingsPage';
import AdminPage from '@/pages/AdminPage'; import AdminPage from '@/pages/AdminPage';
import NetworkPage from '@/pages/NetworkPage';
import InvitePage from '@/pages/InvitePage'; import InvitePage from '@/pages/InvitePage';
import ForgotPasswordPage from '@/pages/ForgotPasswordPage'; import ForgotPasswordPage from '@/pages/ForgotPasswordPage';
import ResetPasswordPage from '@/pages/ResetPasswordPage'; import ResetPasswordPage from '@/pages/ResetPasswordPage';
@@ -48,6 +49,7 @@ export default function App() {
<Route path="clients/:id" element={<ClientDetailPage />} /> <Route path="clients/:id" element={<ClientDetailPage />} />
<Route path="events" element={<EventsPage />} /> <Route path="events" element={<EventsPage />} />
<Route path="emails" element={<EmailsPage />} /> <Route path="emails" element={<EmailsPage />} />
<Route path="network" element={<NetworkPage />} />
<Route path="settings" element={<SettingsPage />} /> <Route path="settings" element={<SettingsPage />} />
<Route path="admin" element={<AdminPage />} /> <Route path="admin" element={<AdminPage />} />
</Route> </Route>

View File

@@ -12,6 +12,7 @@ const baseNavItems = [
{ path: '/clients', label: 'Clients', icon: Users }, { path: '/clients', label: 'Clients', icon: Users },
{ path: '/events', label: 'Events', icon: Calendar }, { path: '/events', label: 'Events', icon: Calendar },
{ path: '/emails', label: 'Emails', icon: Mail }, { path: '/emails', label: 'Emails', icon: Mail },
{ path: '/network', label: 'Network', icon: Network },
{ path: '/settings', label: 'Settings', icon: Settings }, { path: '/settings', label: 'Settings', icon: Settings },
]; ];

View File

@@ -239,6 +239,31 @@ class ApiClient {
await this.fetch(`/emails/${id}`, { method: 'DELETE' }); await this.fetch(`/emails/${id}`, { method: 'DELETE' });
} }
// Network Matching
async getNetworkMatches(params?: { minScore?: number; limit?: number }): Promise<{ matches: NetworkMatch[]; total: number; clientCount: number }> {
const searchParams = new URLSearchParams();
if (params?.minScore) searchParams.set('minScore', String(params.minScore));
if (params?.limit) searchParams.set('limit', String(params.limit));
const query = searchParams.toString();
return this.fetch(`/network/matches${query ? `?${query}` : ''}`);
}
async getClientMatches(clientId: string, minScore?: number): Promise<{ matches: NetworkMatch[]; client: { id: string; name: string } }> {
const query = minScore ? `?minScore=${minScore}` : '';
return this.fetch(`/network/matches/${clientId}${query}`);
}
async generateIntro(clientAId: string, clientBId: string, reasons: string[], provider?: string): Promise<{ introSuggestion: string }> {
return this.fetch('/network/intro', {
method: 'POST',
body: JSON.stringify({ clientAId, clientBId, reasons, provider }),
});
}
async getNetworkStats(): Promise<NetworkStats> {
return this.fetch('/network/stats');
}
// Admin // Admin
async getUsers(): Promise<User[]> { async getUsers(): Promise<User[]> {
return this.fetch('/admin/users'); return this.fetch('/admin/users');

313
src/pages/NetworkPage.tsx Normal file
View File

@@ -0,0 +1,313 @@
import { useEffect, useState, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { api } from '@/lib/api';
import type { NetworkMatch, NetworkStats } from '@/types';
import {
Network, Users, Sparkles, ArrowRight, TrendingUp,
MapPin, Briefcase, Heart, Building2, Star, Loader2, Filter,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { PageLoader } from '@/components/LoadingSpinner';
const categoryConfig: Record<string, { label: string; icon: typeof Network; color: string; bg: string }> = {
industry: { label: 'Industry', icon: Building2, color: 'text-blue-600', bg: 'bg-blue-50' },
interests: { label: 'Interests', icon: Heart, color: 'text-pink-600', bg: 'bg-pink-50' },
location: { label: 'Location', icon: MapPin, color: 'text-emerald-600', bg: 'bg-emerald-50' },
business: { label: 'Business', icon: Briefcase, color: 'text-purple-600', bg: 'bg-purple-50' },
social: { label: 'Social', icon: Users, color: 'text-amber-600', bg: 'bg-amber-50' },
};
function ScoreBadge({ score }: { score: number }) {
const color = score >= 60 ? 'bg-emerald-100 text-emerald-700' :
score >= 40 ? 'bg-blue-100 text-blue-700' :
score >= 25 ? 'bg-amber-100 text-amber-700' :
'bg-slate-100 text-slate-600';
return (
<span className={cn('px-2 py-0.5 rounded-full text-xs font-bold', color)}>
{score}%
</span>
);
}
function MatchCard({ match, onGenerateIntro, generatingIntro }: {
match: NetworkMatch;
onGenerateIntro: (match: NetworkMatch) => void;
generatingIntro: boolean;
}) {
const config = categoryConfig[match.category] || categoryConfig.social;
const Icon = config.icon;
return (
<div className="bg-white border border-slate-200 rounded-xl p-5 hover:shadow-md transition-shadow">
{/* Header */}
<div className="flex items-start justify-between mb-3">
<div className={cn('flex items-center gap-2 px-2.5 py-1 rounded-full text-xs font-medium', config.bg, config.color)}>
<Icon className="w-3.5 h-3.5" />
{config.label}
</div>
<ScoreBadge score={match.score} />
</div>
{/* People */}
<div className="flex items-center gap-3 mb-3">
<Link
to={`/clients/${match.clientA.id}`}
className="flex-1 flex items-center gap-2 p-2 rounded-lg hover:bg-slate-50 transition-colors min-w-0"
>
<div className="w-9 h-9 bg-blue-100 text-blue-700 rounded-full flex items-center justify-center text-sm font-semibold flex-shrink-0">
{match.clientA.name.split(' ').map(n => n[0]).join('')}
</div>
<span className="text-sm font-medium text-slate-900 truncate">{match.clientA.name}</span>
</Link>
<div className="flex-shrink-0">
<Network className="w-4 h-4 text-slate-300" />
</div>
<Link
to={`/clients/${match.clientB.id}`}
className="flex-1 flex items-center gap-2 p-2 rounded-lg hover:bg-slate-50 transition-colors min-w-0"
>
<div className="w-9 h-9 bg-purple-100 text-purple-700 rounded-full flex items-center justify-center text-sm font-semibold flex-shrink-0">
{match.clientB.name.split(' ').map(n => n[0]).join('')}
</div>
<span className="text-sm font-medium text-slate-900 truncate">{match.clientB.name}</span>
</Link>
</div>
{/* Reasons */}
<div className="space-y-1.5 mb-3">
{match.reasons.map((reason, i) => (
<div key={i} className="flex items-start gap-2 text-sm text-slate-600">
<span className="text-emerald-500 mt-0.5 flex-shrink-0"></span>
{reason}
</div>
))}
</div>
{/* Intro suggestion */}
<div className="bg-slate-50 rounded-lg p-3 mb-3">
<p className="text-sm text-slate-600 italic">{match.introSuggestion}</p>
</div>
{/* Actions */}
<div className="flex gap-2">
<button
onClick={() => onGenerateIntro(match)}
disabled={generatingIntro}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-50 text-blue-700 hover:bg-blue-100 rounded-lg transition-colors disabled:opacity-50"
>
{generatingIntro ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Sparkles className="w-3.5 h-3.5" />
)}
AI Introduction
</button>
</div>
</div>
);
}
export default function NetworkPage() {
const [matches, setMatches] = useState<NetworkMatch[]>([]);
const [stats, setStats] = useState<NetworkStats | null>(null);
const [loading, setLoading] = useState(true);
const [generatingId, setGeneratingId] = useState<string | null>(null);
const [categoryFilter, setCategoryFilter] = useState<string | null>(null);
const [minScore, setMinScore] = useState(20);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const [matchData, statsData] = await Promise.all([
api.getNetworkMatches({ minScore, limit: 100 }),
api.getNetworkStats(),
]);
setMatches(matchData.matches);
setStats(statsData);
} catch (e) {
console.error('Failed to load network data:', e);
} finally {
setLoading(false);
}
}, [minScore]);
useEffect(() => {
fetchData();
}, [fetchData]);
const handleGenerateIntro = async (match: NetworkMatch) => {
const id = `${match.clientA.id}-${match.clientB.id}`;
setGeneratingId(id);
try {
const result = await api.generateIntro(match.clientA.id, match.clientB.id, match.reasons);
setMatches(prev => prev.map(m =>
m.clientA.id === match.clientA.id && m.clientB.id === match.clientB.id
? { ...m, introSuggestion: result.introSuggestion }
: m
));
} catch (e) {
console.error('Failed to generate intro:', e);
} finally {
setGeneratingId(null);
}
};
const filtered = categoryFilter
? matches.filter(m => m.category === categoryFilter)
: matches;
if (loading) return <PageLoader />;
const categories = ['industry', 'interests', 'location', 'business', 'social'];
return (
<div className="max-w-6xl mx-auto space-y-6 animate-fade-in">
{/* Header */}
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h1 className="text-2xl font-bold text-slate-900">Network Matching</h1>
<p className="text-slate-500 text-sm mt-1">
AI-powered suggestions for connecting your clients
</p>
</div>
</div>
{/* Stats */}
{stats && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="bg-white border border-slate-200 rounded-xl p-4">
<div className="flex items-center gap-2 mb-1">
<Users className="w-4 h-4 text-blue-500" />
<span className="text-sm text-slate-500">Clients</span>
</div>
<p className="text-2xl font-bold text-slate-900">{stats.totalClients}</p>
</div>
<div className="bg-white border border-slate-200 rounded-xl p-4">
<div className="flex items-center gap-2 mb-1">
<Network className="w-4 h-4 text-purple-500" />
<span className="text-sm text-slate-500">Matches Found</span>
</div>
<p className="text-2xl font-bold text-slate-900">{stats.totalMatches}</p>
</div>
<div className="bg-white border border-slate-200 rounded-xl p-4">
<div className="flex items-center gap-2 mb-1">
<TrendingUp className="w-4 h-4 text-emerald-500" />
<span className="text-sm text-slate-500">Avg Score</span>
</div>
<p className="text-2xl font-bold text-slate-900">{stats.avgScore}%</p>
</div>
<div className="bg-white border border-slate-200 rounded-xl p-4">
<div className="flex items-center gap-2 mb-1">
<Star className="w-4 h-4 text-amber-500" />
<span className="text-sm text-slate-500">Top Connector</span>
</div>
<p className="text-lg font-bold text-slate-900 truncate">
{stats.topConnectors[0]?.name || '—'}
</p>
</div>
</div>
)}
{/* Top Connectors */}
{stats && stats.topConnectors.length > 0 && (
<div className="bg-white border border-slate-200 rounded-xl p-5">
<h3 className="font-semibold text-slate-900 mb-3">Most Connected Clients</h3>
<div className="flex flex-wrap gap-3">
{stats.topConnectors.map((c) => (
<Link
key={c.id}
to={`/clients/${c.id}`}
className="flex items-center gap-2 px-3 py-2 bg-slate-50 hover:bg-slate-100 rounded-lg transition-colors"
>
<div className="w-7 h-7 bg-blue-100 text-blue-700 rounded-full flex items-center justify-center text-xs font-semibold">
{c.name.split(' ').map(n => n[0]).join('')}
</div>
<span className="text-sm font-medium text-slate-700">{c.name}</span>
<span className="text-xs text-slate-400">{c.matchCount} matches</span>
</Link>
))}
</div>
</div>
)}
{/* Filters */}
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-1.5 text-sm text-slate-500">
<Filter className="w-4 h-4" />
Filter:
</div>
<button
onClick={() => setCategoryFilter(null)}
className={cn(
'px-3 py-1.5 rounded-full text-sm font-medium transition-colors',
!categoryFilter ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
)}
>
All ({matches.length})
</button>
{categories.map(cat => {
const config = categoryConfig[cat]!;
const count = matches.filter(m => m.category === cat).length;
if (count === 0) return null;
return (
<button
key={cat}
onClick={() => setCategoryFilter(categoryFilter === cat ? null : cat)}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium transition-colors',
categoryFilter === cat ? cn(config.bg, config.color, 'ring-2 ring-offset-1', `ring-current`) : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
)}
>
{config.label} ({count})
</button>
);
})}
<div className="ml-auto flex items-center gap-2">
<label className="text-xs text-slate-500">Min score:</label>
<select
value={minScore}
onChange={(e) => setMinScore(parseInt(e.target.value))}
className="text-sm border border-slate-200 rounded-lg px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-200"
>
<option value={10}>10%</option>
<option value={20}>20%</option>
<option value={30}>30%</option>
<option value={50}>50%</option>
</select>
</div>
</div>
{/* Match Cards */}
{filtered.length === 0 ? (
<div className="bg-white border border-slate-200 rounded-xl p-12 text-center">
<Network className="w-12 h-12 text-slate-300 mx-auto mb-3" />
<h3 className="font-semibold text-slate-900 mb-1">No matches found</h3>
<p className="text-sm text-slate-500 mb-4">
{matches.length === 0
? 'Add more clients with detailed profiles (interests, industry, location) to find connections.'
: 'Try adjusting the filter or minimum score.'}
</p>
{matches.length === 0 && (
<Link
to="/clients"
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors"
>
Add Clients <ArrowRight className="w-4 h-4" />
</Link>
)}
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{filtered.map((match) => (
<MatchCard
key={`${match.clientA.id}-${match.clientB.id}`}
match={match}
onGenerateIntro={handleGenerateIntro}
generatingIntro={generatingId === `${match.clientA.id}-${match.clientB.id}`}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -112,6 +112,25 @@ export interface EmailGenerate {
provider?: 'anthropic' | 'openai'; provider?: 'anthropic' | 'openai';
} }
export interface NetworkMatch {
clientA: { id: string; name: string };
clientB: { id: string; name: string };
score: number;
reasons: string[];
introSuggestion: string;
category: 'industry' | 'interests' | 'location' | 'business' | 'social';
}
export interface NetworkStats {
totalClients: number;
totalMatches: number;
avgScore: number;
industries: [string, number][];
locations: [string, number][];
categories: Record<string, number>;
topConnectors: { id: string; name: string; matchCount: number }[];
}
export interface Invite { export interface Invite {
id: string; id: string;
email: string; email: string;