diff --git a/scripts/dokploy_apply_organic_geo.py b/scripts/dokploy_apply_organic_geo.py new file mode 100644 index 0000000..753b0cb --- /dev/null +++ b/scripts/dokploy_apply_organic_geo.py @@ -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()) diff --git a/sql/organic_geo.sql b/sql/organic_geo.sql new file mode 100644 index 0000000..7d95db2 --- /dev/null +++ b/sql/organic_geo.sql @@ -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' + ) + ); diff --git a/src/app/api/organico/route.ts b/src/app/api/organico/route.ts new file mode 100644 index 0000000..cf4578f --- /dev/null +++ b/src/app/api/organico/route.ts @@ -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> & { + ctx: { tenantId: string | null; portfolioWide: boolean; label: string }; + agency: boolean; + headers: ReturnType; + }; + + 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> & { + ctx: { tenantId: string | null }; + agency: boolean; + headers: ReturnType; + }; + 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] }); +} diff --git a/src/app/dashboard/organico/page.tsx b/src/app/dashboard/organico/page.tsx new file mode 100644 index 0000000..0950ca5 --- /dev/null +++ b/src/app/dashboard/organico/page.tsx @@ -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 ( + + ); +} diff --git a/src/components/DashboardShell.tsx b/src/components/DashboardShell.tsx index 7300510..12fee66 100644 --- a/src/components/DashboardShell.tsx +++ b/src/components/DashboardShell.tsx @@ -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', diff --git a/src/components/OrganicoDashboard.tsx b/src/components/OrganicoDashboard.tsx new file mode 100644 index 0000000..0a279a8 --- /dev/null +++ b/src/components/OrganicoDashboard.tsx @@ -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('seo'); + const [loading, setLoading] = useState(true); + const [warning, setWarning] = useState(null); + const [organic, setOrganic] = useState([]); + const [geo, setGeo] = useState([]); + const [integrations, setIntegrations] = useState([]); + const [showGeoForm, setShowGeoForm] = useState(false); + const [saving, setSaving] = useState(false); + const [msg, setMsg] = useState(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(); + 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 ( +
+ +

+ Selecciona un cliente para ver su ficha orgánica. +

+
+ ); + } + + if (loading) { + return ( +
+ Cargando orgánico… +
+ ); + } + + return ( +
+ + } + /> + + {warning && ( +

+ {warning} +

+ )} + {msg && ( +

+ {msg} +

+ )} + + {/* Connection strip */} +
+ {[ + { 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) => ( +
+
+

{c.title}

+ + {c.row?.status === 'connected' ? 'ON' : 'OFF'} + +
+

+ {c.row?.external_account_id || 'Sin cuenta vinculada'} +

+ {isAgency && tenantId && c.key !== 'geo' ? ( + + Conectar en ficha + + ) : null} +
+ ))} +
+ + {tab === 'seo' && ( +
+
+ {[ + { + 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) => ( +
+

+ {k.label} +

+

+ {k.value} +

+

{k.hint}

+
+ ))} +
+ + {!organic.length && ( +
+
+ +
+

+ Sin serie SEO todavía +

+

+ Como en el{' '} + + caso Vivegreen + + : 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. +

+
+
+
+ )} + + {organic.length > 0 && ( +
+

+ Serie diaria +

+ + + + + + + + + + + + + + {[...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 ( + + + + + + + + + + ); + })} + +
DíaClicsImpr.CTRPos.Org. GA4Conv.
+ {day} + {formatNum(clicks)}{formatNum(impr)}{formatPct(ctr)} + {r.gsc_avg_position != null + ? formatNum(Number(r.gsc_avg_position), 1) + : '—'} + + {formatNum(Number(r.ga4_organic_sessions) || 0)} + + {formatNum(Number(r.ga4_conversions) || 0)} +
+
+ )} + +
+

+ + Cimientos SEO (seguimiento) +

+

+ Checklist inspirado en SEO técnico desde cero: indexación, + arquitectura y medición antes de escalar contenido. +

+
    + {SEO_FOUNDATIONS.map((f) => { + const ok = + f.id === 'medicion' && + ga4?.status === 'connected' && + gsc?.status === 'connected'; + return ( +
  • +

    + {f.title} +

    +

    {f.hint}

    +
  • + ); + })} +
+
+ + {isAgency && ( +
+

+ Cargar día (manual · hasta sync GA4/GSC) +

+
+ {( + [ + ['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]) => ( + + ))} +
+ +
+ )} +
+ )} + + {tab === 'geo' && ( +
+
+
+ +
+

+ Visibilidad en IA (GEO) +

+

+ Framework alineado con estudios tipo{' '} + + Pixelclip · Share of Model + + : qué marca recomienda ChatGPT / Gemini / AI Overviews, cuota + de citas a web oficial y ranking por intención. +

+
+
+
+ + {latestGeo ? ( + <> +
+ {[ + { + 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) => ( +
+

+ {k.label} +

+

+ {k.value} +

+

{k.hint}

+
+ ))} +
+ +
+ {GEO_ENGINES.map((eng) => { + const v = Number( + (latestGeo.engines || {})[ + eng.id as keyof typeof latestGeo.engines + ] ?? NaN + ); + return ( +
+

+ {eng.label} +

+

+ {Number.isFinite(v) ? formatPct(v) : '—'} +

+
+
+
+
+ ); + })} +
+ +
+
+

+ Ranking Share of Model +

+ {(latestGeo.competitors || []).length === 0 ? ( +

Sin competidores.

+ ) : ( +
    + {(latestGeo.competitors || []).map((c, i) => ( +
  • + + {c.rank || i + 1} + +
    +
    + + {c.brand} + + + {formatPct(Number(c.share))} + +
    +
    +
    +
    +
    +
  • + ))} +
+ )} +
+ +
+

+ Por intención / categoría +

+ {(latestGeo.categories || []).length === 0 ? ( +

Sin categorías.

+ ) : ( +
    + {(latestGeo.categories || []).map((c, i) => ( +
  • + + {c.category} + + + {formatPct(Number(c.visibility))} + +
  • + ))} +
+ )} +
+
+ + {latestGeo.notes || latestGeo.study_url ? ( +

+ {latestGeo.notes}{' '} + {latestGeo.study_url ? ( + + Ver estudio + + ) : null} +

+ ) : null} + + {geo.length > 1 && ( +

+ Historial: {geo.length} mediciones · última{' '} + {latestGeo.created_at + ? new Date(latestGeo.created_at).toLocaleDateString('es-ES') + : ''} +

+ )} + + ) : ( +
+ +

+ Aún no hay medición GEO +

+

+ Registra Share of Model, motores (ChatGPT / Gemini / AI + Overviews) y ranking competitivo cuando tengas un estudio o + muestra de prompts. +

+
+ )} + + {isAgency && ( +
+ + {showGeoForm && ( +
+
+ + {( + [ + ['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]) => ( + + ))} +
+
+