feat: suite Orgánico tipo Semrush (SEO + GEO por secciones)

Navegación por visión general, competencia, prompts, percepción,
auditoría, keywords, backlinks y prioridades accionables por cliente.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-24 11:25:32 +02:00
parent 5c90bcf940
commit 81ede58330
4 changed files with 1322 additions and 599 deletions

View File

@@ -54,7 +54,7 @@ export async function GET(req: NextRequest) {
since && until since && until
? `&day=gte.${since}&day=lte.${until}` ? `&day=gte.${since}&day=lte.${until}`
: ''; : '';
const [oRes, gRes, iRes] = await Promise.all([ const [oRes, gRes, iRes, tRes] = await Promise.all([
fetch( fetch(
`${base}/rest/v1/organic_daily?tenant_id=eq.${encodeURIComponent(tid)}&select=*&order=day.asc${dayFilter}`, `${base}/rest/v1/organic_daily?tenant_id=eq.${encodeURIComponent(tid)}&select=*&order=day.asc${dayFilter}`,
{ headers, cache: 'no-store' } { headers, cache: 'no-store' }
@@ -67,12 +67,17 @@ export async function GET(req: NextRequest) {
`${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`, `${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' } { headers, cache: 'no-store' }
), ),
fetch(
`${base}/rest/v1/tenants?id=eq.${encodeURIComponent(tid)}&select=settings`,
{ headers, cache: 'no-store' }
),
]); ]);
const warningParts: string[] = []; const warningParts: string[] = [];
let organic: OrganicDailyRow[] = []; let organic: OrganicDailyRow[] = [];
let geo: GeoSnapshot[] = []; let geo: GeoSnapshot[] = [];
let integrations: unknown[] = []; let integrations: unknown[] = [];
let workspace = {};
if (oRes.ok) organic = await oRes.json(); if (oRes.ok) organic = await oRes.json();
else { else {
@@ -91,6 +96,10 @@ export async function GET(req: NextRequest) {
} }
} }
if (iRes.ok) integrations = await iRes.json(); if (iRes.ok) integrations = await iRes.json();
if (tRes.ok) {
const rows = await tRes.json();
workspace = rows[0]?.settings?.organico || {};
}
return NextResponse.json({ return NextResponse.json({
portfolioWide: false, portfolioWide: false,
@@ -99,6 +108,7 @@ export async function GET(req: NextRequest) {
organic, organic,
geo, geo,
integrations, integrations,
workspace,
warning: warningParts[0] || null, warning: warningParts[0] || null,
}); });
} }
@@ -110,7 +120,7 @@ export async function GET(req: NextRequest) {
.order('day', { ascending: true }); .order('day', { ascending: true });
if (since) organicQ = organicQ.gte('day', since); if (since) organicQ = organicQ.gte('day', since);
if (until) organicQ = organicQ.lte('day', until); if (until) organicQ = organicQ.lte('day', until);
const [o, ge, integ] = await Promise.all([ const [o, ge, integ, ten] = await Promise.all([
organicQ, organicQ,
supabase supabase
.from('geo_snapshots') .from('geo_snapshots')
@@ -123,6 +133,7 @@ export async function GET(req: NextRequest) {
'provider,status,enabled,external_account_id,last_sync_at,error_message' 'provider,status,enabled,external_account_id,last_sync_at,error_message'
) )
.in('provider', ['ga4', 'search_console', 'tag_manager']), .in('provider', ['ga4', 'search_console', 'tag_manager']),
supabase.from('tenants').select('settings').eq('id', tid).maybeSingle(),
]); ]);
return NextResponse.json({ return NextResponse.json({
@@ -132,6 +143,7 @@ export async function GET(req: NextRequest) {
organic: o.data || [], organic: o.data || [],
geo: ge.data || [], geo: ge.data || [],
integrations: integ.data || [], integrations: integ.data || [],
workspace: (ten.data?.settings as { organico?: unknown } | null)?.organico || {},
warning: o.error?.message || ge.error?.message || null, warning: o.error?.message || ge.error?.message || null,
}); });
} }
@@ -159,9 +171,61 @@ export async function POST(req: NextRequest) {
} }
const body = await req.json().catch(() => ({})); const body = await req.json().catch(() => ({}));
const kind = body.kind === 'geo' ? 'geo' : 'organic'; const kind =
body.kind === 'geo'
? 'geo'
: body.kind === 'workspace'
? 'workspace'
: 'organic';
const base = supabaseUrl(); const base = supabaseUrl();
if (kind === 'workspace') {
// Merge organico workspace into tenants.settings
const cur = await fetch(
`${base}/rest/v1/tenants?id=eq.${encodeURIComponent(ctx.tenantId)}&select=settings`,
{ headers, cache: 'no-store' }
);
let settings: Record<string, unknown> = {};
if (cur.ok) {
const rows = await cur.json();
settings =
rows[0]?.settings && typeof rows[0].settings === 'object'
? { ...rows[0].settings }
: {};
}
const prev =
settings.organico && typeof settings.organico === 'object'
? (settings.organico as Record<string, unknown>)
: {};
settings.organico = {
...prev,
...(body.workspace && typeof body.workspace === 'object'
? body.workspace
: {}),
};
const r = await fetch(
`${base}/rest/v1/tenants?id=eq.${encodeURIComponent(ctx.tenantId)}`,
{
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=representation',
},
body: JSON.stringify({ settings }),
}
);
if (!r.ok) {
return NextResponse.json(
{ error: (await r.text()).slice(0, 300) },
{ status: 500 }
);
}
return NextResponse.json({
workspace: (await r.json())[0]?.settings?.organico || settings.organico,
});
}
if (kind === 'organic') { if (kind === 'organic') {
const day = typeof body.day === 'string' ? body.day.slice(0, 10) : ''; const day = typeof body.day === 'string' ? body.day.slice(0, 10) : '';
if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) { if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) {

File diff suppressed because it is too large Load Diff

View File

@@ -130,7 +130,6 @@ export function formatPct(v: number, digits = 1): string {
return `${formatNum(v, digits)}%`; return `${formatNum(v, digits)}%`;
} }
/** SEO technical health checklist (Vivegreen-style foundations). */
export const SEO_FOUNDATIONS = [ export const SEO_FOUNDATIONS = [
{ {
id: 'indexacion', id: 'indexacion',
@@ -159,9 +158,509 @@ export const SEO_FOUNDATIONS = [
}, },
] as const; ] as const;
/** GEO engines tracked (Pixelclip-style AI visibility). */
export const GEO_ENGINES = [ export const GEO_ENGINES = [
{ id: 'chatgpt', label: 'ChatGPT' }, { id: 'chatgpt', label: 'ChatGPT' },
{ id: 'gemini', label: 'Gemini' }, { id: 'gemini', label: 'Gemini' },
{ id: 'ai_overviews', label: 'Google AI Overviews' }, { id: 'ai_overviews', label: 'Google AI Overviews' },
] as const; ] as const;
/* ── Product IA (Semrush-style) ─────────────────────────────────── */
export type OrganicoPillar = 'seo' | 'geo';
export type OrganicoSectionId =
// GEO
| 'geo-overview'
| 'geo-competition'
| 'geo-prompts'
| 'geo-brand'
| 'geo-perception'
| 'geo-narrative'
| 'geo-questions'
| 'geo-audit'
| 'geo-tracking'
| 'geo-content'
| 'geo-roi'
// SEO
| 'seo-overview'
| 'seo-audit'
| 'seo-rankings'
| 'seo-competition'
| 'seo-domain'
| 'seo-organic'
| 'seo-pages'
| 'seo-compare'
| 'seo-keyword-gap'
| 'seo-backlink-gap'
| 'seo-keywords'
| 'seo-keyword-magic'
| 'seo-strategy'
| 'seo-content-ideas'
| 'seo-writing'
| 'seo-topics'
| 'seo-template'
| 'seo-backlinks'
| 'seo-refdomains'
| 'seo-backlink-audit'
| 'seo-onpage'
| 'seo-traffic-insights';
export type OrganicoSection = {
id: OrganicoSectionId;
pillar: OrganicoPillar;
group: string;
label: string;
blurb: string;
/** What the user should do next when empty. */
actions: string[];
live?: 'seo-metrics' | 'geo-metrics' | 'geo-competitors' | 'geo-categories' | 'seo-foundations';
soon?: boolean;
};
export const ORGANICO_SECTIONS: OrganicoSection[] = [
// ——— GEO ———
{
id: 'geo-overview',
pillar: 'geo',
group: 'Visibilidad en la IA',
label: 'Visión general',
blurb:
'Monitoriza la visibilidad de tu marca en la IA. Share of Model, motores y prioridades claras de mejora.',
actions: [
'Registrar medición GEO (Share of Model + motores)',
'Priorizar 3 prompts donde no apareces',
'Revisar % de citas a web oficial',
],
live: 'geo-metrics',
},
{
id: 'geo-competition',
pillar: 'geo',
group: 'Visibilidad en la IA',
label: 'Análisis de la competencia',
blurb:
'Identifica prompts, menciones y fuentes donde tus rivales aparecen pero tú no, y reduce esa brecha.',
actions: [
'Listar competidores con más Share of Voice',
'Mapear fuentes que citan a rivales y no a ti',
'Definir 5 acciones para cerrar la brecha',
],
live: 'geo-competitors',
},
{
id: 'geo-prompts',
pillar: 'geo',
group: 'Visibilidad en la IA',
label: 'Investigación de prompts',
blurb:
'Descubre los prompts que generan conversaciones de IA en tu sector y las fuentes de las respuestas.',
actions: [
'Clusterizar prompts por intención (informativa / compra / comparativa)',
'Marcar prompts prioritarios por volumen o negocio',
'Anotar marcas y URLs citadas en cada respuesta',
],
},
{
id: 'geo-brand',
pillar: 'geo',
group: 'Rendimiento de marca',
label: 'Rendimiento de marca',
blurb:
'Verifica puntuaciones de visibilidad en IA y los prompts más relevantes para tu marca.',
actions: [
'Comparar Share of Model vs top 3',
'Filtrar menciones por plataforma (ChatGPT / Gemini / AIO)',
'Detectar caídas día a día cuando haya rastreo',
],
live: 'geo-metrics',
},
{
id: 'geo-perception',
pillar: 'geo',
group: 'Rendimiento de marca',
label: 'Percepción',
blurb:
'Evalúa sentimiento positivo/negativo y cómo te percibe cada plataforma de IA.',
actions: [
'Clasificar menciones + / − / neutro',
'Anotar narrativas por motor',
'Priorizar correcciones de reputación',
],
},
{
id: 'geo-narrative',
pillar: 'geo',
group: 'Rendimiento de marca',
label: 'Elementos narrativos',
blurb:
'Aduéñate de la narrativa: mensajes, claims y atributos que la IA asocia a tu marca.',
actions: [
'Extraer claims recurrentes en respuestas',
'Contrastar con messaging oficial',
'Definir mensajes a reforzar en contenido citables',
],
},
{
id: 'geo-questions',
pillar: 'geo',
group: 'Rendimiento de marca',
label: 'Preguntas',
blurb:
'Las preguntas reales de la audiencia orientan el contenido que la IA citará.',
actions: [
'Listar top preguntas por categoría',
'Crear FAQ / guías que respondan con datos propios',
'Enlazar a landings de conversión',
],
live: 'geo-categories',
},
{
id: 'geo-audit',
pillar: 'geo',
group: 'Impulso y monitorización',
label: 'Auditoría del sitio (GEO)',
blurb:
'Elimina barreras a la visibilidad: que la IA entienda, confíe y cite tu web.',
actions: [
'Revisar schema, autoría y E-E-A-T',
'Asegurar respuestas claras y citables en páginas clave',
'Mejorar velocidad y acceso de crawlers',
],
live: 'seo-foundations',
},
{
id: 'geo-tracking',
pillar: 'geo',
group: 'Impulso y monitorización',
label: 'Rastreo de prompts',
blurb:
'Rastrea visibilidad a diario, detecta prompts prioritarios y obtén recomendaciones de crecimiento.',
actions: [
'Fijar cartera de prompts a monitorizar',
'Registrar aparición / no aparición por motor',
'Alertar caídas de Share of Model',
],
},
{
id: 'geo-content',
pillar: 'geo',
group: 'Impulso y monitorización',
label: 'Creación de contenidos',
blurb:
'Prepara contenido para búsqueda de IA: fortalece visibilidad y sentimiento.',
actions: [
'Briefs a partir de prompts ganadores de competidores',
'Optimizar para cita (datos, definiciones, tablas)',
'Publicar y re-medir Share of Model',
],
},
{
id: 'geo-roi',
pillar: 'geo',
group: 'Impulso y monitorización',
label: 'ROI de visibilidad IA',
blurb:
'Informes de visibilidad IA junto a tráfico y conversiones (GA4). Disponible en breve.',
actions: [
'Conectar GA4',
'Cruzar sesiones orgánicas con picos de menciones IA',
'Informe ejecutivo con un clic (próximamente)',
],
soon: true,
},
// ——— SEO ———
{
id: 'seo-overview',
pillar: 'seo',
group: 'Rendimiento del sitio',
label: 'Visión general',
blurb:
'Hazte visible donde busca tu audiencia: datos de mercado, acción y resultados medibles.',
actions: [
'Conectar Search Console y GA4',
'Cargar o sincronizar serie diaria',
'Definir 3 prioridades del mes',
],
live: 'seo-metrics',
},
{
id: 'seo-audit',
pillar: 'seo',
group: 'Rendimiento del sitio',
label: 'Auditoría del sitio',
blurb:
'Consulta problemas de SEO que limitan posicionamiento: salud, contenido y UX.',
actions: [
'Auditar indexación y CWV',
'Corregir errores críticos',
'Plan de remediación por impacto',
],
live: 'seo-foundations',
},
{
id: 'seo-rankings',
pillar: 'seo',
group: 'Rendimiento del sitio',
label: 'Rastreo de posición',
blurb:
'Rastrea posicionamiento en Google (y más adelante Bing / Modo IA) para keywords de alto valor.',
actions: [
'Definir cartera de keywords',
'Registrar posición media GSC',
'Alertar caídas de top 10',
],
live: 'seo-metrics',
},
{
id: 'seo-competition',
pillar: 'seo',
group: 'Análisis de la competencia',
label: 'Visión general del dominio',
blurb:
'Supera a tu competencia con análisis SEO: tráfico, keywords y estrategias.',
actions: [
'Fijar 3 dominios rivales',
'Comparar autoridad y temáticas',
'Extraer oportunidades de contenido',
],
},
{
id: 'seo-organic',
pillar: 'seo',
group: 'Análisis de la competencia',
label: 'Posicionamiento orgánico',
blurb:
'Mide visibilidad orgánica y evolución de clics/impresiones frente al mercado.',
actions: [
'Revisar serie GSC MoM',
'Identificar queries en ascenso/descenso',
'Alinear con objetivos de negocio',
],
live: 'seo-metrics',
},
{
id: 'seo-pages',
pillar: 'seo',
group: 'Análisis de la competencia',
label: 'Páginas principales',
blurb: 'Landings que concentran tráfico y conversión orgánica.',
actions: [
'Listar top páginas por clics',
'Optimizar títulos/meta y UX',
'Reforzar enlazado interno',
],
},
{
id: 'seo-compare',
pillar: 'seo',
group: 'Análisis de la competencia',
label: 'Comparación de dominios',
blurb: 'Compara tu dominio con rivales en una sola vista.',
actions: [
'Elegir métricas comunes (tráfico, KW, backlinks)',
'Marcar gaps críticos',
'Asignar owners internos',
],
},
{
id: 'seo-keyword-gap',
pillar: 'seo',
group: 'Análisis de la competencia',
label: 'Brecha de palabras clave',
blurb:
'Keywords donde rivales rankean y tú no — hoja de ruta clara.',
actions: [
'Exportar gap vs top rivales',
'Priorizar por intención de compra',
'Crear o potenciar páginas',
],
},
{
id: 'seo-backlink-gap',
pillar: 'seo',
group: 'Análisis de la competencia',
label: 'Brecha de backlinks',
blurb:
'Dominios que enlazan a rivales y no a ti: autoridad y confianza.',
actions: [
'Listar dominios de referencia únicos de rivales',
'Pitch / digital PR',
'Monitorizar nuevos enlaces',
],
},
{
id: 'seo-keywords',
pillar: 'seo',
group: 'Investigación de palabras clave',
label: 'Visión general de keywords',
blurb:
'Palabras clave y prompts con intención de compra para Google y ChatGPT.',
actions: [
'Mapear KW → intención → URL',
'Detectar huecos de cobertura',
'Alinear con calendario editorial',
],
},
{
id: 'seo-keyword-magic',
pillar: 'seo',
group: 'Investigación de palabras clave',
label: 'Keyword Magic / ideas',
blurb: 'Explora ideas de keywords y variantes semánticas.',
actions: [
'Sembrar seed keywords del negocio',
'Agrupar por clusters',
'Seleccionar quick wins',
],
},
{
id: 'seo-strategy',
pillar: 'seo',
group: 'Investigación de palabras clave',
label: 'Keyword Strategy Builder',
blurb: 'Construye la estrategia de keywords del trimestre.',
actions: [
'Definir pilares y clusters',
'Asignar prioridad P1/P2/P3',
'Vincular a OKRs',
],
},
{
id: 'seo-content-ideas',
pillar: 'seo',
group: 'Ideas de contenido',
label: 'Ideas de contenido',
blurb:
'Temas en tendencia, planes y briefings optimizados para posicionar.',
actions: [
'Topic research del mes',
'Briefs con outline + KW + FAQs',
'Publicar y medir en GSC',
],
},
{
id: 'seo-writing',
pillar: 'seo',
group: 'Ideas de contenido',
label: 'SEO Writing Assistant',
blurb: 'Asistencia para redactar y optimizar on-page.',
actions: [
'Revisar densidad y entidades',
'Mejorar claridad y E-E-A-T',
'Checklist pre-publicación',
],
},
{
id: 'seo-topics',
pillar: 'seo',
group: 'Ideas de contenido',
label: 'Topic Research',
blurb: 'Tendencias de temas que alimentan SEO y GEO.',
actions: [
'Cruzar topics con prompts IA',
'Detectar ángulos diferenciales',
'Planificar pieza pilar + satélites',
],
},
{
id: 'seo-template',
pillar: 'seo',
group: 'Ideas de contenido',
label: 'SEO Content Template',
blurb: 'Plantillas de contenido basadas en lo que rankea.',
actions: [
'Analizar SERP / respuestas IA',
'Definir estructura ganadora',
'Reutilizar en el equipo',
],
},
{
id: 'seo-backlinks',
pillar: 'seo',
group: 'Generación de enlaces',
label: 'Backlinks',
blurb:
'Gana autoridad con backlinks de calidad que impulsan confianza y SEO.',
actions: [
'Auditar perfil actual',
'Campañas de link earning',
'Desautorizar tóxicos',
],
},
{
id: 'seo-refdomains',
pillar: 'seo',
group: 'Generación de enlaces',
label: 'Dominios de referencia',
blurb: 'Diversidad y calidad de dominios que te enlazan.',
actions: [
'Medir referidos únicos',
'Buscar nichos afines',
'Relaciones con medios / partners',
],
},
{
id: 'seo-backlink-audit',
pillar: 'seo',
group: 'Generación de enlaces',
label: 'Backlink Audit',
blurb: 'Salud del perfil de enlaces y riesgo reputacional.',
actions: [
'Clasificar enlaces',
'Disavow si procede',
'Informe trimestral',
],
},
{
id: 'seo-onpage',
pillar: 'seo',
group: 'Extras',
label: 'On Page SEO Checker',
blurb: 'Comprueba on-page en URLs prioritarias.',
actions: [
'Auditar top landings',
'Corregir title/H1/internal links',
'Re-crawl',
],
},
{
id: 'seo-traffic-insights',
pillar: 'seo',
group: 'Extras',
label: 'Organic Traffic Insights',
blurb:
'Cruza GSC + GA4: cómo el orgánico mueve tráfico y conversiones reales.',
actions: [
'Conectar GA4',
'Atribuir landings a leads/ventas',
'Informar ROI orgánico',
],
live: 'seo-metrics',
},
];
export function sectionsForPillar(pillar: OrganicoPillar): OrganicoSection[] {
return ORGANICO_SECTIONS.filter((s) => s.pillar === pillar);
}
export function groupsForPillar(
pillar: OrganicoPillar
): { group: string; items: OrganicoSection[] }[] {
const list = sectionsForPillar(pillar);
const order: string[] = [];
const map = new Map<string, OrganicoSection[]>();
for (const s of list) {
if (!map.has(s.group)) {
map.set(s.group, []);
order.push(s.group);
}
map.get(s.group)!.push(s);
}
return order.map((group) => ({ group, items: map.get(group)! }));
}
export type OrganicoWorkspace = {
priorities?: { seo: string[]; geo: string[] };
sectionNotes?: Partial<Record<OrganicoSectionId, string>>;
};

View File

@@ -100,6 +100,11 @@ export type TenantSettings = {
modules?: TenantModuleMap; modules?: TenantModuleMap;
/** When true, modules were explicitly saved by agency (don't auto-overwrite). */ /** When true, modules were explicitly saved by agency (don't auto-overwrite). */
modulesLocked?: boolean; modulesLocked?: boolean;
/** SEO/GEO workspace notes & priorities (Orgánico). */
organico?: {
priorities?: { seo: string[]; geo: string[] };
sectionNotes?: Record<string, string>;
};
}; };
export function defaultModulesAllOn(): Record<DashboardModuleId, boolean> { export function defaultModulesAllOn(): Record<DashboardModuleId, boolean> {