'use client'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import Link from 'next/link'; import { GEO_ENGINES, ORGANICO_SECTIONS, GeoSnapshot, IntegrationLite, OrganicDailyRow, OrganicoPillar, OrganicoSection, OrganicoSectionId, OrganicoWorkspace, SeoAuditRow, formatNum, formatPct, groupsForPillar, sumOrganic, DEFAULT_EXPERT_PRIORITIES, } from '@/lib/organic'; import { isoYesterday } from '@/lib/ad-tracker-range'; import { AlertTriangle, ArrowRight, Bot, Check, ExternalLink, Leaf, Loader2, Plus, Search, ShieldAlert, Sparkles, } from 'lucide-react'; import { PageHeader, SegmentedControl } from '@/components/ui'; import SyncNowButton from '@/components/SyncNowButton'; type Props = { label: string; portfolioWide: boolean; isAgency: boolean; tenantId: string | null; }; function connBadge(status?: string) { if (status === 'connected') return 'bg-emerald-50 text-emerald-800 border-emerald-200'; if (status === 'error') return 'bg-red-50 text-red-800 border-red-200'; if (status === 'pending') return 'bg-amber-50 text-amber-900 border-amber-200'; return 'bg-gray-50 text-gray-600 border-gray-200'; } function Kpi({ label, value, hint, accent, }: { label: string; value: string; hint?: string; accent?: boolean; }) { return (

{label}

{value}

{hint ?

{hint}

: null}
); } export default function OrganicoDashboard({ label, portfolioWide, isAgency, tenantId, }: Props) { const [pillar, setPillar] = useState('seo'); const [sectionId, setSectionId] = useState('seo-overview'); const [loading, setLoading] = useState(true); const [warning, setWarning] = useState(null); const [organic, setOrganic] = useState([]); const [geo, setGeo] = useState([]); const [integrations, setIntegrations] = useState([]); const [workspace, setWorkspace] = useState({}); const [gscQueries, setGscQueries] = useState< { key: string; clicks: number; impressions: number; ctr: number; position: number }[] >([]); const [gscPages, setGscPages] = useState< { key: string; clicks: number; impressions: number; ctr: number; position: number }[] >([]); const [ga4Landings, setGa4Landings] = useState< { page: string; sessions: number; users: number }[] >([]); const [seoAudits, setSeoAudits] = useState([]); const [selectedAuditId, setSelectedAuditId] = useState(null); const [auditing, setAuditing] = useState(false); const [sectionNote, setSectionNote] = useState(''); const [prioritiesText, setPrioritiesText] = useState(''); const [saving, setSaving] = useState(false); const [msg, setMsg] = useState(null); const [showCapture, setShowCapture] = useState(false); const [geoForm, setGeoForm] = useState({ label: 'Medición GEO', share_of_model: '', chatgpt: '', gemini: '', ai_overviews: '', official_citation_pct: '', sentiment_score: '', prompts_analyzed: '', responses_analyzed: '', study_url: '', notes: '', competitors: 'Competidor A, 40\nCompetidor B, 25\nTu marca, 12', categories: 'Informativa, 15\nCompra, 10\nComparativa, 8', }); const [seoForm, setSeoForm] = useState({ day: isoYesterday(), gsc_clicks: '', gsc_impressions: '', gsc_avg_position: '', ga4_organic_sessions: '', ga4_sessions: '', ga4_users: '', ga4_conversions: '', }); const load = useCallback(async () => { setLoading(true); const res = await fetch('/api/organico'); const data = await res.json(); setOrganic(data.organic || []); setGeo(data.geo || []); setIntegrations(data.integrations || []); setWorkspace(data.workspace || {}); setGscQueries(data.gscQueries || []); setGscPages(data.gscPages || []); setGa4Landings(data.ga4Landings || []); setSeoAudits(data.seoAudits || []); if (data.seoAudits?.[0]?.id) { setSelectedAuditId((prev) => prev || data.seoAudits[0].id); } setWarning(data.warning || null); if (data.autoSynced?.daysWritten) { setMsg( `Datos GA4/GSC actualizados automáticamente (${data.autoSynced.daysWritten} días).` ); } setLoading(false); }, []); useEffect(() => { void load(); }, [load]); useEffect(() => { const first = ORGANICO_SECTIONS.find((s) => s.pillar === pillar); if (first) setSectionId(first.id); }, [pillar]); useEffect(() => { setSectionNote(workspace.sectionNotes?.[sectionId] || ''); const list = pillar === 'geo' ? workspace.priorities?.geo?.length ? workspace.priorities.geo : DEFAULT_EXPERT_PRIORITIES.geo : workspace.priorities?.seo?.length ? workspace.priorities.seo : DEFAULT_EXPERT_PRIORITIES.seo; setPrioritiesText(list.join('\n')); }, [sectionId, pillar, workspace]); const displayPriorities = useMemo(() => { const saved = pillar === 'geo' ? workspace.priorities?.geo : workspace.priorities?.seo; if (saved?.length) return saved; return pillar === 'geo' ? DEFAULT_EXPERT_PRIORITIES.geo : DEFAULT_EXPERT_PRIORITIES.seo; }, [pillar, workspace.priorities]); const seoDaily = useMemo(() => { return [...organic] .filter((r) => r.day) .sort((a, b) => String(a.day).localeCompare(String(b.day))) .slice(-28) .map((r) => ({ day: String(r.day).slice(0, 10), clicks: Number(r.gsc_clicks) || 0, sessions: Number(r.ga4_organic_sessions) || 0, })); }, [organic]); const maxClicks = Math.max(1, ...seoDaily.map((d) => d.clicks)); const maxSessions = Math.max(1, ...seoDaily.map((d) => d.sessions)); const section = useMemo( () => ORGANICO_SECTIONS.find((s) => s.id === sectionId)!, [sectionId] ); const navGroups = useMemo(() => groupsForPillar(pillar), [pillar]); const totals = useMemo(() => sumOrganic(organic), [organic]); const latestGeo = geo[0] || null; const byProvider = useMemo(() => { const m = new Map(); for (const i of integrations) m.set(i.provider, i); return m; }, [integrations]); const ga4 = byProvider.get('ga4'); const gsc = byProvider.get('search_console'); function parsePairs(raw: string): { name: string; value: number }[] { return raw .split('\n') .map((l) => l.trim()) .filter(Boolean) .map((l) => { const [name, num] = l.split(',').map((s) => s.trim()); return { name: name || '', value: Number(num) || 0 }; }) .filter((x) => x.name); } async function saveWorkspace(patch: OrganicoWorkspace) { setSaving(true); setMsg(null); const next: OrganicoWorkspace = { priorities: { seo: workspace.priorities?.seo || [], geo: workspace.priorities?.geo || [], ...patch.priorities, }, sectionNotes: { ...(workspace.sectionNotes || {}), ...(patch.sectionNotes || {}), }, }; if (patch.priorities?.seo) next.priorities!.seo = patch.priorities.seo; if (patch.priorities?.geo) next.priorities!.geo = patch.priorities.geo; const res = await fetch('/api/organico', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kind: 'workspace', workspace: next }), }); const data = await res.json(); setSaving(false); if (!res.ok) { setMsg(data.error || 'Error al guardar'); return; } setWorkspace(data.workspace || next); setMsg('Guardado'); } async function saveSectionNote() { await saveWorkspace({ sectionNotes: { [sectionId]: sectionNote }, }); } async function savePriorities() { const lines = prioritiesText .split('\n') .map((l) => l.trim()) .filter(Boolean); await saveWorkspace({ priorities: pillar === 'geo' ? { geo: lines, seo: workspace.priorities?.seo || [] } : { seo: lines, geo: workspace.priorities?.geo || [] }, }); } async function runSeoAudit() { setAuditing(true); setMsg(null); const res = await fetch('/api/organico', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kind: 'seo-audit' }), }); const data = await res.json(); setAuditing(false); if (!res.ok) { setMsg(data.error || 'Error al auditar'); return; } if (data.warning) setWarning(data.warning); setMsg(data.audit?.summary || 'Auditoría guardada'); if (data.audit?.id) setSelectedAuditId(data.audit.id); await load(); } async function saveOrganicDay() { setSaving(true); setMsg(null); const res = await fetch('/api/organico', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kind: 'organic', ...seoForm, gsc_clicks: Number(seoForm.gsc_clicks) || 0, gsc_impressions: Number(seoForm.gsc_impressions) || 0, gsc_avg_position: seoForm.gsc_avg_position ? Number(seoForm.gsc_avg_position) : null, ga4_organic_sessions: Number(seoForm.ga4_organic_sessions) || 0, ga4_sessions: Number(seoForm.ga4_sessions) || 0, ga4_users: Number(seoForm.ga4_users) || 0, ga4_conversions: Number(seoForm.ga4_conversions) || 0, }), }); const data = await res.json(); setSaving(false); if (!res.ok) { setMsg(data.error || 'Error'); return; } setMsg('Día SEO guardado'); await load(); } async function saveGeo() { setSaving(true); setMsg(null); const comps = parsePairs(geoForm.competitors).map((x, i) => ({ brand: x.name, share: x.value, rank: i + 1, })); const cats = parsePairs(geoForm.categories).map((x, i) => ({ category: x.name, visibility: x.value, rank: i + 1, })); const res = await fetch('/api/organico', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kind: 'geo', label: geoForm.label, share_of_model: geoForm.share_of_model ? Number(geoForm.share_of_model) : null, engines: { chatgpt: geoForm.chatgpt ? Number(geoForm.chatgpt) : undefined, gemini: geoForm.gemini ? Number(geoForm.gemini) : undefined, ai_overviews: geoForm.ai_overviews ? Number(geoForm.ai_overviews) : undefined, }, official_citation_pct: geoForm.official_citation_pct ? Number(geoForm.official_citation_pct) : null, sentiment_score: geoForm.sentiment_score ? Number(geoForm.sentiment_score) : null, prompts_analyzed: geoForm.prompts_analyzed ? Number(geoForm.prompts_analyzed) : null, responses_analyzed: geoForm.responses_analyzed ? Number(geoForm.responses_analyzed) : null, study_url: geoForm.study_url || null, notes: geoForm.notes || null, competitors: comps, categories: cats, }), }); const data = await res.json(); setSaving(false); if (!res.ok) { setMsg(data.error || 'Error'); return; } setMsg('Medición GEO registrada'); setShowCapture(false); await load(); } if (portfolioWide) { return (
} />

Selecciona un cliente para abrir su suite SEO / GEO.

); } if (loading) { return (
Cargando suite orgánica…
); } return (
setPillar(p)} size="sm" options={[ { id: 'geo', label: 'GEO · IA' }, { id: 'seo', label: 'SEO' }, ]} /> {isAgency ? ( ) : null}
} /> {(warning || msg) && (

{msg || warning}

)} {/* Connections */}
{[ { title: 'Search Console', row: gsc }, { title: 'GA4', row: ga4 }, { title: 'GEO / IA', row: latestGeo ? { status: 'connected', external_account_id: latestGeo.label || 'Medición activa', } : { status: 'disconnected', external_account_id: null }, }, ].map((c) => (

{c.title}

{c.row?.status === 'connected' ? 'ON' : 'OFF'}

{c.row?.external_account_id || 'Sin vincular'}

{isAgency && tenantId && c.title !== 'GEO / IA' ? ( Ficha ) : null}
))}
{/* Capture forms */} {isAgency && showCapture && pillar === 'seo' && ( void saveOrganicDay()} /> )} {isAgency && showCapture && pillar === 'geo' && ( void saveGeo()} /> )} {/* Shell: nav + content */}
{/* Paneles SEO por sección */} {section.id === 'seo-overview' && ( <>
{seoDaily.length > 0 && (

Clics GSC · 28d

{seoDaily.map((d) => (
))}

Sesiones orgánicas GA4 · 28d

{seoDaily.map((d) => (
))}
)} {totals.days === 0 && ( )} )} {section.live === 'seo-foundations' && ( )} {section.live === 'seo-rankings' && (
Sí hay histórico: posición media diaria en{' '} organic_daily (acumulamos nosotros). Las queries actuales vienen live de GSC (ventana ~28d). No es un rank tracker de 10k KW tipo Semrush.
{seoDaily.length > 0 && (

Posición media GSC · histórico diario

{organic .filter((r) => r.day) .sort((a, b) => String(a.day).localeCompare(String(b.day))) .slice(-28) .map((r) => { const pos = Number(r.gsc_avg_position) || 0; const h = pos > 0 ? Math.max(8, Math.min(100, (1 / pos) * 100)) : 4; return (
); })}

Barras más altas ≈ mejor posición (inverso)

)} a.position - b.position)} pages={[]} landings={[]} focus="queries" />
)} {section.live === 'seo-trend' && (
{seoDaily.length > 0 ? (

Clics · evolución

{seoDaily.map((d) => (
))}

Sesiones orgánicas · evolución

{seoDaily.map((d) => (
))}
) : ( )}
)} {section.live === 'seo-pages' && ( )} {section.live === 'seo-queries' && ( )} {section.live === 'seo-cross' && (

Cruce: qué URLs rankean (GSC) vs qué landings reciben sesión orgánica (GA4).

)} {section.live === 'seo-content' && (

Ideas a partir de queries con impresiones altas y CTR bajo (oportunidad de mejorar snippet / contenido).

q.impressions >= 50) .sort((a, b) => a.ctr - b.ctr) .slice(0, 25)} pages={[]} landings={[]} focus="queries" />
)} {section.live === 'geo-metrics' && (section.id === 'geo-overview' || section.id === 'geo-brand' || section.id === 'geo-tracking') && ( <>
{GEO_ENGINES.map((eng) => { const v = Number( (latestGeo?.engines || {})[ eng.id as keyof typeof latestGeo.engines ] ?? NaN ); return (

{eng.label}

{Number.isFinite(v) ? formatPct(v) : '—'}

); })}
{!latestGeo && (

Dashboard GEO listo — falta la primera medición

A diferencia de SEO (GSC/GA4 por API), Share of Model se captura con un estudio de prompts × motores. Usa las prioridades expertas y registra el snapshot para rellenar estas tarjetas.

{isAgency && ( )}
)} )} {section.live === 'geo-competitors' && ( )} {section.live === 'geo-categories' && ( )} {/* Prioridades + notas solo en visión general SEO/GEO */} {(section.id === 'seo-overview' || section.id === 'geo-overview') && (

Prioridades claras · {pillar === 'geo' ? 'GEO' : 'SEO'}

Una por línea. Lo que hay que mejorar primero.

    {section.actions.map((a) => (
  • {a}
  • ))}
{isAgency ? ( <>