feat: calendario bajo Social, SEO/GEO con datos live, Drive = 03-Clientes

Calendario editorial anidado en Social; Orgánico con queries/páginas GSC y landings GA4 + dashboard GEO; Biblioteca enlazada a la carpeta real del cliente bajo 03-Clientes (no carpetas homónimas); previews Ads solo en Meta/Google.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-24 16:12:07 +02:00
parent 3425364b07
commit f4dfc50cc9
9 changed files with 699 additions and 107 deletions

View File

@@ -86,14 +86,23 @@ export default function OrganicoDashboard({
isAgency,
tenantId,
}: Props) {
const [pillar, setPillar] = useState<OrganicoPillar>('geo');
const [sectionId, setSectionId] = useState<OrganicoSectionId>('geo-overview');
const [pillar, setPillar] = useState<OrganicoPillar>('seo');
const [sectionId, setSectionId] = useState<OrganicoSectionId>('seo-overview');
const [loading, setLoading] = useState(true);
const [warning, setWarning] = useState<string | null>(null);
const [organic, setOrganic] = useState<OrganicDailyRow[]>([]);
const [geo, setGeo] = useState<GeoSnapshot[]>([]);
const [integrations, setIntegrations] = useState<IntegrationLite[]>([]);
const [workspace, setWorkspace] = useState<OrganicoWorkspace>({});
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 [sectionNote, setSectionNote] = useState('');
const [prioritiesText, setPrioritiesText] = useState('');
const [saving, setSaving] = useState(false);
@@ -135,6 +144,9 @@ export default function OrganicoDashboard({
setGeo(data.geo || []);
setIntegrations(data.integrations || []);
setWorkspace(data.workspace || {});
setGscQueries(data.gscQueries || []);
setGscPages(data.gscPages || []);
setGa4Landings(data.ga4Landings || []);
setWarning(data.warning || null);
if (data.autoSynced?.daysWritten) {
setMsg(
@@ -618,26 +630,49 @@ export default function OrganicoDashboard({
{totals.days === 0 && (
<EmptyHint text="GSC/GA4 conectados pero sin filas en organic_daily. Lanza sync en Agencia o revisa property/URL-prefix." />
)}
<SeoTables
queries={gscQueries}
pages={gscPages}
landings={ga4Landings}
/>
</>
)}
{section.live === 'geo-metrics' && latestGeo && (
{(section.id === 'seo-keywords' ||
section.id === 'seo-rankings' ||
section.id === 'seo-keyword-magic') && (
<SeoTables queries={gscQueries} pages={[]} landings={[]} focus="queries" />
)}
{(section.id === 'seo-pages' ||
section.id === 'seo-organic' ||
section.id === 'seo-traffic-insights') && (
<SeoTables
queries={[]}
pages={gscPages}
landings={ga4Landings}
focus="pages"
/>
)}
{section.live === 'geo-metrics' && (
<>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<Kpi
accent
label="Share of Model"
value={
latestGeo.share_of_model != null
latestGeo?.share_of_model != null
? formatPct(Number(latestGeo.share_of_model))
: '—'
}
hint={latestGeo ? undefined : 'Sin medición aún'}
/>
<Kpi
accent
label="Citas web oficial"
value={
latestGeo.official_citation_pct != null
latestGeo?.official_citation_pct != null
? formatPct(Number(latestGeo.official_citation_pct))
: '—'
}
@@ -645,26 +680,25 @@ export default function OrganicoDashboard({
<Kpi
label="Sentimiento"
value={
latestGeo.sentiment_score != null
latestGeo?.sentiment_score != null
? formatNum(Number(latestGeo.sentiment_score), 0)
: '—'
}
hint="0–100"
/>
<Kpi
label="Muestra"
label="Prompts analizados"
value={
latestGeo.prompts_analyzed != null
latestGeo?.prompts_analyzed != null
? formatNum(Number(latestGeo.prompts_analyzed))
: '—'
}
hint="Prompts"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{GEO_ENGINES.map((eng) => {
const v = Number(
(latestGeo.engines || {})[
(latestGeo?.engines || {})[
eng.id as keyof typeof latestGeo.engines
] ?? NaN
);
@@ -691,31 +725,30 @@ export default function OrganicoDashboard({
);
})}
</div>
</>
)}
{section.live === 'geo-metrics' && !latestGeo && (
<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">
GEO sin medición aún
</p>
<p className="text-xs text-amber-800">
A diferencia de GSC/GA4, Share of Model no llega por API
automática: registra un snapshot (prompts × motores) para
activar el dashboard. Mientras tanto, trabaja las prioridades
expertas de la derecha.
</p>
{isAgency && (
<button
type="button"
onClick={() => setShowCapture(true)}
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg bg-amber-800 text-white"
>
<Plus className="w-3.5 h-3.5" />
Registrar medición GEO
</button>
{!latestGeo && (
<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">
Dashboard GEO listo — falta la primera medición
</p>
<p className="text-xs text-amber-800">
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.
</p>
{isAgency && (
<button
type="button"
onClick={() => setShowCapture(true)}
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg bg-amber-800 text-white"
>
<Plus className="w-3.5 h-3.5" />
Registrar medición GEO
</button>
)}
</div>
)}
</div>
</>
)}
{section.live === 'geo-competitors' && (
@@ -872,6 +905,151 @@ export default function OrganicoDashboard({
);
}
function SeoTables({
queries,
pages,
landings,
focus = 'all',
}: {
queries: { key: string; clicks: number; impressions: number; ctr: number; position: number }[];
pages: { key: string; clicks: number; impressions: number; ctr: number; position: number }[];
landings: { page: string; sessions: number; users: number }[];
focus?: 'all' | 'queries' | 'pages';
}) {
const showQ = focus === 'all' || focus === 'queries';
const showP = focus === 'all' || focus === 'pages';
return (
<div className="space-y-4">
{showQ && (
<section className="rounded-xl border border-gray-200 bg-white overflow-hidden">
<div className="px-4 py-3 border-b border-gray-100">
<h4 className="text-sm font-semibold text-gray-900">
Top queries GSC (28d)
</h4>
</div>
{queries.length === 0 ? (
<p className="px-4 py-6 text-xs text-gray-500">
Sin queries. Verifica Search Console conectado y permisos.
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="text-[10px] uppercase tracking-wider text-gray-500 bg-gray-50">
<tr>
<th className="px-4 py-2">Query</th>
<th className="px-3 py-2">Clics</th>
<th className="px-3 py-2">Imp.</th>
<th className="px-3 py-2">CTR</th>
<th className="px-3 py-2">Pos.</th>
</tr>
</thead>
<tbody>
{queries.slice(0, 20).map((r) => (
<tr key={r.key} className="border-t border-gray-100">
<td className="px-4 py-2 text-gray-800 max-w-[280px] truncate">
{r.key}
</td>
<td className="px-3 py-2 tabular-nums">{formatNum(r.clicks)}</td>
<td className="px-3 py-2 tabular-nums">
{formatNum(r.impressions)}
</td>
<td className="px-3 py-2 tabular-nums">{formatPct(r.ctr)}</td>
<td className="px-3 py-2 tabular-nums">
{formatNum(r.position, 1)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
)}
{showP && (
<section className="rounded-xl border border-gray-200 bg-white overflow-hidden">
<div className="px-4 py-3 border-b border-gray-100">
<h4 className="text-sm font-semibold text-gray-900">
Top páginas GSC (28d)
</h4>
</div>
{pages.length === 0 ? (
<p className="px-4 py-6 text-xs text-gray-500">Sin páginas GSC.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="text-[10px] uppercase tracking-wider text-gray-500 bg-gray-50">
<tr>
<th className="px-4 py-2">URL</th>
<th className="px-3 py-2">Clics</th>
<th className="px-3 py-2">Imp.</th>
<th className="px-3 py-2">Pos.</th>
</tr>
</thead>
<tbody>
{pages.slice(0, 20).map((r) => (
<tr key={r.key} className="border-t border-gray-100">
<td className="px-4 py-2 text-gray-800 max-w-[320px] truncate">
{r.key}
</td>
<td className="px-3 py-2 tabular-nums">{formatNum(r.clicks)}</td>
<td className="px-3 py-2 tabular-nums">
{formatNum(r.impressions)}
</td>
<td className="px-3 py-2 tabular-nums">
{formatNum(r.position, 1)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
)}
{showP && (
<section className="rounded-xl border border-gray-200 bg-white overflow-hidden">
<div className="px-4 py-3 border-b border-gray-100">
<h4 className="text-sm font-semibold text-gray-900">
Landings orgánicas GA4 (28d)
</h4>
</div>
{landings.length === 0 ? (
<p className="px-4 py-6 text-xs text-gray-500">
Sin landings orgánicas GA4.
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="text-[10px] uppercase tracking-wider text-gray-500 bg-gray-50">
<tr>
<th className="px-4 py-2">Landing</th>
<th className="px-3 py-2">Sesiones</th>
<th className="px-3 py-2">Usuarios</th>
</tr>
</thead>
<tbody>
{landings.slice(0, 20).map((r) => (
<tr key={r.page} className="border-t border-gray-100">
<td className="px-4 py-2 text-gray-800 max-w-[320px] truncate">
{r.page}
</td>
<td className="px-3 py-2 tabular-nums">
{formatNum(r.sessions)}
</td>
<td className="px-3 py-2 tabular-nums">{formatNum(r.users)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
)}
</div>
);
}
function SectionHero({ section }: { section: OrganicoSection }) {
return (
<div className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">