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

@@ -328,13 +328,18 @@ export default function AdTrackerClient({
]);
useEffect(() => {
if (tab === 'resumen') {
setCreatives([]);
setCreativesWarning(null);
setCreativesLoading(false);
return;
}
let cancelled = false;
async function load() {
setCreativesLoading(true);
setCreativesWarning(null);
try {
const platform =
tab === 'meta' ? 'meta' : tab === 'google' ? 'google' : 'all';
const platform = tab === 'meta' ? 'meta' : 'google';
const qs = new URLSearchParams({
since: range.since,
until: range.until,
@@ -532,21 +537,6 @@ export default function AdTrackerClient({
</div>
</section>
<CreativesGallery
platform="meta"
ads={creatives}
loading={creativesLoading}
warning={creativesWarning}
onZoom={setZoomAd}
/>
<CreativesGallery
platform="google"
ads={creatives}
loading={creativesLoading}
warning={creativesWarning}
onZoom={setZoomAd}
/>
<section className="rounded-2xl border border-gray-200 bg-white p-4 md:p-5">
<div className="flex flex-wrap items-start justify-between gap-3 mb-3">
<div>

View File

@@ -256,34 +256,34 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
<PageHeader
eyebrow="Contenido"
title="Activos"
description={`Réplica de la carpeta Drive del cliente (${tenantLabel}) bajo Clientes. Navega carpetas como en Drive; «Abrir en Drive» para la UI nativa.`}
description={`Réplica exacta de Drive · carpeta del cliente «${tenantLabel}» (bajo Clientes). Solo ese árbol; sin mezclar otros clientes.`}
/>
<div className="flex gap-2">
<button
type="button"
onClick={() => setTab('portal')}
className={`text-xs px-3 py-1.5 rounded-lg border ${
tab === 'portal'
? 'border-brand-500/40 text-brand-400'
: 'border-gray-200 text-gray-400'
}`}
>
Portal
</button>
<button
type="button"
onClick={() => setTab('drive')}
className={`inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg border ${
tab === 'drive'
? 'border-brand-500/40 text-brand-400'
: 'border-gray-200 text-gray-400'
? 'border-brand-500/40 text-brand-600 bg-brand-50'
: 'border-gray-200 text-gray-500'
}`}
>
<HardDrive className="w-3.5 h-3.5" />
Google Drive
{driveFolder ? ` · ${driveFolder}` : ''}
</button>
<button
type="button"
onClick={() => setTab('portal')}
className={`text-xs px-3 py-1.5 rounded-lg border ${
tab === 'portal'
? 'border-brand-500/40 text-brand-600 bg-brand-50'
: 'border-gray-200 text-gray-500'
}`}
>
Subidas portal
</button>
</div>
{warning && (

View File

@@ -64,13 +64,6 @@ const NAV_ITEMS: NavItem[] = [
iconClass: 'text-emerald-500',
moduleId: 'estrategia',
},
{
href: '/dashboard/calendario',
label: 'Calendario Editorial',
icon: CalendarDays,
iconClass: 'text-pink-500',
moduleId: 'calendario',
},
{
href: '/dashboard/ad-tracker',
label: 'Ads',
@@ -174,6 +167,7 @@ export default function DashboardShell({
const [open, setOpen] = useState(false);
const close = () => setOpen(false);
const dateRange = useDateRange();
const pathname = usePathname();
const visibleNav = NAV_ITEMS.filter((item) => {
if (isAgency) return true;
@@ -230,9 +224,32 @@ export default function DashboardShell({
</div>
<nav className="flex-1 overflow-y-auto space-y-1.5 pr-1">
{visibleNav.map((item) => (
<NavLink key={item.href} item={item} onNavigate={close} />
))}
{visibleNav.map((item) => {
const showCalendarioUnderSocial =
item.moduleId === 'social' &&
(isAgency ||
!enabledModules ||
enabledModules.calendario !== false);
return (
<div key={item.href} className="space-y-0.5">
<NavLink item={item} onNavigate={close} />
{showCalendarioUnderSocial ? (
<Link
href="/dashboard/calendario"
onClick={close}
className={`ml-4 flex items-center gap-2.5 px-3 py-2 rounded-lg text-xs font-medium transition-all ${
isActive(pathname, '/dashboard/calendario')
? 'bg-brand-50 text-gray-900 border border-brand-200'
: 'text-gray-500 border border-transparent hover:text-gray-900 hover:bg-gray-50'
}`}
>
<CalendarDays className="w-3.5 h-3.5 shrink-0 text-pink-500" />
Calendario editorial
</Link>
) : null}
</div>
);
})}
{isAgency ? (
<div className="pt-4 mt-4 border-t border-gray-200">

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">

View File

@@ -259,7 +259,7 @@ export default function SocialDashboard({
<div className="flex flex-wrap gap-2">
<Link
href="/dashboard/calendario"
className="text-xs font-semibold px-3 py-1.5 rounded-lg border border-gray-200 text-gray-800 hover:bg-gray-50"
className="text-xs font-semibold px-3 py-1.5 rounded-lg bg-brand-500 text-white hover:bg-brand-600"
>
Calendario editorial
</Link>