feat(CC-086): add Communities page (observations with community subjectType + engagement levels)

This commit is contained in:
admin-valentin 2026-08-02 17:38:04 +00:00
parent a0f60e9495
commit ad43e8d831

View file

@ -0,0 +1,139 @@
'use client';
import { useMemo, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiFetch } from '../../../lib/api';
import { useSession } from '../../../components/session-provider';
interface Contact { id: string; firstName: string; lastName: string | null; tags: string[]; organization: string | null; createdAt: string; }
interface Observation { id: string; metric: string; value: string; source: string | null; subjectType: string; createdAt: string; }
const COMMUNITY_TAGS = ['community', 'comunitate', 'group', 'grup', 'association', 'asociatie', 'club', 'network', 'forum', 'slack', 'discord'];
const ENGAGEMENT_LEVELS = [
{ level: 'core', label: 'Core member', icon: '⭐', desc: 'Contributor activ' },
{ level: 'active', label: 'Activ', icon: '🟢', desc: 'Participare regulată' },
{ level: 'observer', label: 'Observer', icon: '👀', desc: 'Urmăresc activitatea' },
];
export default function CommunitiesPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const qc = useQueryClient();
const [showAdd, setShowAdd] = useState(false);
const [form, setForm] = useState({ name: '', url: '', type: 'community', level: 'active', since: '' });
const { data: observations = [] } = useQuery({ queryKey: ['comm-obs', tenantId], queryFn: () => apiFetch<Observation[]>('/v1/observations', { tenantId }), enabled: Boolean(tenantId), staleTime: 60_000 });
const { data: contacts = [] } = useQuery({ queryKey: ['comm-contacts', tenantId], queryFn: () => apiFetch<Contact[]>('/v1/contacts?limit=500', { tenantId }), enabled: Boolean(tenantId), staleTime: 120_000 });
const communities = useMemo(() =>
observations.filter((o) =>
o.subjectType === 'community' ||
COMMUNITY_TAGS.some((k) => o.metric.toLowerCase().includes(k))
).sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()),
[observations]);
const communityContacts = useMemo(() =>
contacts.filter((c) => c.tags.some((t) => COMMUNITY_TAGS.includes(t.toLowerCase()))),
[contacts]);
const addMut = useMutation({
mutationFn: () => apiFetch('/v1/observations', { tenantId, method: 'POST', body: {
metric: form.type, value: form.name,
source: form.url || undefined, unit: form.level,
subjectType: 'community',
observedAt: form.since ? new Date(form.since).toISOString() : new Date().toISOString(),
confidence: form.level === 'core' ? 1 : form.level === 'active' ? 0.8 : 0.5,
}}),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['comm-obs', tenantId] }); setShowAdd(false); setForm({ name: '', url: '', type: 'community', level: 'active', since: '' }); },
});
return (
<div className="max-w-4xl space-y-6 p-6">
<div className="flex items-start justify-between flex-wrap gap-3">
<div>
<h1 className="font-display text-2xl font-semibold text-ink">Comunități & Rețele</h1>
<p className="text-sm text-ink-faint mt-1">
{communities.length} comunități · {communityContacts.length} contacte din comunități
</p>
</div>
<button onClick={() => setShowAdd(true)}
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90">
+ Adaugă comunitate
</button>
</div>
{showAdd && (
<div className="card p-5 space-y-3">
<p className="text-sm font-semibold text-ink">Comunitate nouă</p>
<div className="grid gap-3 sm:grid-cols-2">
<input placeholder="Nume comunitate (ex: AI Founders Slack)" value={form.name}
onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))}
className="rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
<input placeholder="URL / Link" value={form.url}
onChange={(e) => setForm((p) => ({ ...p, url: e.target.value }))}
className="rounded-lg border bg-background px-3 py-2 text-sm text-ink font-mono text-xs focus:outline-none focus:ring-2 focus:ring-ring" />
<select value={form.type} onChange={(e) => setForm((p) => ({ ...p, type: e.target.value }))}
className="rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring">
<option value="community">Comunitate</option>
<option value="slack">Slack workspace</option>
<option value="discord">Discord server</option>
<option value="forum">Forum</option>
<option value="asociatie">Asociație / ONG</option>
<option value="club">Club profesional</option>
</select>
<select value={form.level} onChange={(e) => setForm((p) => ({ ...p, level: e.target.value }))}
className="rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring">
{ENGAGEMENT_LEVELS.map((l) => <option key={l.level} value={l.level}>{l.icon} {l.label} {l.desc}</option>)}
</select>
<div className="space-y-1">
<label className="text-[10px] text-ink-faint">Membru din</label>
<input type="date" value={form.since} onChange={(e) => setForm((p) => ({ ...p, since: e.target.value }))}
className="w-full rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
</div>
</div>
<div className="flex gap-2">
<button onClick={() => addMut.mutate()} disabled={!form.name || addMut.isPending}
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white disabled:opacity-50">
{addMut.isPending ? 'Se salvează…' : 'Salvează'}
</button>
<button onClick={() => setShowAdd(false)} className="rounded-lg border px-4 py-2 text-sm text-ink">Anulează</button>
</div>
</div>
)}
{communities.length === 0 ? (
<div className="card p-8 text-center space-y-2">
<p className="text-2xl">🌐</p>
<p className="text-sm text-ink-faint">Nicio comunitate. Adaugă Slack-uri, Discord-uri, asociații profesionale.</p>
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{communities.map((c) => {
const engLevel = ENGAGEMENT_LEVELS.find((l) => l.level === c.unit) ?? ENGAGEMENT_LEVELS[1];
return (
<div key={c.id} className="card p-4 space-y-2">
<div className="flex items-start justify-between gap-2">
<div>
<p className="text-sm font-semibold text-ink">{c.value}</p>
<p className="text-[10px] text-ink-faint capitalize">{c.metric}</p>
</div>
<span className="text-sm shrink-0">{engLevel.icon}</span>
</div>
<div className="flex items-center justify-between text-[10px] text-ink-faint">
<span className={`rounded-full px-2 py-0.5 ${c.unit === 'core' ? 'bg-primary/10 text-primary' : 'bg-muted text-ink-faint'}`}>
{engLevel.label}
</span>
{c.source && (
<a href={c.source.startsWith('http') ? c.source : undefined}
target="_blank" rel="noopener noreferrer"
className="text-primary hover:underline">Link </a>
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
}