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') && (
{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.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}
queries={[...gscQueries].sort((a, b) => a.position - b.position)}
pages={[]}
landings={[]}
focus="queries"
/>
</div>
)}
{(section.id === 'seo-pages' ||
section.id === 'seo-organic' ||
section.id === 'seo-traffic-insights' ||
section.id === 'seo-compare') && (
{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)}%`,
}}
<SeoTables
queries={[]}
pages={gscPages}
landings={ga4Landings}
focus="pages"
/>
</div>
))}
</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.
<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 &&

View File

@@ -213,7 +213,18 @@ export type OrganicoSection = {
blurb: string;
/** What the user should do next when empty. */
actions: string[];
live?: 'seo-metrics' | 'geo-metrics' | 'geo-competitors' | 'geo-categories' | 'seo-foundations';
live?:
| 'seo-metrics'
| 'seo-foundations'
| 'seo-rankings'
| 'seo-trend'
| 'seo-pages'
| 'seo-queries'
| 'seo-cross'
| 'seo-content'
| 'geo-metrics'
| 'geo-competitors'
| 'geo-categories';
soon?: boolean;
};
@@ -370,18 +381,18 @@ export const ORGANICO_SECTIONS: OrganicoSection[] = [
soon: true,
},
// ——— SEO ———
// ——— SEO (solo secciones con datos reales GSC/GA4 o checklist operable) ———
{
id: 'seo-overview',
pillar: 'seo',
group: 'Rendimiento del sitio',
label: 'Visión general',
blurb:
'Hazte visible donde busca tu audiencia. Serie diaria desde GA4/GSC — el portal guarda histórico propio (GSC solo ofrece ~16 meses en API).',
'KPIs diarios GA4 + GSC. El histórico vive en organic_daily (nosotros lo acumulamos; la API de GSC sola no basta).',
actions: [
'Conectar Search Console y GA4',
'Sincronizar (histórico acumulado en organic_daily)',
'Definir 3 prioridades del mes',
'Revisar serie 28d',
'Definir prioridades del mes',
],
live: 'seo-metrics',
},
@@ -391,11 +402,11 @@ export const ORGANICO_SECTIONS: OrganicoSection[] = [
group: 'Rendimiento del sitio',
label: 'Auditoría del sitio',
blurb:
'Consulta problemas de SEO que limitan posicionamiento: salud, contenido y UX.',
'Checklist operativo de salud SEO (indexación, arquitectura, CWV, contenido, medición). No es un crawl Semrush: es la base que hay que cerrar con los datos conectados.',
actions: [
'Auditar indexación y CWV',
'Corregir errores críticos',
'Plan de remediación por impacto',
'Marcar OK/KO por bloque',
'Priorizar indexación + medición',
'CWV en landings de conversión',
],
live: 'seo-foundations',
},
@@ -405,240 +416,97 @@ export const ORGANICO_SECTIONS: OrganicoSection[] = [
group: 'Rendimiento del sitio',
label: 'Rastreo de posición',
blurb:
'Rastrea posicionamiento en Google (y más adelante Bing / Modo IA) para keywords de alto valor.',
'Sí: posición media diaria en organic_daily (histórico nuestro) + ranking actual de queries GSC. No es un rank tracker de 10k KW tipo Semrush; es lo que GSC reporta para tus impresiones reales.',
actions: [
'Definir cartera de keywords',
'Registrar posición media GSC',
'Alertar caídas de top 10',
'Mirar tendencia de posición media',
'Atacar queries en pos. 4–15 (quick wins)',
'Ampliar cartera vía contenido',
],
live: 'seo-metrics',
},
{
id: 'seo-competition',
pillar: 'seo',
group: 'Análisis de la competencia',
label: 'Visión general del dominio',
blurb:
'Supera a tu competencia con análisis SEO: tráfico, keywords y estrategias.',
actions: [
'Fijar 3 dominios rivales',
'Comparar autoridad y temáticas',
'Extraer oportunidades de contenido',
],
soon: true,
live: 'seo-rankings',
},
{
id: 'seo-organic',
pillar: 'seo',
group: 'Análisis de la competencia',
group: 'Tráfico y páginas',
label: 'Posicionamiento orgánico',
blurb:
'Mide visibilidad orgánica y evolución de clics/impresiones frente al mercado.',
'Evolución de clics e impresiones (visibilidad). Distinto de «Páginas principales».',
actions: [
'Revisar serie GSC MoM',
'Identificar queries en ascenso/descenso',
'Alinear con objetivos de negocio',
'Leer tendencia MoM de clics',
'Cruzar con campañas de contenido',
'Alertar caídas bruscas',
],
live: 'seo-metrics',
live: 'seo-trend',
},
{
id: 'seo-pages',
pillar: 'seo',
group: 'Análisis de la competencia',
group: 'Tráfico y páginas',
label: 'Páginas principales',
blurb: 'Landings que concentran tráfico y conversión orgánica.',
actions: [
'Listar top páginas por clics',
'Optimizar títulos/meta y UX',
'Reforzar enlazado interno',
],
},
{
id: 'seo-compare',
pillar: 'seo',
group: 'Análisis de la competencia',
label: 'Comparación de dominios',
blurb: 'Compara tu dominio con rivales en una sola vista.',
actions: [
'Elegir métricas comunes (tráfico, KW, backlinks)',
'Marcar gaps críticos',
'Asignar owners internos',
],
},
{
id: 'seo-keyword-gap',
pillar: 'seo',
group: 'Análisis de la competencia',
label: 'Brecha de palabras clave',
blurb:
'Keywords donde rivales rankean y tú no — hoja de ruta clara.',
'URLs que concentran clics GSC + landings orgánicas GA4. No es el mismo panel que posicionamiento.',
actions: [
'Exportar gap vs top rivales',
'Priorizar por intención de compra',
'Crear o potenciar páginas',
],
},
{
id: 'seo-backlink-gap',
pillar: 'seo',
group: 'Análisis de la competencia',
label: 'Brecha de backlinks',
blurb:
'Dominios que enlazan a rivales y no a ti: autoridad y confianza.',
actions: [
'Listar dominios de referencia únicos de rivales',
'Pitch / digital PR',
'Monitorizar nuevos enlaces',
'Optimizar top 10 URLs',
'Alinear title/H1/CTA',
'Enlazar desde hubs',
],
live: 'seo-pages',
},
{
id: 'seo-keywords',
pillar: 'seo',
group: 'Investigación de palabras clave',
group: 'Keywords',
label: 'Visión general de keywords',
blurb:
'Palabras clave y prompts con intención de compra para Google y ChatGPT.',
'Queries reales con impresiones/clics/CTR/posición desde Search Console (28d).',
actions: [
'Mapear KW → intención → URL',
'Detectar huecos de cobertura',
'Alinear con calendario editorial',
],
},
{
id: 'seo-keyword-magic',
pillar: 'seo',
group: 'Investigación de palabras clave',
label: 'Keyword Magic / ideas',
blurb: 'Explora ideas de keywords y variantes semánticas.',
actions: [
'Sembrar seed keywords del negocio',
'Agrupar por clusters',
'Seleccionar quick wins',
],
},
{
id: 'seo-strategy',
pillar: 'seo',
group: 'Investigación de palabras clave',
label: 'Keyword Strategy Builder',
blurb: 'Construye la estrategia de keywords del trimestre.',
actions: [
'Definir pilares y clusters',
'Asignar prioridad P1/P2/P3',
'Vincular a OKRs',
],
},
{
id: 'seo-content-ideas',
pillar: 'seo',
group: 'Ideas de contenido',
label: 'Ideas de contenido',
blurb:
'Temas en tendencia, planes y briefings optimizados para posicionar.',
actions: [
'Topic research del mes',
'Briefs con outline + KW + FAQs',
'Publicar y medir en GSC',
],
},
{
id: 'seo-writing',
pillar: 'seo',
group: 'Ideas de contenido',
label: 'SEO Writing Assistant',
blurb: 'Asistencia para redactar y optimizar on-page.',
actions: [
'Revisar densidad y entidades',
'Mejorar claridad y E-E-A-T',
'Checklist pre-publicación',
],
},
{
id: 'seo-topics',
pillar: 'seo',
group: 'Ideas de contenido',
label: 'Topic Research',
blurb: 'Tendencias de temas que alimentan SEO y GEO.',
actions: [
'Cruzar topics con prompts IA',
'Detectar ángulos diferenciales',
'Planificar pieza pilar + satélites',
],
},
{
id: 'seo-template',
pillar: 'seo',
group: 'Ideas de contenido',
label: 'SEO Content Template',
blurb: 'Plantillas de contenido basadas en lo que rankea.',
actions: [
'Analizar SERP / respuestas IA',
'Definir estructura ganadora',
'Reutilizar en el equipo',
],
},
{
id: 'seo-backlinks',
pillar: 'seo',
group: 'Generación de enlaces',
label: 'Backlinks',
blurb:
'Gana autoridad con backlinks de calidad que impulsan confianza y SEO.',
actions: [
'Auditar perfil actual',
'Campañas de link earning',
'Desautorizar tóxicos',
],
},
{
id: 'seo-refdomains',
pillar: 'seo',
group: 'Generación de enlaces',
label: 'Dominios de referencia',
blurb: 'Diversidad y calidad de dominios que te enlazan.',
actions: [
'Medir referidos únicos',
'Buscar nichos afines',
'Relaciones con medios / partners',
],
},
{
id: 'seo-backlink-audit',
pillar: 'seo',
group: 'Generación de enlaces',
label: 'Backlink Audit',
blurb: 'Salud del perfil de enlaces y riesgo reputacional.',
actions: [
'Clasificar enlaces',
'Disavow si procede',
'Informe trimestral',
],
},
{
id: 'seo-onpage',
pillar: 'seo',
group: 'Extras',
label: 'On Page SEO Checker',
blurb: 'Comprueba on-page en URLs prioritarias.',
actions: [
'Auditar top landings',
'Corregir title/H1/internal links',
'Re-crawl',
'Mapear KW → URL',
'Separar informativas vs compra',
'Priorizar quick wins',
],
live: 'seo-queries',
},
{
id: 'seo-traffic-insights',
pillar: 'seo',
group: 'Extras',
group: 'Keywords',
label: 'Organic Traffic Insights',
blurb:
'Cruza GSC + GA4: cómo el orgánico mueve tráfico y conversiones reales.',
'Cruce GSC (páginas) + GA4 (sesiones orgánicas / landings) para ver qué rankea y qué convierte.',
actions: [
'Conectar GA4',
'Atribuir landings a leads/ventas',
'Comparar top pages GSC vs landings GA4',
'Detectar páginas con clics y poca conversión',
'Informar ROI orgánico',
],
live: 'seo-metrics',
live: 'seo-cross',
},
{
id: 'seo-competition',
pillar: 'seo',
group: 'Competencia y enlaces',
label: 'Competencia / backlinks',
blurb:
'Requiere Ahrefs/Semrush u otra fuente de backlinks y dominio rival. No está en GSC/GA4. Aquí solo el marco de trabajo hasta integrar una herramienta.',
actions: [
'Fijar 3 dominios rivales',
'Importar gap KW / backlinks desde herramienta externa',
'Priorizar 5 acciones/mes',
],
soon: true,
},
{
id: 'seo-content-ideas',
pillar: 'seo',
group: 'Contenido',
label: 'Ideas de contenido',
blurb:
'Briefs a partir de queries GSC con tracción (clics o impresiones altas y CTR bajo).',
actions: [
'Partir de queries con CTR bajo',
'Crear/ampliar pieza',
'Medir de nuevo en 28d',
],
live: 'seo-content',
},
];