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:
91
scripts/dokploy_apply_organic_geo.py
Normal file
91
scripts/dokploy_apply_organic_geo.py
Normal 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
75
sql/organic_geo.sql
Normal 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'
|
||||||
|
)
|
||||||
|
);
|
||||||
266
src/app/api/organico/route.ts
Normal file
266
src/app/api/organico/route.ts
Normal 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] });
|
||||||
|
}
|
||||||
28
src/app/dashboard/organico/page.tsx
Normal file
28
src/app/dashboard/organico/page.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
Contact,
|
Contact,
|
||||||
Menu,
|
Menu,
|
||||||
X,
|
X,
|
||||||
|
Leaf,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import TenantSwitcher from '@/components/TenantSwitcher';
|
import TenantSwitcher from '@/components/TenantSwitcher';
|
||||||
import type { DashboardModuleId } from '@/lib/tenant-modules';
|
import type { DashboardModuleId } from '@/lib/tenant-modules';
|
||||||
@@ -74,6 +75,13 @@ const NAV_ITEMS: NavItem[] = [
|
|||||||
iconClass: 'text-violet-500',
|
iconClass: 'text-violet-500',
|
||||||
moduleId: 'ad-tracker',
|
moduleId: 'ad-tracker',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: '/dashboard/organico',
|
||||||
|
label: 'Orgánico',
|
||||||
|
icon: Leaf,
|
||||||
|
iconClass: 'text-lime-600',
|
||||||
|
moduleId: 'organico',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: '/dashboard/activos',
|
href: '/dashboard/activos',
|
||||||
label: 'Biblioteca de Activos',
|
label: 'Biblioteca de Activos',
|
||||||
|
|||||||
885
src/components/OrganicoDashboard.tsx
Normal file
885
src/components/OrganicoDashboard.tsx
Normal 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
167
src/lib/organic.ts
Normal 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;
|
||||||
@@ -7,6 +7,7 @@ export type DashboardModuleId =
|
|||||||
| 'estrategia'
|
| 'estrategia'
|
||||||
| 'calendario'
|
| 'calendario'
|
||||||
| 'ad-tracker'
|
| 'ad-tracker'
|
||||||
|
| 'organico'
|
||||||
| 'activos'
|
| 'activos'
|
||||||
| 'creatives'
|
| 'creatives'
|
||||||
| 'asistente';
|
| 'asistente';
|
||||||
@@ -25,7 +26,7 @@ export const DASHBOARD_MODULES: DashboardModuleDef[] = [
|
|||||||
id: 'kpis',
|
id: 'kpis',
|
||||||
href: '/dashboard/kpis',
|
href: '/dashboard/kpis',
|
||||||
label: 'Métricas',
|
label: 'Métricas',
|
||||||
providers: ['meta_ads', 'google_ads', 'ga4'],
|
providers: ['meta_ads', 'google_ads'],
|
||||||
hint: 'KPIs de inversión y leads',
|
hint: 'KPIs de inversión y leads',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -63,6 +64,13 @@ export const DASHBOARD_MODULES: DashboardModuleDef[] = [
|
|||||||
providers: ['meta_ads', 'google_ads'],
|
providers: ['meta_ads', 'google_ads'],
|
||||||
hint: 'Creatividades Meta / Google',
|
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',
|
id: 'activos',
|
||||||
href: '/dashboard/activos',
|
href: '/dashboard/activos',
|
||||||
|
|||||||
Reference in New Issue
Block a user