fix: menú SEO realista — paneles distintos y sin stubs Semrush vacíos

Recorta la suite a secciones con GSC/GA4 (overview, auditoría checklist, rankings con histórico, tendencia, páginas, keywords, insights, contenido). Competencia/backlinks quedan como “herramienta externa”. Ya no se reutiliza el mismo dashboard en ítems distintos.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-24 16:47:24 +02:00
parent 4b6f7f1896
commit d896041bed
2 changed files with 306 additions and 296 deletions

View File

@@ -547,15 +547,12 @@ export default function OrganicoDashboard({
<div className="space-y-4 min-w-0">
<SectionHero section={section} />
{/* Paneles por sección (no el mismo dashboard en todos) */}
{/* Paneles SEO por sección */}
{section.id === 'seo-overview' && (
<>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<Kpi label="Clics GSC" value={formatNum(totals.gscClicks)} />
<Kpi
label="Impresiones"
value={formatNum(totals.gscImpressions)}
/>
<Kpi label="Impresiones" value={formatNum(totals.gscImpressions)} />
<Kpi label="CTR" value={formatPct(totals.gscCtr)} />
<Kpi
label="Posición media"
@@ -576,9 +573,9 @@ export default function OrganicoDashboard({
value={formatNum(totals.ga4Conversions)}
/>
<Kpi
label="Días con datos"
label="Días con histórico"
value={formatNum(totals.days)}
hint="Serie cargada"
hint="organic_daily"
/>
</div>
{seoDaily.length > 0 && (
@@ -589,16 +586,10 @@ export default function OrganicoDashboard({
</p>
<div className="flex items-end gap-0.5 h-16">
{seoDaily.map((d) => (
<div
key={`c-${d.day}`}
className="flex-1"
title={`${d.day}: ${d.clicks} clics`}
>
<div key={`c-${d.day}`} className="flex-1" title={`${d.day}: ${d.clicks}`}>
<div
className="w-full rounded-t bg-emerald-400/80"
style={{
height: `${Math.max(3, (d.clicks / maxClicks) * 100)}%`,
}}
style={{ height: `${Math.max(3, (d.clicks / maxClicks) * 100)}%` }}
/>
</div>
))}
@@ -610,16 +601,10 @@ export default function OrganicoDashboard({
</p>
<div className="flex items-end gap-0.5 h-16">
{seoDaily.map((d) => (
<div
key={`s-${d.day}`}
className="flex-1"
title={`${d.day}: ${d.sessions} sesiones`}
>
<div key={`s-${d.day}`} className="flex-1" title={`${d.day}: ${d.sessions}`}>
<div
className="w-full rounded-t bg-sky-400/80"
style={{
height: `${Math.max(3, (d.sessions / maxSessions) * 100)}%`,
}}
style={{ height: `${Math.max(3, (d.sessions / maxSessions) * 100)}%` }}
/>
</div>
))}
@@ -628,7 +613,7 @@ export default function OrganicoDashboard({
</div>
)}
{totals.days === 0 && (
<EmptyHint text="GSC/GA4 conectados pero sin filas en organic_daily. Lanza sync en Agencia o revisa property/URL-prefix." />
<EmptyHint text="Sin filas en organic_daily. Sync GA4/GSC en Agencia." />
)}
<SeoTables
queries={gscQueries.slice(0, 8)}
@@ -638,22 +623,169 @@ export default function OrganicoDashboard({
</>
)}
{(section.id === 'seo-keywords' ||
section.id === 'seo-rankings' ||
section.id === 'seo-keyword-magic' ||
section.id === 'seo-keyword-gap') && (
<SeoTables
queries={gscQueries}
pages={[]}
landings={[]}
focus="queries"
/>
{section.live === 'seo-foundations' && (
<div className="space-y-3">
<p className="text-xs text-gray-600">
Checklist de auditoría operativa. El estado «OK» de medición
refleja si GA4 + GSC están conectados; el resto lo cierra el equipo.
</p>
<ul className="grid grid-cols-1 md:grid-cols-2 gap-2">
{SEO_FOUNDATIONS.map((f) => {
const ok =
f.id === 'medicion' &&
ga4?.status === 'connected' &&
gsc?.status === 'connected';
return (
<li
key={f.id}
className={`rounded-lg border px-3 py-2.5 ${
ok
? 'border-emerald-200 bg-emerald-50'
: 'border-gray-200 bg-white'
}`}
>
<p className="text-sm font-semibold text-gray-900">
{f.title}
{ok ? (
<span className="ml-2 text-[10px] uppercase text-emerald-700">
OK
</span>
) : (
<span className="ml-2 text-[10px] uppercase text-amber-700">
Revisar
</span>
)}
</p>
<p className="text-[11px] text-gray-600 mt-0.5">{f.hint}</p>
</li>
);
})}
</ul>
<div className="rounded-xl border border-gray-200 bg-white p-3 text-xs text-gray-700 space-y-1">
<p className="font-semibold text-gray-900">Estado conexiones</p>
<p>GSC: {gsc?.status === 'connected' ? 'conectado' : 'sin conectar'}{gsc?.external_account_id ? ` · ${gsc.external_account_id}` : ''}</p>
<p>GA4: {ga4?.status === 'connected' ? 'conectado' : 'sin conectar'}{ga4?.external_account_id ? ` · ${ga4.external_account_id}` : ''}</p>
</div>
</div>
)}
{(section.id === 'seo-pages' ||
section.id === 'seo-organic' ||
section.id === 'seo-traffic-insights' ||
section.id === 'seo-compare') && (
{section.live === 'seo-rankings' && (
<div className="space-y-4">
<div className="rounded-xl border border-sky-200 bg-sky-50 px-3 py-2 text-xs text-sky-900">
<strong>Sí hay histórico:</strong> posición media diaria en{' '}
<code className="text-[10px]">organic_daily</code> (acumulamos
nosotros). Las queries actuales vienen live de GSC (ventana ~28d).
No es un rank tracker de 10k KW tipo Semrush.
</div>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
<Kpi
label="Posición media (periodo)"
value={
totals.gscAvgPosition
? formatNum(totals.gscAvgPosition, 1)
: '—'
}
/>
<Kpi label="Queries con datos" value={formatNum(gscQueries.length)} />
<Kpi
label="Días históricos"
value={formatNum(totals.days)}
hint="organic_daily"
/>
</div>
{seoDaily.length > 0 && (
<div className="rounded-xl border border-gray-200 bg-white p-3">
<p className="text-[10px] uppercase tracking-wider text-gray-500 mb-2">
Posición media GSC · histórico diario
</p>
<div className="flex items-end gap-0.5 h-20">
{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 (
<div
key={`pos-${r.day}`}
className="flex-1"
title={`${String(r.day).slice(0, 10)}: pos ${pos || '—'}`}
>
<div
className="w-full rounded-t bg-violet-400/80"
style={{ height: `${h}%` }}
/>
</div>
);
})}
</div>
<p className="text-[10px] text-gray-500 mt-1">
Barras más altas ≈ mejor posición (inverso)
</p>
</div>
)}
<SeoTables
queries={[...gscQueries].sort((a, b) => a.position - b.position)}
pages={[]}
landings={[]}
focus="queries"
/>
</div>
)}
{section.live === 'seo-trend' && (
<div className="space-y-4">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<Kpi label="Clics" value={formatNum(totals.gscClicks)} />
<Kpi label="Impresiones" value={formatNum(totals.gscImpressions)} />
<Kpi label="CTR" value={formatPct(totals.gscCtr)} />
<Kpi
label="Sesiones orgánicas"
value={formatNum(totals.ga4Organic)}
/>
</div>
{seoDaily.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="rounded-xl border border-gray-200 bg-white p-3">
<p className="text-[10px] uppercase tracking-wider text-gray-500 mb-2">
Clics · evolución
</p>
<div className="flex items-end gap-0.5 h-24">
{seoDaily.map((d) => (
<div key={`t-${d.day}`} className="flex-1" title={`${d.day}: ${d.clicks}`}>
<div
className="w-full rounded-t bg-emerald-500/80"
style={{ height: `${Math.max(3, (d.clicks / maxClicks) * 100)}%` }}
/>
</div>
))}
</div>
</div>
<div className="rounded-xl border border-gray-200 bg-white p-3">
<p className="text-[10px] uppercase tracking-wider text-gray-500 mb-2">
Sesiones orgánicas · evolución
</p>
<div className="flex items-end gap-0.5 h-24">
{seoDaily.map((d) => (
<div key={`u-${d.day}`} className="flex-1" title={`${d.day}: ${d.sessions}`}>
<div
className="w-full rounded-t bg-sky-500/80"
style={{ height: `${Math.max(3, (d.sessions / maxSessions) * 100)}%` }}
/>
</div>
))}
</div>
</div>
</div>
) : (
<EmptyHint text="Sin serie diaria todavía." />
)}
</div>
)}
{section.live === 'seo-pages' && (
<SeoTables
queries={[]}
pages={gscPages}
@@ -662,27 +794,45 @@ export default function OrganicoDashboard({
/>
)}
{section.id === 'seo-organic' && seoDaily.length > 0 && (
<div className="rounded-xl border border-gray-200 bg-white p-3">
<p className="text-[10px] uppercase tracking-wider text-gray-500 mb-2">
Evolución clics orgánicos
{section.live === 'seo-queries' && (
<SeoTables
queries={gscQueries}
pages={[]}
landings={[]}
focus="queries"
/>
)}
{section.live === 'seo-cross' && (
<div className="space-y-4">
<p className="text-xs text-gray-600">
Cruce: qué URLs rankean (GSC) vs qué landings reciben sesión
orgánica (GA4).
</p>
<div className="flex items-end gap-0.5 h-20">
{seoDaily.map((d) => (
<div
key={`o-${d.day}`}
className="flex-1"
title={`${d.day}: ${d.clicks}`}
>
<div
className="w-full rounded-t bg-emerald-500/80"
style={{
height: `${Math.max(3, (d.clicks / maxClicks) * 100)}%`,
}}
/>
</div>
))}
</div>
<SeoTables
queries={[]}
pages={gscPages}
landings={ga4Landings}
focus="pages"
/>
</div>
)}
{section.live === 'seo-content' && (
<div className="space-y-3">
<p className="text-xs text-gray-600">
Ideas a partir de queries con impresiones altas y CTR bajo
(oportunidad de mejorar snippet / contenido).
</p>
<SeoTables
queries={[...gscQueries]
.filter((q) => q.impressions >= 50)
.sort((a, b) => a.ctr - b.ctr)
.slice(0, 25)}
pages={[]}
landings={[]}
focus="queries"
/>
</div>
)}
@@ -793,32 +943,6 @@ export default function OrganicoDashboard({
<CategoryPanel latest={latestGeo} />
)}
{section.live === 'seo-foundations' && (
<ul className="grid grid-cols-1 md:grid-cols-2 gap-2">
{SEO_FOUNDATIONS.map((f) => {
const ok =
f.id === 'medicion' &&
ga4?.status === 'connected' &&
gsc?.status === 'connected';
return (
<li
key={f.id}
className={`rounded-lg border px-3 py-2.5 ${
ok
? 'border-emerald-200 bg-emerald-50'
: 'border-gray-200 bg-white'
}`}
>
<p className="text-sm font-semibold text-gray-900">
{f.title}
</p>
<p className="text-[11px] text-gray-600 mt-0.5">{f.hint}</p>
</li>
);
})}
</ul>
)}
{/* Prioridades + notas solo en visión general SEO/GEO */}
{(section.id === 'seo-overview' || section.id === 'geo-overview') && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
@@ -930,10 +1054,28 @@ export default function OrganicoDashboard({
)}
{section.soon && (
<p className="text-xs text-amber-900 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
Disponible en breve: informes de ROI IA + tráfico/conversiones GA4
en un clic.
</p>
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 space-y-2">
<p className="text-sm font-semibold text-amber-900">
Requiere herramienta externa
</p>
<p className="text-xs text-amber-800">
Competencia de dominio, keyword gap y backlinks no salen de
GSC/GA4. Hay que conectar Ahrefs/Semrush (o importar CSV) para
alimentar este panel. Mientras tanto usa las secciones con datos
reales: visión general, rankings, keywords y páginas.
</p>
<ul className="space-y-1.5 pt-1">
{section.actions.map((a) => (
<li
key={a}
className="flex items-start gap-2 text-xs text-amber-900"
>
<ArrowRight className="w-3.5 h-3.5 shrink-0 mt-0.5" />
{a}
</li>
))}
</ul>
</div>
)}
{!section.soon &&