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:
313
src/pages/NetworkPage.tsx
Normal file
313
src/pages/NetworkPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user