feat: vista Orgánico con SEO (GA4/GSC) y GEO (visibilidad IA)

Nueva sección con métricas Search Console/GA4, cimientos SEO y
Share of Model por motor (ChatGPT/Gemini/AI Overviews).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-24 11:18:26 +02:00
parent dd0e867108
commit 5c90bcf940
8 changed files with 1529 additions and 1 deletions

View File

@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""Apply sql/organic_geo.sql via Dokploy supabase-db terminal."""
from __future__ import annotations
import base64
import os
import re
import sys
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path("/Users/4ventures/4ventures-client-portal")
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
SQL = ROOT / "sql" / "organic_geo.sql"
BASE = "https://dokploy.4ventures.es"
def env(key: str) -> str:
v = os.environ.get(key, "").strip()
if v:
return v
p = BPS / ".env"
if not p.is_file():
return ""
for line in p.read_text().splitlines():
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
return ""
def main() -> int:
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
password = env("DOKPLOY_PASSWORD")
b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode()
chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)]
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1400, "height": 1000})
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(800)
if "/dashboard" not in page.url:
page.locator('input[type="email"], input[name="email"]').first.fill(email)
page.locator('input[type="password"]').first.fill(password)
page.locator('button[type="submit"]').first.click()
page.wait_for_url(re.compile(r"/dashboard"), timeout=90000)
print("login ok", flush=True)
page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000)
page.wait_for_timeout(800)
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill(
"supabase-db"
)
page.wait_for_timeout(1000)
page.keyboard.press("Escape")
page.wait_for_timeout(200)
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
page.wait_for_timeout(400)
page.get_by_text("Terminal", exact=True).click()
page.wait_for_timeout(1000)
if page.locator("button", has_text="Bash").count():
page.locator("button", has_text="Bash").first.click()
page.wait_for_timeout(2800)
if page.locator("textarea.xterm-helper-textarea").count():
page.locator("textarea.xterm-helper-textarea").first.click()
def run(cmd: str, wait: int = 800) -> None:
page.keyboard.type(cmd, delay=1)
page.keyboard.press("Enter")
page.wait_for_timeout(wait)
run("rm -f /tmp/og.b64 /tmp/og.sql; echo READY", 1200)
for i, ch in enumerate(chunks):
op = ">" if i == 0 else ">>"
run(f"printf '%s' '{ch}' {op} /tmp/og.b64", 400)
run(
"base64 -d /tmp/og.b64 > /tmp/og.sql && "
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/og.sql; echo EXIT:$?",
8000,
)
text = page.evaluate(
"() => (document.querySelector('.xterm-rows')||{}).innerText||''"
)
browser.close()
ok = "EXIT:0" in text
print("SQL_OK", ok, flush=True)
return 0 if ok else 6
if __name__ == "__main__":
raise SystemExit(main())

75
sql/organic_geo.sql Normal file
View File

@@ -0,0 +1,75 @@
-- Organic SEO (GA4/GSC) + GEO (AI visibility) warehouse
-- Idempotent.
create table if not exists public.organic_daily (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
day date not null,
gsc_clicks numeric not null default 0,
gsc_impressions numeric not null default 0,
gsc_ctr numeric,
gsc_avg_position numeric,
ga4_sessions numeric not null default 0,
ga4_users numeric not null default 0,
ga4_organic_sessions numeric not null default 0,
ga4_conversions numeric not null default 0,
source text not null default 'manual'
check (source in ('manual', 'sync', 'import')),
meta jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
unique (tenant_id, day)
);
create index if not exists organic_daily_tenant_day_idx
on public.organic_daily (tenant_id, day desc);
create table if not exists public.geo_snapshots (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
label text not null default 'Medición GEO',
period_start date,
period_end date,
-- Share of Model: % de primeras respuestas / menciones de la marca
share_of_model numeric,
-- Engines: { chatgpt, gemini, ai_overviews } visibility %
engines jsonb not null default '{}'::jsonb,
-- Official brand site citation share among sources
official_citation_pct numeric,
-- Sentiment -1..1 or 0..100 (store 0..100)
sentiment_score numeric,
-- Competitor share of model rows: [{ brand, share, rank }]
competitors jsonb not null default '[]'::jsonb,
-- Intent / category visibility: [{ category, visibility, rank }]
categories jsonb not null default '[]'::jsonb,
-- Top sources feeding AI answers
top_sources jsonb not null default '[]'::jsonb,
prompts_analyzed integer,
responses_analyzed integer,
study_url text,
notes text,
created_at timestamptz not null default now()
);
create index if not exists geo_snapshots_tenant_created_idx
on public.geo_snapshots (tenant_id, created_at desc);
alter table public.organic_daily enable row level security;
alter table public.geo_snapshots enable row level security;
drop policy if exists organic_daily_tenant_select on public.organic_daily;
create policy organic_daily_tenant_select on public.organic_daily
for select using (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
);
drop policy if exists geo_snapshots_tenant_select on public.geo_snapshots;
create policy geo_snapshots_tenant_select on public.geo_snapshots
for select using (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
);

View File

@@ -0,0 +1,266 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import {
isAgencyRole,
serviceHeaders,
supabaseUrl,
userRole,
} from '@/lib/agency';
import { resolveActiveTenant } from '@/lib/tenant-context';
import type { GeoSnapshot, OrganicDailyRow } from '@/lib/organic';
export const dynamic = 'force-dynamic';
async function gate() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return { error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) };
}
const ctx = await resolveActiveTenant(user);
const agency = isAgencyRole(userRole(user));
return { user, ctx, agency, headers: serviceHeaders() };
}
/** GET — organic series + latest GEO snapshot + integration status for active tenant. */
export async function GET(req: NextRequest) {
const g = await gate();
if ('error' in g && g.error) return g.error;
const { ctx, agency, headers } = g as Awaited<ReturnType<typeof gate>> & {
ctx: { tenantId: string | null; portfolioWide: boolean; label: string };
agency: boolean;
headers: ReturnType<typeof serviceHeaders>;
};
if (ctx.portfolioWide || !ctx.tenantId) {
return NextResponse.json({
portfolioWide: true,
organic: [] as OrganicDailyRow[],
geo: [] as GeoSnapshot[],
integrations: [],
warning: 'Selecciona un cliente en Contexto para ver Orgánico.',
});
}
const tid = ctx.tenantId;
const since = req.nextUrl.searchParams.get('since') || '';
const until = req.nextUrl.searchParams.get('until') || '';
if (agency && headers) {
const base = supabaseUrl();
const dayFilter =
since && until
? `&day=gte.${since}&day=lte.${until}`
: '';
const [oRes, gRes, iRes] = await Promise.all([
fetch(
`${base}/rest/v1/organic_daily?tenant_id=eq.${encodeURIComponent(tid)}&select=*&order=day.asc${dayFilter}`,
{ headers, cache: 'no-store' }
),
fetch(
`${base}/rest/v1/geo_snapshots?tenant_id=eq.${encodeURIComponent(tid)}&select=*&order=created_at.desc&limit=12`,
{ headers, cache: 'no-store' }
),
fetch(
`${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tid)}&provider=in.(ga4,search_console,tag_manager)&select=provider,status,enabled,external_account_id,last_sync_at,error_message`,
{ headers, cache: 'no-store' }
),
]);
const warningParts: string[] = [];
let organic: OrganicDailyRow[] = [];
let geo: GeoSnapshot[] = [];
let integrations: unknown[] = [];
if (oRes.ok) organic = await oRes.json();
else {
const t = await oRes.text();
warningParts.push(
t.includes('organic_daily')
? 'Aplicar sql/organic_geo.sql'
: t.slice(0, 120)
);
}
if (gRes.ok) geo = await gRes.json();
else {
const t = await gRes.text();
if (t.includes('geo_snapshots')) {
warningParts.push('Aplicar sql/organic_geo.sql (GEO)');
}
}
if (iRes.ok) integrations = await iRes.json();
return NextResponse.json({
portfolioWide: false,
label: ctx.label,
tenantId: tid,
organic,
geo,
integrations,
warning: warningParts[0] || null,
});
}
const supabase = await createClient();
let organicQ = supabase
.from('organic_daily')
.select('*')
.order('day', { ascending: true });
if (since) organicQ = organicQ.gte('day', since);
if (until) organicQ = organicQ.lte('day', until);
const [o, ge, integ] = await Promise.all([
organicQ,
supabase
.from('geo_snapshots')
.select('*')
.order('created_at', { ascending: false })
.limit(12),
supabase
.from('integrations')
.select(
'provider,status,enabled,external_account_id,last_sync_at,error_message'
)
.in('provider', ['ga4', 'search_console', 'tag_manager']),
]);
return NextResponse.json({
portfolioWide: false,
label: ctx.label,
tenantId: tid,
organic: o.data || [],
geo: ge.data || [],
integrations: integ.data || [],
warning: o.error?.message || ge.error?.message || null,
});
}
/** POST — agency: upsert organic day OR create GEO snapshot. */
export async function POST(req: NextRequest) {
const g = await gate();
if ('error' in g && g.error) return g.error;
const { ctx, agency, headers } = g as Awaited<ReturnType<typeof gate>> & {
ctx: { tenantId: string | null };
agency: boolean;
headers: ReturnType<typeof serviceHeaders>;
};
if (!agency) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
if (!headers) {
return NextResponse.json({ error: 'no service role' }, { status: 503 });
}
if (!ctx.tenantId) {
return NextResponse.json(
{ error: 'Selecciona un cliente en Contexto' },
{ status: 400 }
);
}
const body = await req.json().catch(() => ({}));
const kind = body.kind === 'geo' ? 'geo' : 'organic';
const base = supabaseUrl();
if (kind === 'organic') {
const day = typeof body.day === 'string' ? body.day.slice(0, 10) : '';
if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) {
return NextResponse.json({ error: 'day YYYY-MM-DD requerido' }, { status: 400 });
}
const row = {
tenant_id: ctx.tenantId,
day,
gsc_clicks: Number(body.gsc_clicks) || 0,
gsc_impressions: Number(body.gsc_impressions) || 0,
gsc_ctr:
body.gsc_ctr != null
? Number(body.gsc_ctr)
: Number(body.gsc_impressions) > 0
? (100 * Number(body.gsc_clicks || 0)) /
Number(body.gsc_impressions)
: null,
gsc_avg_position:
body.gsc_avg_position != null ? Number(body.gsc_avg_position) : null,
ga4_sessions: Number(body.ga4_sessions) || 0,
ga4_users: Number(body.ga4_users) || 0,
ga4_organic_sessions: Number(body.ga4_organic_sessions) || 0,
ga4_conversions: Number(body.ga4_conversions) || 0,
source: 'manual',
};
const r = await fetch(
`${base}/rest/v1/organic_daily?on_conflict=tenant_id,day`,
{
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'resolution=merge-duplicates,return=representation',
},
body: JSON.stringify(row),
}
);
if (!r.ok) {
const msg = await r.text();
return NextResponse.json(
{
error: msg.includes('organic_daily')
? 'Aplicar sql/organic_geo.sql'
: msg.slice(0, 300),
},
{ status: 500 }
);
}
return NextResponse.json({ row: (await r.json())[0] });
}
// GEO snapshot
const snapshot = {
tenant_id: ctx.tenantId,
label:
typeof body.label === 'string' && body.label.trim()
? body.label.trim()
: 'Medición GEO',
period_start: body.period_start || null,
period_end: body.period_end || null,
share_of_model:
body.share_of_model != null ? Number(body.share_of_model) : null,
engines: body.engines && typeof body.engines === 'object' ? body.engines : {},
official_citation_pct:
body.official_citation_pct != null
? Number(body.official_citation_pct)
: null,
sentiment_score:
body.sentiment_score != null ? Number(body.sentiment_score) : null,
competitors: Array.isArray(body.competitors) ? body.competitors : [],
categories: Array.isArray(body.categories) ? body.categories : [],
top_sources: Array.isArray(body.top_sources) ? body.top_sources : [],
prompts_analyzed:
body.prompts_analyzed != null ? Number(body.prompts_analyzed) : null,
responses_analyzed:
body.responses_analyzed != null ? Number(body.responses_analyzed) : null,
study_url: typeof body.study_url === 'string' ? body.study_url : null,
notes: typeof body.notes === 'string' ? body.notes : null,
};
const r = await fetch(`${base}/rest/v1/geo_snapshots`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=representation',
},
body: JSON.stringify(snapshot),
});
if (!r.ok) {
const msg = await r.text();
return NextResponse.json(
{
error: msg.includes('geo_snapshots')
? 'Aplicar sql/organic_geo.sql'
: msg.slice(0, 300),
},
{ status: 500 }
);
}
return NextResponse.json({ snapshot: (await r.json())[0] });
}

View File

@@ -0,0 +1,28 @@
import React from 'react';
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
import OrganicoDashboard from '@/components/OrganicoDashboard';
import { resolveActiveTenant } from '@/lib/tenant-context';
import { isAgencyRole, userRole } from '@/lib/agency';
export const dynamic = 'force-dynamic';
export default async function OrganicoPage() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) redirect('/login');
const ctx = await resolveActiveTenant(user);
const agency = isAgencyRole(userRole(user));
return (
<OrganicoDashboard
label={ctx.label}
portfolioWide={ctx.portfolioWide}
isAgency={agency}
tenantId={ctx.tenantId}
/>
);
}

View File

@@ -19,6 +19,7 @@ import {
Contact,
Menu,
X,
Leaf,
} from 'lucide-react';
import TenantSwitcher from '@/components/TenantSwitcher';
import type { DashboardModuleId } from '@/lib/tenant-modules';
@@ -74,6 +75,13 @@ const NAV_ITEMS: NavItem[] = [
iconClass: 'text-violet-500',
moduleId: 'ad-tracker',
},
{
href: '/dashboard/organico',
label: 'Orgánico',
icon: Leaf,
iconClass: 'text-lime-600',
moduleId: 'organico',
},
{
href: '/dashboard/activos',
label: 'Biblioteca de Activos',

View File

@@ -0,0 +1,885 @@
'use client';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import {
Bot,
ExternalLink,
Loader2,
Plus,
Search,
Sparkles,
TrendingUp,
} from 'lucide-react';
import { PageHeader, SegmentedControl } from '@/components/ui';
import {
GEO_ENGINES,
SEO_FOUNDATIONS,
GeoSnapshot,
IntegrationLite,
OrganicDailyRow,
formatNum,
formatPct,
sumOrganic,
} from '@/lib/organic';
import { isoYesterday } from '@/lib/ad-tracker-range';
type Tab = 'seo' | 'geo';
type Props = {
label: string;
portfolioWide: boolean;
isAgency: boolean;
tenantId: string | null;
};
function connBadge(status?: string) {
if (status === 'connected')
return 'bg-emerald-50 text-emerald-800 border-emerald-200';
if (status === 'error') return 'bg-red-50 text-red-800 border-red-200';
if (status === 'pending') return 'bg-amber-50 text-amber-900 border-amber-200';
return 'bg-gray-50 text-gray-600 border-gray-200';
}
export default function OrganicoDashboard({
label,
portfolioWide,
isAgency,
tenantId,
}: Props) {
const [tab, setTab] = useState<Tab>('seo');
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 [showGeoForm, setShowGeoForm] = useState(false);
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [geoForm, setGeoForm] = useState({
label: 'Medición GEO',
share_of_model: '',
chatgpt: '',
gemini: '',
ai_overviews: '',
official_citation_pct: '',
sentiment_score: '',
prompts_analyzed: '',
responses_analyzed: '',
study_url: '',
notes: '',
competitors: 'Marca A, 40\nMarca B, 25\nTu marca, 12',
categories: 'General, 15\nCompra, 10\nComparativa, 8',
});
const [seoForm, setSeoForm] = useState({
day: isoYesterday(),
gsc_clicks: '',
gsc_impressions: '',
gsc_avg_position: '',
ga4_organic_sessions: '',
ga4_sessions: '',
ga4_users: '',
ga4_conversions: '',
});
const load = useCallback(async () => {
setLoading(true);
const res = await fetch('/api/organico');
const data = await res.json();
setOrganic(data.organic || []);
setGeo(data.geo || []);
setIntegrations(data.integrations || []);
setWarning(data.warning || null);
setLoading(false);
}, []);
useEffect(() => {
void load();
}, [load]);
const totals = useMemo(() => sumOrganic(organic), [organic]);
const latestGeo = geo[0] || null;
const byProvider = useMemo(() => {
const m = new Map<string, IntegrationLite>();
for (const i of integrations) m.set(i.provider, i);
return m;
}, [integrations]);
const ga4 = byProvider.get('ga4');
const gsc = byProvider.get('search_console');
async function saveOrganicDay() {
setSaving(true);
setMsg(null);
const res = await fetch('/api/organico', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
kind: 'organic',
...seoForm,
gsc_clicks: Number(seoForm.gsc_clicks) || 0,
gsc_impressions: Number(seoForm.gsc_impressions) || 0,
gsc_avg_position: seoForm.gsc_avg_position
? Number(seoForm.gsc_avg_position)
: null,
ga4_organic_sessions: Number(seoForm.ga4_organic_sessions) || 0,
ga4_sessions: Number(seoForm.ga4_sessions) || 0,
ga4_users: Number(seoForm.ga4_users) || 0,
ga4_conversions: Number(seoForm.ga4_conversions) || 0,
}),
});
const data = await res.json();
setSaving(false);
if (!res.ok) {
setMsg(data.error || 'Error');
return;
}
setMsg('Día SEO guardado');
await load();
}
function parsePairs(
raw: string
): { name: string; value: number }[] {
return raw
.split('\n')
.map((l) => l.trim())
.filter(Boolean)
.map((l) => {
const [name, num] = l.split(',').map((s) => s.trim());
return { name: name || '', value: Number(num) || 0 };
})
.filter((x) => x.name);
}
async function saveGeo() {
setSaving(true);
setMsg(null);
const comps = parsePairs(geoForm.competitors).map((x, i) => ({
brand: x.name!,
share: x.value,
rank: i + 1,
}));
const cats = parsePairs(geoForm.categories).map((x, i) => ({
category: x.name!,
visibility: x.value,
rank: i + 1,
}));
const res = await fetch('/api/organico', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
kind: 'geo',
label: geoForm.label,
share_of_model: geoForm.share_of_model
? Number(geoForm.share_of_model)
: null,
engines: {
chatgpt: geoForm.chatgpt ? Number(geoForm.chatgpt) : undefined,
gemini: geoForm.gemini ? Number(geoForm.gemini) : undefined,
ai_overviews: geoForm.ai_overviews
? Number(geoForm.ai_overviews)
: undefined,
},
official_citation_pct: geoForm.official_citation_pct
? Number(geoForm.official_citation_pct)
: null,
sentiment_score: geoForm.sentiment_score
? Number(geoForm.sentiment_score)
: null,
prompts_analyzed: geoForm.prompts_analyzed
? Number(geoForm.prompts_analyzed)
: null,
responses_analyzed: geoForm.responses_analyzed
? Number(geoForm.responses_analyzed)
: null,
study_url: geoForm.study_url || null,
notes: geoForm.notes || null,
competitors: comps,
categories: cats,
}),
});
const data = await res.json();
setSaving(false);
if (!res.ok) {
setMsg(data.error || 'Error');
return;
}
setMsg('Medición GEO registrada');
setShowGeoForm(false);
await load();
}
if (portfolioWide) {
return (
<div className="space-y-6">
<PageHeader
eyebrow="Crecimiento"
title="Orgánico"
description="SEO (GA4 + Search Console) y GEO (visibilidad en IA). Elige un cliente en Contexto."
/>
<p className="text-sm text-gray-600 border border-gray-200 rounded-xl px-4 py-8 text-center bg-gray-50">
Selecciona un cliente para ver su ficha orgánica.
</p>
</div>
);
}
if (loading) {
return (
<div className="flex items-center justify-center gap-2 py-20 text-sm text-gray-600">
<Loader2 className="w-4 h-4 animate-spin" /> Cargando orgánico…
</div>
);
}
return (
<div className="space-y-6">
<PageHeader
eyebrow="Crecimiento"
title="Orgánico"
description={`${label} · SEO clásico + GEO (recomendación en ChatGPT, Gemini y AI Overviews).`}
actions={
<SegmentedControl
value={tab}
onChange={setTab}
size="sm"
options={[
{ id: 'seo', label: 'SEO' },
{ id: 'geo', label: 'GEO · IA' },
]}
/>
}
/>
{warning && (
<p className="text-xs text-amber-900 border border-amber-300 bg-amber-50 rounded-lg px-3 py-2">
{warning}
</p>
)}
{msg && (
<p className="text-xs text-emerald-900 border border-emerald-300 bg-emerald-50 rounded-lg px-3 py-2">
{msg}
</p>
)}
{/* Connection strip */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{[
{ key: 'search_console', title: 'Search Console', row: gsc },
{ key: 'ga4', title: 'GA4', row: ga4 },
{
key: 'geo',
title: 'GEO / IA',
row: latestGeo
? { status: 'connected', external_account_id: latestGeo.label }
: { status: 'disconnected' },
},
].map((c) => (
<div
key={c.key}
className="rounded-xl border border-gray-200 bg-white px-4 py-3 shadow-sm"
>
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-semibold text-gray-700">{c.title}</p>
<span
className={`text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-full border ${connBadge(c.row?.status)}`}
>
{c.row?.status === 'connected' ? 'ON' : 'OFF'}
</span>
</div>
<p className="text-sm text-gray-900 mt-1 truncate font-medium">
{c.row?.external_account_id || 'Sin cuenta vinculada'}
</p>
{isAgency && tenantId && c.key !== 'geo' ? (
<Link
href={`/dashboard/agencia/cliente/${tenantId}`}
className="inline-flex items-center gap-1 text-[11px] font-semibold text-brand-700 mt-2 hover:underline"
>
Conectar en ficha <ExternalLink className="w-3 h-3" />
</Link>
) : null}
</div>
))}
</div>
{tab === 'seo' && (
<div className="space-y-6">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{[
{
label: 'Clics GSC',
value: formatNum(totals.gscClicks),
hint: 'Search Console',
},
{
label: 'Impresiones',
value: formatNum(totals.gscImpressions),
hint: 'Visibilidad SERP',
},
{
label: 'CTR medio',
value: formatPct(totals.gscCtr),
hint: 'Clics / impresiones',
},
{
label: 'Posición media',
value: totals.gscAvgPosition
? formatNum(totals.gscAvgPosition, 1)
: '—',
hint: 'Cuanto más bajo, mejor',
},
{
label: 'Sesiones orgánicas',
value: formatNum(totals.ga4Organic),
hint: 'GA4 · organic',
},
{
label: 'Usuarios',
value: formatNum(totals.ga4Users),
hint: 'GA4',
},
{
label: 'Sesiones totales',
value: formatNum(totals.ga4Sessions),
hint: 'GA4',
},
{
label: 'Conversiones',
value: formatNum(totals.ga4Conversions),
hint: 'GA4',
},
].map((k) => (
<div
key={k.label}
className="rounded-xl border border-gray-200 bg-white px-4 py-3 shadow-sm"
>
<p className="text-[11px] font-semibold uppercase tracking-wider text-gray-600">
{k.label}
</p>
<p className="text-xl font-bold text-gray-900 mt-1 tabular-nums">
{k.value}
</p>
<p className="text-[11px] text-gray-500 mt-0.5">{k.hint}</p>
</div>
))}
</div>
{!organic.length && (
<div className="rounded-xl border border-dashed border-gray-300 bg-gray-50 px-5 py-6">
<div className="flex items-start gap-3">
<Search className="w-5 h-5 text-brand-600 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-semibold text-gray-900">
Sin serie SEO todavía
</p>
<p className="text-xs text-gray-600 mt-1 max-w-2xl leading-relaxed">
Como en el{' '}
<a
href="https://www.pixelclip.es/casos-de-exito/caso-de-exito-vivegreen"
target="_blank"
rel="noreferrer"
className="text-brand-700 font-semibold hover:underline"
>
caso Vivegreen
</a>
: primero cimientos técnicos, luego curva de clics e
impresiones. Conecta GSC/GA4 en la ficha; la agencia puede
cargar días manualmente hasta el sync automático.
</p>
</div>
</div>
</div>
)}
{organic.length > 0 && (
<section className="rounded-xl border border-gray-200 bg-white p-5 shadow-sm overflow-x-auto">
<h3 className="text-sm font-semibold text-gray-900 mb-3">
Serie diaria
</h3>
<table className="w-full text-left text-sm">
<thead className="text-gray-600 border-b border-gray-200">
<tr>
<th className="py-2 pr-3 font-semibold">Día</th>
<th className="py-2 pr-3 font-semibold">Clics</th>
<th className="py-2 pr-3 font-semibold">Impr.</th>
<th className="py-2 pr-3 font-semibold">CTR</th>
<th className="py-2 pr-3 font-semibold">Pos.</th>
<th className="py-2 pr-3 font-semibold">Org. GA4</th>
<th className="py-2 font-semibold">Conv.</th>
</tr>
</thead>
<tbody>
{[...organic].reverse().slice(0, 28).map((r) => {
const day = (r.day || r.date || '').slice(0, 10);
const clicks = Number(r.gsc_clicks) || 0;
const impr = Number(r.gsc_impressions) || 0;
const ctr =
r.gsc_ctr != null
? Number(r.gsc_ctr)
: impr > 0
? (100 * clicks) / impr
: 0;
return (
<tr
key={day}
className="border-b border-gray-100 hover:bg-gray-50"
>
<td className="py-2 pr-3 font-medium text-gray-900">
{day}
</td>
<td className="py-2 pr-3 tabular-nums">{formatNum(clicks)}</td>
<td className="py-2 pr-3 tabular-nums">{formatNum(impr)}</td>
<td className="py-2 pr-3 tabular-nums">{formatPct(ctr)}</td>
<td className="py-2 pr-3 tabular-nums">
{r.gsc_avg_position != null
? formatNum(Number(r.gsc_avg_position), 1)
: '—'}
</td>
<td className="py-2 pr-3 tabular-nums">
{formatNum(Number(r.ga4_organic_sessions) || 0)}
</td>
<td className="py-2 tabular-nums">
{formatNum(Number(r.ga4_conversions) || 0)}
</td>
</tr>
);
})}
</tbody>
</table>
</section>
)}
<section className="rounded-xl border border-gray-200 bg-white p-5 shadow-sm space-y-3">
<h3 className="text-sm font-semibold text-gray-900 flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-brand-600" />
Cimientos SEO (seguimiento)
</h3>
<p className="text-xs text-gray-600">
Checklist inspirado en SEO técnico desde cero: indexación,
arquitectura y medición antes de escalar contenido.
</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-gray-50'
}`}
>
<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>
</section>
{isAgency && (
<section className="rounded-xl border border-brand-200 bg-brand-50/40 p-5 space-y-3">
<h3 className="text-sm font-semibold text-gray-900">
Cargar día (manual · hasta sync GA4/GSC)
</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{(
[
['day', 'Día', 'date'],
['gsc_clicks', 'Clics GSC', 'number'],
['gsc_impressions', 'Impresiones', 'number'],
['gsc_avg_position', 'Posición media', 'number'],
['ga4_organic_sessions', 'Sesiones orgánicas', 'number'],
['ga4_sessions', 'Sesiones', 'number'],
['ga4_users', 'Usuarios', 'number'],
['ga4_conversions', 'Conversiones', 'number'],
] as const
).map(([key, lbl, type]) => (
<label key={key} className="block space-y-1">
<span className="text-[10px] font-semibold uppercase text-gray-600">
{lbl}
</span>
<input
type={type}
className="w-full rounded-lg border border-gray-200 bg-white px-2.5 py-1.5 text-sm"
value={seoForm[key]}
onChange={(e) =>
setSeoForm((s) => ({ ...s, [key]: e.target.value }))
}
/>
</label>
))}
</div>
<button
type="button"
disabled={saving}
onClick={() => void saveOrganicDay()}
className="inline-flex items-center gap-1.5 text-sm font-semibold px-4 py-2 rounded-lg bg-brand-500 text-white hover:bg-brand-600 disabled:opacity-50"
>
{saving ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Plus className="w-4 h-4" />
)}
Guardar día
</button>
</section>
)}
</div>
)}
{tab === 'geo' && (
<div className="space-y-6">
<div className="rounded-xl border border-gray-200 bg-white p-5 shadow-sm space-y-2">
<div className="flex items-start gap-3">
<Bot className="w-5 h-5 text-brand-600 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-semibold text-gray-900">
Visibilidad en IA (GEO)
</p>
<p className="text-xs text-gray-600 mt-1 max-w-3xl leading-relaxed">
Framework alineado con estudios tipo{' '}
<a
href="https://www.pixelclip.es/casos-de-exito/benchmark-de-visibilidad-en-ia-protectores-solares-en-espana-2026"
target="_blank"
rel="noreferrer"
className="text-brand-700 font-semibold hover:underline"
>
Pixelclip · Share of Model
</a>
: qué marca recomienda ChatGPT / Gemini / AI Overviews, cuota
de citas a web oficial y ranking por intención.
</p>
</div>
</div>
</div>
{latestGeo ? (
<>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{[
{
label: 'Share of Model',
value:
latestGeo.share_of_model != null
? formatPct(Number(latestGeo.share_of_model))
: '—',
hint: '% menciones / 1ª respuesta',
},
{
label: 'Citas web oficial',
value:
latestGeo.official_citation_pct != null
? formatPct(Number(latestGeo.official_citation_pct))
: '—',
hint: 'Fuentes → dominio marca',
},
{
label: 'Sentimiento',
value:
latestGeo.sentiment_score != null
? formatNum(Number(latestGeo.sentiment_score), 0)
: '—',
hint: '0–100',
},
{
label: 'Muestra',
value:
latestGeo.prompts_analyzed != null
? formatNum(Number(latestGeo.prompts_analyzed))
: '—',
hint:
latestGeo.responses_analyzed != null
? `${formatNum(Number(latestGeo.responses_analyzed))} respuestas`
: 'Prompts analizados',
},
].map((k) => (
<div
key={k.label}
className="rounded-xl border border-brand-200 bg-brand-50 px-4 py-3 shadow-sm"
>
<p className="text-[11px] font-semibold uppercase tracking-wider text-brand-700">
{k.label}
</p>
<p className="text-xl font-bold text-gray-900 mt-1 tabular-nums">
{k.value}
</p>
<p className="text-[11px] text-gray-600 mt-0.5">{k.hint}</p>
</div>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
{GEO_ENGINES.map((eng) => {
const v = Number(
(latestGeo.engines || {})[
eng.id as keyof typeof latestGeo.engines
] ?? NaN
);
return (
<div
key={eng.id}
className="rounded-xl border border-gray-200 bg-white px-4 py-4 shadow-sm"
>
<p className="text-xs font-semibold text-gray-600">
{eng.label}
</p>
<p className="text-2xl font-bold text-gray-900 mt-1 tabular-nums">
{Number.isFinite(v) ? formatPct(v) : '—'}
</p>
<div className="mt-3 h-2 rounded-full bg-gray-100 overflow-hidden">
<div
className="h-full bg-brand-500 rounded-full"
style={{
width: `${Number.isFinite(v) ? Math.min(100, v) : 0}%`,
}}
/>
</div>
</div>
);
})}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<section className="rounded-xl border border-gray-200 bg-white p-5 shadow-sm">
<h3 className="text-sm font-semibold text-gray-900 mb-3">
Ranking Share of Model
</h3>
{(latestGeo.competitors || []).length === 0 ? (
<p className="text-xs text-gray-500">Sin competidores.</p>
) : (
<ul className="space-y-2">
{(latestGeo.competitors || []).map((c, i) => (
<li
key={`${c.brand}-${i}`}
className="flex items-center gap-3"
>
<span className="text-xs font-bold text-gray-500 w-5">
{c.rank || i + 1}
</span>
<div className="flex-1 min-w-0">
<div className="flex justify-between gap-2 text-sm">
<span className="font-semibold text-gray-900 truncate">
{c.brand}
</span>
<span className="tabular-nums text-gray-700 font-medium">
{formatPct(Number(c.share))}
</span>
</div>
<div className="mt-1 h-1.5 rounded-full bg-gray-100 overflow-hidden">
<div
className="h-full bg-brand-500"
style={{
width: `${Math.min(100, Number(c.share) || 0)}%`,
}}
/>
</div>
</div>
</li>
))}
</ul>
)}
</section>
<section className="rounded-xl border border-gray-200 bg-white p-5 shadow-sm">
<h3 className="text-sm font-semibold text-gray-900 mb-3">
Por intención / categoría
</h3>
{(latestGeo.categories || []).length === 0 ? (
<p className="text-xs text-gray-500">Sin categorías.</p>
) : (
<ul className="space-y-2">
{(latestGeo.categories || []).map((c, i) => (
<li
key={`${c.category}-${i}`}
className="flex items-center justify-between gap-3 text-sm border-b border-gray-100 pb-2"
>
<span className="font-medium text-gray-900">
{c.category}
</span>
<span className="tabular-nums font-semibold text-brand-700">
{formatPct(Number(c.visibility))}
</span>
</li>
))}
</ul>
)}
</section>
</div>
{latestGeo.notes || latestGeo.study_url ? (
<p className="text-xs text-gray-600">
{latestGeo.notes}{' '}
{latestGeo.study_url ? (
<a
href={latestGeo.study_url}
target="_blank"
rel="noreferrer"
className="text-brand-700 font-semibold hover:underline"
>
Ver estudio
</a>
) : null}
</p>
) : null}
{geo.length > 1 && (
<p className="text-[11px] text-gray-500">
Historial: {geo.length} mediciones · última{' '}
{latestGeo.created_at
? new Date(latestGeo.created_at).toLocaleDateString('es-ES')
: ''}
</p>
)}
</>
) : (
<div className="rounded-xl border border-dashed border-gray-300 bg-gray-50 px-5 py-8 text-center space-y-2">
<Sparkles className="w-6 h-6 text-brand-600 mx-auto" />
<p className="text-sm font-semibold text-gray-900">
Aún no hay medición GEO
</p>
<p className="text-xs text-gray-600 max-w-lg mx-auto">
Registra Share of Model, motores (ChatGPT / Gemini / AI
Overviews) y ranking competitivo cuando tengas un estudio o
muestra de prompts.
</p>
</div>
)}
{isAgency && (
<div className="space-y-3">
<button
type="button"
onClick={() => setShowGeoForm((v) => !v)}
className="inline-flex items-center gap-1.5 text-sm font-semibold px-4 py-2 rounded-lg bg-brand-500 text-white hover:bg-brand-600"
>
<Plus className="w-4 h-4" />
{showGeoForm ? 'Cerrar formulario' : 'Nueva medición GEO'}
</button>
{showGeoForm && (
<section className="rounded-xl border border-brand-200 bg-white p-5 space-y-3 shadow-sm">
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
<label className="block space-y-1 col-span-2">
<span className="text-[10px] font-semibold uppercase text-gray-600">
Etiqueta
</span>
<input
className="w-full rounded-lg border border-gray-200 px-2.5 py-1.5 text-sm"
value={geoForm.label}
onChange={(e) =>
setGeoForm((s) => ({ ...s, label: e.target.value }))
}
/>
</label>
{(
[
['share_of_model', 'Share of Model %'],
['official_citation_pct', 'Citas oficial %'],
['sentiment_score', 'Sentimiento 0–100'],
['chatgpt', 'ChatGPT %'],
['gemini', 'Gemini %'],
['ai_overviews', 'AI Overviews %'],
['prompts_analyzed', 'Prompts'],
['responses_analyzed', 'Respuestas'],
] as const
).map(([key, lbl]) => (
<label key={key} className="block space-y-1">
<span className="text-[10px] font-semibold uppercase text-gray-600">
{lbl}
</span>
<input
type="number"
className="w-full rounded-lg border border-gray-200 px-2.5 py-1.5 text-sm"
value={geoForm[key]}
onChange={(e) =>
setGeoForm((s) => ({ ...s, [key]: e.target.value }))
}
/>
</label>
))}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<label className="block space-y-1">
<span className="text-[10px] font-semibold uppercase text-gray-600">
Competidores (marca, share%)
</span>
<textarea
className="w-full rounded-lg border border-gray-200 px-2.5 py-1.5 text-sm min-h-[88px] font-mono"
value={geoForm.competitors}
onChange={(e) =>
setGeoForm((s) => ({
...s,
competitors: e.target.value,
}))
}
/>
</label>
<label className="block space-y-1">
<span className="text-[10px] font-semibold uppercase text-gray-600">
Categorías (nombre, visibilidad%)
</span>
<textarea
className="w-full rounded-lg border border-gray-200 px-2.5 py-1.5 text-sm min-h-[88px] font-mono"
value={geoForm.categories}
onChange={(e) =>
setGeoForm((s) => ({
...s,
categories: e.target.value,
}))
}
/>
</label>
</div>
<label className="block space-y-1">
<span className="text-[10px] font-semibold uppercase text-gray-600">
URL estudio / notas
</span>
<input
className="w-full rounded-lg border border-gray-200 px-2.5 py-1.5 text-sm"
placeholder="https://…"
value={geoForm.study_url}
onChange={(e) =>
setGeoForm((s) => ({ ...s, study_url: e.target.value }))
}
/>
<textarea
className="w-full rounded-lg border border-gray-200 px-2.5 py-1.5 text-sm mt-2"
placeholder="Notas internas"
value={geoForm.notes}
onChange={(e) =>
setGeoForm((s) => ({ ...s, notes: e.target.value }))
}
/>
</label>
<button
type="button"
disabled={saving}
onClick={() => void saveGeo()}
className="inline-flex items-center gap-1.5 text-sm font-semibold px-4 py-2 rounded-lg bg-brand-500 text-white hover:bg-brand-600 disabled:opacity-50"
>
{saving ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Sparkles className="w-4 h-4" />
)}
Guardar medición GEO
</button>
</section>
)}
</div>
)}
</div>
)}
</div>
);
}

167
src/lib/organic.ts Normal file
View File

@@ -0,0 +1,167 @@
/** Types + helpers for SEO (GA4/GSC) and GEO (AI visibility). */
export type OrganicDailyRow = {
day?: string;
date?: string;
gsc_clicks?: number;
gsc_impressions?: number;
gsc_ctr?: number | null;
gsc_avg_position?: number | null;
ga4_sessions?: number;
ga4_users?: number;
ga4_organic_sessions?: number;
ga4_conversions?: number;
source?: string;
};
export type GeoEngineScores = {
chatgpt?: number;
gemini?: number;
ai_overviews?: number;
};
export type GeoCompetitor = {
brand: string;
share: number;
rank?: number;
};
export type GeoCategory = {
category: string;
visibility: number;
rank?: number;
};
export type GeoSource = {
name: string;
share: number;
kind?: 'official' | 'pharmacy' | 'media' | 'other';
};
export type GeoSnapshot = {
id?: string;
tenant_id?: string;
label?: string;
period_start?: string | null;
period_end?: string | null;
share_of_model?: number | null;
engines?: GeoEngineScores;
official_citation_pct?: number | null;
sentiment_score?: number | null;
competitors?: GeoCompetitor[];
categories?: GeoCategory[];
top_sources?: GeoSource[];
prompts_analyzed?: number | null;
responses_analyzed?: number | null;
study_url?: string | null;
notes?: string | null;
created_at?: string;
};
export type IntegrationLite = {
provider: string;
status?: string;
enabled?: boolean;
external_account_id?: string | null;
last_sync_at?: string | null;
error_message?: string | null;
};
function n(v: unknown): number {
const x = Number(v);
return Number.isFinite(x) ? x : 0;
}
export type OrganicTotals = {
gscClicks: number;
gscImpressions: number;
gscCtr: number;
gscAvgPosition: number;
ga4Sessions: number;
ga4Users: number;
ga4Organic: number;
ga4Conversions: number;
days: number;
};
export function sumOrganic(rows: OrganicDailyRow[]): OrganicTotals {
let clicks = 0;
let impr = 0;
let posW = 0;
let posN = 0;
let sessions = 0;
let users = 0;
let organic = 0;
let conv = 0;
for (const r of rows) {
clicks += n(r.gsc_clicks);
impr += n(r.gsc_impressions);
const pos = r.gsc_avg_position;
if (pos != null && Number.isFinite(Number(pos))) {
posW += Number(pos) * Math.max(1, n(r.gsc_impressions));
posN += Math.max(1, n(r.gsc_impressions));
}
sessions += n(r.ga4_sessions);
users += n(r.ga4_users);
organic += n(r.ga4_organic_sessions);
conv += n(r.ga4_conversions);
}
return {
gscClicks: clicks,
gscImpressions: impr,
gscCtr: impr > 0 ? (100 * clicks) / impr : 0,
gscAvgPosition: posN > 0 ? posW / posN : 0,
ga4Sessions: sessions,
ga4Users: users,
ga4Organic: organic,
ga4Conversions: conv,
days: rows.length,
};
}
export function formatNum(v: number, digits = 0): string {
return v.toLocaleString('es-ES', {
maximumFractionDigits: digits,
minimumFractionDigits: digits,
});
}
export function formatPct(v: number, digits = 1): string {
return `${formatNum(v, digits)}%`;
}
/** SEO technical health checklist (Vivegreen-style foundations). */
export const SEO_FOUNDATIONS = [
{
id: 'indexacion',
title: 'Indexación y rastreo',
hint: 'Sitemap, robots, casuísticas de URLs dinámicas',
},
{
id: 'arquitectura',
title: 'Arquitectura y enlazado interno',
hint: 'Distribuir autoridad hacia landings clave',
},
{
id: 'render',
title: 'Renderizado y Core Web Vitals',
hint: 'Velocidad, JS crítico, UX móvil',
},
{
id: 'contenido',
title: 'Contenido indexable',
hint: 'Guías, FAQs, fichas que la IA y Google puedan citar',
},
{
id: 'medicion',
title: 'Medición GA4 + GSC',
hint: 'Property + propiedad Search Console conectadas',
},
] as const;
/** GEO engines tracked (Pixelclip-style AI visibility). */
export const GEO_ENGINES = [
{ id: 'chatgpt', label: 'ChatGPT' },
{ id: 'gemini', label: 'Gemini' },
{ id: 'ai_overviews', label: 'Google AI Overviews' },
] as const;

View File

@@ -7,6 +7,7 @@ export type DashboardModuleId =
| 'estrategia'
| 'calendario'
| 'ad-tracker'
| 'organico'
| 'activos'
| 'creatives'
| 'asistente';
@@ -25,7 +26,7 @@ export const DASHBOARD_MODULES: DashboardModuleDef[] = [
id: 'kpis',
href: '/dashboard/kpis',
label: 'Métricas',
providers: ['meta_ads', 'google_ads', 'ga4'],
providers: ['meta_ads', 'google_ads'],
hint: 'KPIs de inversión y leads',
},
{
@@ -63,6 +64,13 @@ export const DASHBOARD_MODULES: DashboardModuleDef[] = [
providers: ['meta_ads', 'google_ads'],
hint: 'Creatividades Meta / Google',
},
{
id: 'organico',
href: '/dashboard/organico',
label: 'Orgánico',
providers: ['ga4', 'search_console'],
hint: 'SEO (GSC/GA4) + GEO (visibilidad IA)',
},
{
id: 'activos',
href: '/dashboard/activos',