← back to Norma
components/connections/OrgConnections.tsx
333 lines
'use client';
import { useState, useEffect } from 'react';
import { Network, ArrowRight, Zap, Target, Users, Award, Search } from 'lucide-react';
import { useOrg, type OrgProfile } from '@/components/OrgProvider';
/* ─── Types ──────────────────────────────────────────────────────────────── */
interface Connection {
type: 'keyword' | 'focus' | 'location' | 'grant';
label: string;
strength: 'strong' | 'medium' | 'weak';
}
/* ─── Helpers ────────────────────────────────────────────────────────────── */
function findConnections(a: OrgProfile, b: OrgProfile): Connection[] {
const conns: Connection[] = [];
// Shared keywords
const aKw = new Set((a.search_keywords || []).map((k) => k.toLowerCase()));
const bKw = new Set((b.search_keywords || []).map((k) => k.toLowerCase()));
for (const kw of aKw) {
if (bKw.has(kw)) {
conns.push({ type: 'keyword', label: kw, strength: 'strong' });
}
}
// Shared focus areas
const aFocus = new Set((a.focus_areas || []).map((f) => f.toLowerCase()));
const bFocus = new Set((b.focus_areas || []).map((f) => f.toLowerCase()));
for (const fa of aFocus) {
if (bFocus.has(fa)) {
conns.push({ type: 'focus', label: fa, strength: 'strong' });
}
}
// Partial keyword overlaps (one org's keyword found in other's focus area)
for (const kw of aKw) {
for (const fa of bFocus) {
if (fa.includes(kw) || kw.includes(fa)) {
if (!conns.some((c) => c.label === kw || c.label === fa)) {
conns.push({ type: 'keyword', label: `${kw} ~ ${fa}`, strength: 'medium' });
}
}
}
}
for (const kw of bKw) {
for (const fa of aFocus) {
if (fa.includes(kw) || kw.includes(fa)) {
if (!conns.some((c) => c.label === kw || c.label === fa)) {
conns.push({ type: 'keyword', label: `${kw} ~ ${fa}`, strength: 'medium' });
}
}
}
}
// Same state
if (a.address_state && b.address_state && a.address_state === b.address_state) {
conns.push({ type: 'location', label: `Both in ${a.address_state}`, strength: 'medium' });
}
// Same city
if (a.address_city && b.address_city && a.address_city.toLowerCase() === b.address_city.toLowerCase()) {
conns.push({ type: 'location', label: `Both in ${a.address_city}, ${a.address_state}`, strength: 'strong' });
}
return conns;
}
const STRENGTH_COLORS: Record<string, string> = {
strong: '#10b981',
medium: '#f59e0b',
weak: '#6b7280',
};
const TYPE_ICONS: Record<string, typeof Network> = {
keyword: Search,
focus: Target,
location: Users,
grant: Award,
};
/* ─── Component ──────────────────────────────────────────────────────────── */
export default function OrgConnections() {
const { orgs } = useOrg();
const [orgA, setOrgA] = useState<string>('');
const [orgB, setOrgB] = useState<string>('');
const [connections, setConnections] = useState<Connection[]>([]);
useEffect(() => {
if (!orgA || !orgB || orgA === orgB) {
setConnections([]);
return;
}
const a = orgs.find((o) => o.id === orgA);
const b = orgs.find((o) => o.id === orgB);
if (a && b) {
setConnections(findConnections(a, b));
}
}, [orgA, orgB, orgs]);
const selectedA = orgs.find((o) => o.id === orgA);
const selectedB = orgs.find((o) => o.id === orgB);
return (
<div className="p-4 sm:p-6 max-w-4xl mx-auto">
<div className="mb-6">
<h1 className="text-lg font-semibold" style={{ color: 'var(--color-text)' }}>
Org Connections
</h1>
<p className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
Select two organizations to discover shared keywords, focus areas, and collaboration opportunities.
</p>
</div>
{/* ── Org Selectors ──────────────────────────────────────────────── */}
<div className="flex items-center gap-4 mb-6 flex-wrap">
{/* Org A */}
<div className="flex-1 min-w-[200px]">
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>
Organization A
</label>
<select
className="input w-full"
value={orgA}
onChange={(e) => setOrgA(e.target.value)}
>
<option value="">Select org...</option>
{orgs.filter((o) => o.id !== orgB).map((o) => (
<option key={o.id} value={o.id}>{o.org_name}</option>
))}
</select>
</div>
<div className="flex items-center pt-5">
<Network size={20} style={{ color: 'var(--color-text-muted)' }} />
</div>
{/* Org B */}
<div className="flex-1 min-w-[200px]">
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>
Organization B
</label>
<select
className="input w-full"
value={orgB}
onChange={(e) => setOrgB(e.target.value)}
>
<option value="">Select org...</option>
{orgs.filter((o) => o.id !== orgA).map((o) => (
<option key={o.id} value={o.id}>{o.org_name}</option>
))}
</select>
</div>
</div>
{/* ── Org Cards ──────────────────────────────────────────────────── */}
{selectedA && selectedB && (
<div className="grid grid-cols-2 gap-4 mb-6">
{[selectedA, selectedB].map((o) => (
<div
key={o.id}
className="card p-4"
style={{ borderTop: `3px solid ${o.brand_color}` }}
>
<div className="flex items-center gap-3 mb-3">
<div
style={{
width: 32,
height: 32,
borderRadius: 8,
background: `linear-gradient(135deg, ${o.brand_color}, ${o.brand_color}cc)`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<span style={{ color: '#fff', fontSize: 12, fontWeight: 800 }}>
{(o.short_name || o.org_name.slice(0, 2)).slice(0, 4)}
</span>
</div>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text)' }}>
{o.org_name}
</div>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>
{o.address_city}{o.address_city && o.address_state ? ', ' : ''}{o.address_state}
</div>
</div>
</div>
{/* Focus areas */}
{(o.focus_areas || []).length > 0 && (
<div className="mb-2">
<span className="text-xs font-medium" style={{ color: 'var(--color-text-secondary)' }}>Focus: </span>
<span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
{o.focus_areas.join(', ')}
</span>
</div>
)}
{/* Keywords */}
{(o.search_keywords || []).length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{o.search_keywords.slice(0, 8).map((kw) => (
<span
key={kw}
className="text-xs px-2 py-0.5 rounded-full"
style={{
backgroundColor: `${o.brand_color}20`,
color: o.brand_color,
fontSize: 10,
}}
>
{kw}
</span>
))}
{o.search_keywords.length > 8 && (
<span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
+{o.search_keywords.length - 8} more
</span>
)}
</div>
)}
</div>
))}
</div>
)}
{/* ── Connections ─────────────────────────────────────────────────── */}
{selectedA && selectedB && (
<div className="card p-5">
<div className="flex items-center gap-2 mb-4">
<Zap size={16} style={{ color: '#f59e0b' }} />
<h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
Connections Found
</h3>
<span
className="badge"
style={{
backgroundColor: connections.length > 0 ? 'rgba(16, 185, 129, 0.15)' : 'rgba(107, 114, 128, 0.15)',
color: connections.length > 0 ? '#10b981' : '#6b7280',
fontSize: '0.6875rem',
padding: '1px 8px',
}}
>
{connections.length}
</span>
</div>
{connections.length === 0 ? (
<div className="text-center py-8">
<Network size={32} style={{ color: 'var(--color-text-muted)', margin: '0 auto 8px' }} />
<p className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
No direct connections found between these organizations.
</p>
<p className="text-xs mt-1" style={{ color: 'var(--color-text-muted)' }}>
Add more keywords in Settings to discover potential overlaps.
</p>
</div>
) : (
<div className="space-y-2">
{connections.map((conn, i) => {
const Icon = TYPE_ICONS[conn.type] || Network;
return (
<div
key={i}
className="flex items-center gap-3 p-3 rounded-lg"
style={{
backgroundColor: 'var(--color-surface-el)',
border: '1px solid var(--color-border)',
}}
>
<Icon size={14} style={{ color: STRENGTH_COLORS[conn.strength], flexShrink: 0 }} />
<div className="flex-1 min-w-0">
<span className="text-sm" style={{ color: 'var(--color-text)' }}>
{conn.label}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<span
className="text-xs px-2 py-0.5 rounded-full capitalize"
style={{
backgroundColor: `${STRENGTH_COLORS[conn.strength]}20`,
color: STRENGTH_COLORS[conn.strength],
}}
>
{conn.strength}
</span>
<span
className="text-xs px-2 py-0.5 rounded-full capitalize"
style={{
backgroundColor: 'rgba(99, 102, 241, 0.15)',
color: '#818cf8',
}}
>
{conn.type}
</span>
</div>
</div>
);
})}
</div>
)}
{/* Summary */}
{connections.length > 0 && (
<div
className="mt-4 p-3 rounded-lg"
style={{
backgroundColor: 'rgba(16, 185, 129, 0.08)',
border: '1px solid rgba(16, 185, 129, 0.2)',
}}
>
<p className="text-sm font-medium" style={{ color: '#10b981' }}>
Collaboration Potential
</p>
<p className="text-xs mt-1" style={{ color: 'var(--color-text-secondary)' }}>
{selectedA.org_name} and {selectedB.org_name} share{' '}
{connections.filter((c) => c.strength === 'strong').length} strong connections
{connections.filter((c) => c.type === 'focus').length > 0
? `, including ${connections.filter((c) => c.type === 'focus').length} shared focus areas`
: ''}
. Consider joint petitions, shared grant applications, or co-branded advocacy campaigns.
</p>
</div>
)}
</div>
)}
</div>
);
}