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