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