diff --git a/src/app/api/organico/route.ts b/src/app/api/organico/route.ts index cf4578f..8bc1420 100644 --- a/src/app/api/organico/route.ts +++ b/src/app/api/organico/route.ts @@ -54,7 +54,7 @@ export async function GET(req: NextRequest) { since && until ? `&day=gte.${since}&day=lte.${until}` : ''; - const [oRes, gRes, iRes] = await Promise.all([ + const [oRes, gRes, iRes, tRes] = await Promise.all([ fetch( `${base}/rest/v1/organic_daily?tenant_id=eq.${encodeURIComponent(tid)}&select=*&order=day.asc${dayFilter}`, { 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`, { headers, cache: 'no-store' } ), + fetch( + `${base}/rest/v1/tenants?id=eq.${encodeURIComponent(tid)}&select=settings`, + { headers, cache: 'no-store' } + ), ]); const warningParts: string[] = []; let organic: OrganicDailyRow[] = []; let geo: GeoSnapshot[] = []; let integrations: unknown[] = []; + let workspace = {}; if (oRes.ok) organic = await oRes.json(); else { @@ -91,6 +96,10 @@ export async function GET(req: NextRequest) { } } if (iRes.ok) integrations = await iRes.json(); + if (tRes.ok) { + const rows = await tRes.json(); + workspace = rows[0]?.settings?.organico || {}; + } return NextResponse.json({ portfolioWide: false, @@ -99,6 +108,7 @@ export async function GET(req: NextRequest) { organic, geo, integrations, + workspace, warning: warningParts[0] || null, }); } @@ -110,7 +120,7 @@ export async function GET(req: NextRequest) { .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([ + const [o, ge, integ, ten] = await Promise.all([ organicQ, supabase .from('geo_snapshots') @@ -123,6 +133,7 @@ export async function GET(req: NextRequest) { 'provider,status,enabled,external_account_id,last_sync_at,error_message' ) .in('provider', ['ga4', 'search_console', 'tag_manager']), + supabase.from('tenants').select('settings').eq('id', tid).maybeSingle(), ]); return NextResponse.json({ @@ -132,6 +143,7 @@ export async function GET(req: NextRequest) { organic: o.data || [], geo: ge.data || [], integrations: integ.data || [], + workspace: (ten.data?.settings as { organico?: unknown } | null)?.organico || {}, 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 kind = body.kind === 'geo' ? 'geo' : 'organic'; + const kind = + body.kind === 'geo' + ? 'geo' + : body.kind === 'workspace' + ? 'workspace' + : 'organic'; 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 = {}; + 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) + : {}; + 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') { const day = typeof body.day === 'string' ? body.day.slice(0, 10) : ''; if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) { diff --git a/src/components/OrganicoDashboard.tsx b/src/components/OrganicoDashboard.tsx index 0a279a8..00dfe4f 100644 --- a/src/components/OrganicoDashboard.tsx +++ b/src/components/OrganicoDashboard.tsx @@ -3,29 +3,35 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import Link from 'next/link'; import { + ArrowRight, Bot, + Check, ExternalLink, + Leaf, Loader2, Plus, Search, Sparkles, - TrendingUp, } from 'lucide-react'; import { PageHeader, SegmentedControl } from '@/components/ui'; import { GEO_ENGINES, + ORGANICO_SECTIONS, SEO_FOUNDATIONS, GeoSnapshot, IntegrationLite, OrganicDailyRow, + OrganicoPillar, + OrganicoSection, + OrganicoSectionId, + OrganicoWorkspace, formatNum, formatPct, + groupsForPillar, sumOrganic, } from '@/lib/organic'; import { isoYesterday } from '@/lib/ad-tracker-range'; -type Tab = 'seo' | 'geo'; - type Props = { label: string; portfolioWide: boolean; @@ -41,21 +47,57 @@ function connBadge(status?: string) { return 'bg-gray-50 text-gray-600 border-gray-200'; } +function Kpi({ + label, + value, + hint, + accent, +}: { + label: string; + value: string; + hint?: string; + accent?: boolean; +}) { + return ( +
+

+ {label} +

+

{value}

+ {hint ?

{hint}

: null} +
+ ); +} + export default function OrganicoDashboard({ label, portfolioWide, isAgency, tenantId, }: Props) { - const [tab, setTab] = useState('seo'); + const [pillar, setPillar] = useState('geo'); + const [sectionId, setSectionId] = useState('geo-overview'); 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 [workspace, setWorkspace] = useState({}); + const [sectionNote, setSectionNote] = useState(''); + const [prioritiesText, setPrioritiesText] = useState(''); const [saving, setSaving] = useState(false); const [msg, setMsg] = useState(null); + const [showCapture, setShowCapture] = useState(false); const [geoForm, setGeoForm] = useState({ label: 'Medición GEO', @@ -69,8 +111,8 @@ export default function OrganicoDashboard({ responses_analyzed: '', study_url: '', notes: '', - competitors: 'Marca A, 40\nMarca B, 25\nTu marca, 12', - categories: 'General, 15\nCompra, 10\nComparativa, 8', + competitors: 'Competidor A, 40\nCompetidor B, 25\nTu marca, 12', + categories: 'Informativa, 15\nCompra, 10\nComparativa, 8', }); const [seoForm, setSeoForm] = useState({ @@ -91,6 +133,7 @@ export default function OrganicoDashboard({ setOrganic(data.organic || []); setGeo(data.geo || []); setIntegrations(data.integrations || []); + setWorkspace(data.workspace || {}); setWarning(data.warning || null); setLoading(false); }, []); @@ -99,6 +142,25 @@ export default function OrganicoDashboard({ void load(); }, [load]); + useEffect(() => { + const first = ORGANICO_SECTIONS.find((s) => s.pillar === pillar); + if (first) setSectionId(first.id); + }, [pillar]); + + useEffect(() => { + setSectionNote(workspace.sectionNotes?.[sectionId] || ''); + const list = + pillar === 'geo' + ? workspace.priorities?.geo || [] + : workspace.priorities?.seo || []; + setPrioritiesText(list.join('\n')); + }, [sectionId, pillar, workspace]); + + const section = useMemo( + () => ORGANICO_SECTIONS.find((s) => s.id === sectionId)!, + [sectionId] + ); + const navGroups = useMemo(() => groupsForPillar(pillar), [pillar]); const totals = useMemo(() => sumOrganic(organic), [organic]); const latestGeo = geo[0] || null; @@ -107,10 +169,72 @@ export default function OrganicoDashboard({ for (const i of integrations) m.set(i.provider, i); return m; }, [integrations]); - const ga4 = byProvider.get('ga4'); const gsc = byProvider.get('search_console'); + 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 saveWorkspace(patch: OrganicoWorkspace) { + setSaving(true); + setMsg(null); + const next: OrganicoWorkspace = { + priorities: { + seo: workspace.priorities?.seo || [], + geo: workspace.priorities?.geo || [], + ...patch.priorities, + }, + sectionNotes: { + ...(workspace.sectionNotes || {}), + ...(patch.sectionNotes || {}), + }, + }; + if (patch.priorities?.seo) next.priorities!.seo = patch.priorities.seo; + if (patch.priorities?.geo) next.priorities!.geo = patch.priorities.geo; + + const res = await fetch('/api/organico', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ kind: 'workspace', workspace: next }), + }); + const data = await res.json(); + setSaving(false); + if (!res.ok) { + setMsg(data.error || 'Error al guardar'); + return; + } + setWorkspace(data.workspace || next); + setMsg('Guardado'); + } + + async function saveSectionNote() { + await saveWorkspace({ + sectionNotes: { [sectionId]: sectionNote }, + }); + } + + async function savePriorities() { + const lines = prioritiesText + .split('\n') + .map((l) => l.trim()) + .filter(Boolean); + await saveWorkspace({ + priorities: + pillar === 'geo' + ? { geo: lines, seo: workspace.priorities?.seo || [] } + : { seo: lines, geo: workspace.priorities?.geo || [] }, + }); + } + async function saveOrganicDay() { setSaving(true); setMsg(null); @@ -141,30 +265,16 @@ export default function OrganicoDashboard({ 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!, + brand: x.name, share: x.value, rank: i + 1, })); const cats = parsePairs(geoForm.categories).map((x, i) => ({ - category: x.name!, + category: x.name, visibility: x.value, rank: i + 1, })); @@ -209,7 +319,7 @@ export default function OrganicoDashboard({ return; } setMsg('Medición GEO registrada'); - setShowGeoForm(false); + setShowCapture(false); await load(); } @@ -219,10 +329,10 @@ export default function OrganicoDashboard({

- Selecciona un cliente para ver su ficha orgánica. + Selecciona un cliente para abrir su suite SEO / GEO.

); @@ -231,237 +341,286 @@ export default function OrganicoDashboard({ if (loading) { return (
- Cargando orgánico… + Cargando suite orgánica…
); } return ( -
+
+
+ setPillar(p)} + size="sm" + options={[ + { id: 'geo', label: 'GEO · IA' }, + { id: 'seo', label: 'SEO' }, + ]} + /> + {isAgency ? ( + + ) : null} +
} /> - {warning && ( -

- {warning} -

- )} - {msg && ( -

- {msg} + {(warning || msg) && ( +

+ {msg || warning}

)} - {/* Connection strip */} + {/* Connections */}
{[ - { key: 'search_console', title: 'Search Console', row: gsc }, - { key: 'ga4', title: 'GA4', row: ga4 }, + { title: 'Search Console', row: gsc }, + { title: 'GA4', row: ga4 }, { - key: 'geo', title: 'GEO / IA', row: latestGeo - ? { status: 'connected', external_account_id: latestGeo.label } - : { status: 'disconnected' }, + ? { + status: 'connected', + external_account_id: latestGeo.label || 'Medición activa', + } + : { status: 'disconnected', external_account_id: null }, }, ].map((c) => (
-
+

{c.title}

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

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

+ {c.row?.external_account_id || 'Sin vincular'}

- {isAgency && tenantId && c.key !== 'geo' ? ( + {isAgency && tenantId && c.title !== 'GEO / IA' ? ( - Conectar en ficha + 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}

-
- ))} -
+ {/* Capture forms */} + {isAgency && showCapture && pillar === 'seo' && ( + void saveOrganicDay()} + /> + )} + {isAgency && showCapture && pillar === 'geo' && ( + void saveGeo()} + /> + )} - {!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. -

-
-
+ {/* Shell: nav + content */} +
+ + +
+ + + {/* Live panels by section.live */} + {section.live === 'seo-metrics' && ( +
+ + + + + + + +
)} - {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)} -
-
+ {section.live === 'geo-metrics' && latestGeo && ( + <> +
+ + + + +
+
+ {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) : '—'} +

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

- - Cimientos SEO (seguimiento) -

-

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

+ {section.live === 'geo-metrics' && !latestGeo && ( + + )} + + {section.live === 'geo-competitors' && ( + + )} + + {section.live === 'geo-categories' && ( + + )} + + {section.live === 'seo-foundations' && (
    {SEO_FOUNDATIONS.map((f) => { const ok = @@ -474,7 +633,7 @@ export default function OrganicoDashboard({ className={`rounded-lg border px-3 py-2.5 ${ ok ? 'border-emerald-200 bg-emerald-50' - : 'border-gray-200 bg-gray-50' + : 'border-gray-200 bg-white' }`} >

    @@ -485,401 +644,397 @@ export default function OrganicoDashboard({ ); })}

-
+ )} - {isAgency && ( -
-

- Cargar día (manual · hasta sync GA4/GSC) + {/* Priorities + section notes */} +
+
+

+ + Prioridades claras · {pillar === 'geo' ? 'GEO' : 'SEO'}

-
- {( - [ - ['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 +

+ Una por línea. Lo que hay que mejorar primero.

-

- 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]) => ( - - ))} -
-
-