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:
@@ -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 &&
|
||||
|
||||
Reference in New Issue
Block a user