El CRM solo vivía de webhooks; ahora hay pull Clientify, cron daily-sync incluye CRM, y cada módulo de Agencia tiene sync manual. Co-authored-by: Cursor <cursoragent@cursor.com>
1744 lines
62 KiB
TypeScript
1744 lines
62 KiB
TypeScript
'use client';
|
||
|
||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||
import Link from 'next/link';
|
||
import {
|
||
GEO_ENGINES,
|
||
ORGANICO_SECTIONS,
|
||
GeoSnapshot,
|
||
IntegrationLite,
|
||
OrganicDailyRow,
|
||
OrganicoPillar,
|
||
OrganicoSection,
|
||
OrganicoSectionId,
|
||
OrganicoWorkspace,
|
||
SeoAuditRow,
|
||
formatNum,
|
||
formatPct,
|
||
groupsForPillar,
|
||
sumOrganic,
|
||
DEFAULT_EXPERT_PRIORITIES,
|
||
} from '@/lib/organic';
|
||
import { isoYesterday } from '@/lib/ad-tracker-range';
|
||
import {
|
||
AlertTriangle,
|
||
ArrowRight,
|
||
Bot,
|
||
Check,
|
||
ExternalLink,
|
||
Leaf,
|
||
Loader2,
|
||
Plus,
|
||
Search,
|
||
ShieldAlert,
|
||
Sparkles,
|
||
} from 'lucide-react';
|
||
import { PageHeader, SegmentedControl } from '@/components/ui';
|
||
import SyncNowButton from '@/components/SyncNowButton';
|
||
|
||
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';
|
||
}
|
||
|
||
function Kpi({
|
||
label,
|
||
value,
|
||
hint,
|
||
accent,
|
||
}: {
|
||
label: string;
|
||
value: string;
|
||
hint?: string;
|
||
accent?: boolean;
|
||
}) {
|
||
return (
|
||
<div
|
||
className={`rounded-xl border px-4 py-3 shadow-sm ${
|
||
accent
|
||
? 'border-brand-200 bg-brand-50'
|
||
: 'border-gray-200 bg-white'
|
||
}`}
|
||
>
|
||
<p
|
||
className={`text-[11px] font-semibold uppercase tracking-wider ${
|
||
accent ? 'text-brand-700' : 'text-gray-600'
|
||
}`}
|
||
>
|
||
{label}
|
||
</p>
|
||
<p className="text-xl font-bold text-gray-900 mt-1 tabular-nums">{value}</p>
|
||
{hint ? <p className="text-[11px] text-gray-500 mt-0.5">{hint}</p> : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function OrganicoDashboard({
|
||
label,
|
||
portfolioWide,
|
||
isAgency,
|
||
tenantId,
|
||
}: Props) {
|
||
const [pillar, setPillar] = useState<OrganicoPillar>('seo');
|
||
const [sectionId, setSectionId] = useState<OrganicoSectionId>('seo-overview');
|
||
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 [workspace, setWorkspace] = useState<OrganicoWorkspace>({});
|
||
const [gscQueries, setGscQueries] = useState<
|
||
{ key: string; clicks: number; impressions: number; ctr: number; position: number }[]
|
||
>([]);
|
||
const [gscPages, setGscPages] = useState<
|
||
{ key: string; clicks: number; impressions: number; ctr: number; position: number }[]
|
||
>([]);
|
||
const [ga4Landings, setGa4Landings] = useState<
|
||
{ page: string; sessions: number; users: number }[]
|
||
>([]);
|
||
const [seoAudits, setSeoAudits] = useState<SeoAuditRow[]>([]);
|
||
const [selectedAuditId, setSelectedAuditId] = useState<string | null>(null);
|
||
const [auditing, setAuditing] = useState(false);
|
||
const [sectionNote, setSectionNote] = useState('');
|
||
const [prioritiesText, setPrioritiesText] = useState('');
|
||
const [saving, setSaving] = useState(false);
|
||
const [msg, setMsg] = useState<string | null>(null);
|
||
const [showCapture, setShowCapture] = useState(false);
|
||
|
||
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: 'Competidor A, 40\nCompetidor B, 25\nTu marca, 12',
|
||
categories: 'Informativa, 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 || []);
|
||
setWorkspace(data.workspace || {});
|
||
setGscQueries(data.gscQueries || []);
|
||
setGscPages(data.gscPages || []);
|
||
setGa4Landings(data.ga4Landings || []);
|
||
setSeoAudits(data.seoAudits || []);
|
||
if (data.seoAudits?.[0]?.id) {
|
||
setSelectedAuditId((prev) => prev || data.seoAudits[0].id);
|
||
}
|
||
setWarning(data.warning || null);
|
||
if (data.autoSynced?.daysWritten) {
|
||
setMsg(
|
||
`Datos GA4/GSC actualizados automáticamente (${data.autoSynced.daysWritten} días).`
|
||
);
|
||
}
|
||
setLoading(false);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
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?.length
|
||
? workspace.priorities.geo
|
||
: DEFAULT_EXPERT_PRIORITIES.geo
|
||
: workspace.priorities?.seo?.length
|
||
? workspace.priorities.seo
|
||
: DEFAULT_EXPERT_PRIORITIES.seo;
|
||
setPrioritiesText(list.join('\n'));
|
||
}, [sectionId, pillar, workspace]);
|
||
|
||
const displayPriorities = useMemo(() => {
|
||
const saved =
|
||
pillar === 'geo'
|
||
? workspace.priorities?.geo
|
||
: workspace.priorities?.seo;
|
||
if (saved?.length) return saved;
|
||
return pillar === 'geo'
|
||
? DEFAULT_EXPERT_PRIORITIES.geo
|
||
: DEFAULT_EXPERT_PRIORITIES.seo;
|
||
}, [pillar, workspace.priorities]);
|
||
|
||
const seoDaily = useMemo(() => {
|
||
return [...organic]
|
||
.filter((r) => r.day)
|
||
.sort((a, b) => String(a.day).localeCompare(String(b.day)))
|
||
.slice(-28)
|
||
.map((r) => ({
|
||
day: String(r.day).slice(0, 10),
|
||
clicks: Number(r.gsc_clicks) || 0,
|
||
sessions: Number(r.ga4_organic_sessions) || 0,
|
||
}));
|
||
}, [organic]);
|
||
const maxClicks = Math.max(1, ...seoDaily.map((d) => d.clicks));
|
||
const maxSessions = Math.max(1, ...seoDaily.map((d) => d.sessions));
|
||
|
||
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;
|
||
|
||
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');
|
||
|
||
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 runSeoAudit() {
|
||
setAuditing(true);
|
||
setMsg(null);
|
||
const res = await fetch('/api/organico', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ kind: 'seo-audit' }),
|
||
});
|
||
const data = await res.json();
|
||
setAuditing(false);
|
||
if (!res.ok) {
|
||
setMsg(data.error || 'Error al auditar');
|
||
return;
|
||
}
|
||
if (data.warning) setWarning(data.warning);
|
||
setMsg(data.audit?.summary || 'Auditoría guardada');
|
||
if (data.audit?.id) setSelectedAuditId(data.audit.id);
|
||
await load();
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
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');
|
||
setShowCapture(false);
|
||
await load();
|
||
}
|
||
|
||
if (portfolioWide) {
|
||
return (
|
||
<div className="space-y-6">
|
||
<PageHeader
|
||
eyebrow="Crecimiento"
|
||
title="Orgánico"
|
||
description="SEO + GEO (visibilidad en IA). Elige un cliente en Contexto."
|
||
actions={<SyncNowButton forceModule="organico" />}
|
||
/>
|
||
<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 abrir su suite SEO / GEO.
|
||
</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 suite orgánica…
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-5">
|
||
<PageHeader
|
||
eyebrow="Crecimiento · Suite"
|
||
title="Orgánico"
|
||
description={`${label} · Observa posicionamiento en búsqueda e IA, competencia y oportunidades de contenido.`}
|
||
actions={
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<SyncNowButton forceModule="organico" />
|
||
<SegmentedControl
|
||
value={pillar}
|
||
onChange={(p) => setPillar(p)}
|
||
size="sm"
|
||
options={[
|
||
{ id: 'geo', label: 'GEO · IA' },
|
||
{ id: 'seo', label: 'SEO' },
|
||
]}
|
||
/>
|
||
{isAgency ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowCapture((v) => !v)}
|
||
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg bg-brand-500 text-white hover:bg-brand-600"
|
||
>
|
||
<Plus className="w-3.5 h-3.5" />
|
||
{pillar === 'geo' ? 'Medición GEO' : 'Cargar día SEO'}
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
}
|
||
/>
|
||
|
||
{(warning || msg) && (
|
||
<p
|
||
className={`text-xs rounded-lg px-3 py-2 border ${
|
||
warning && !msg
|
||
? 'bg-amber-50 border-amber-300 text-amber-900'
|
||
: 'bg-emerald-50 border-emerald-300 text-emerald-900'
|
||
}`}
|
||
>
|
||
{msg || warning}
|
||
</p>
|
||
)}
|
||
|
||
{/* Connections */}
|
||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||
{[
|
||
{ title: 'Search Console', row: gsc },
|
||
{ title: 'GA4', row: ga4 },
|
||
{
|
||
title: 'GEO / IA',
|
||
row: latestGeo
|
||
? {
|
||
status: 'connected',
|
||
external_account_id: latestGeo.label || 'Medición activa',
|
||
}
|
||
: { status: 'disconnected', external_account_id: null },
|
||
},
|
||
].map((c) => (
|
||
<div
|
||
key={c.title}
|
||
className="rounded-xl border border-gray-200 bg-white px-4 py-3 shadow-sm"
|
||
>
|
||
<div className="flex items-center justify-between">
|
||
<p className="text-xs font-semibold text-gray-700">{c.title}</p>
|
||
<span
|
||
className={`text-[10px] font-bold uppercase px-2 py-0.5 rounded-full border ${connBadge(c.row?.status)}`}
|
||
>
|
||
{c.row?.status === 'connected' ? 'ON' : 'OFF'}
|
||
</span>
|
||
</div>
|
||
<p className="text-sm font-medium text-gray-900 mt-1 truncate">
|
||
{c.row?.external_account_id || 'Sin vincular'}
|
||
</p>
|
||
{isAgency && tenantId && c.title !== 'GEO / IA' ? (
|
||
<Link
|
||
href={`/dashboard/agencia/cliente/${tenantId}`}
|
||
className="inline-flex items-center gap-1 text-[11px] font-semibold text-brand-700 mt-1.5 hover:underline"
|
||
>
|
||
Ficha <ExternalLink className="w-3 h-3" />
|
||
</Link>
|
||
) : null}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Capture forms */}
|
||
{isAgency && showCapture && pillar === 'seo' && (
|
||
<CaptureSeo
|
||
form={seoForm}
|
||
setForm={setSeoForm}
|
||
saving={saving}
|
||
onSave={() => void saveOrganicDay()}
|
||
/>
|
||
)}
|
||
{isAgency && showCapture && pillar === 'geo' && (
|
||
<CaptureGeo
|
||
form={geoForm}
|
||
setForm={setGeoForm}
|
||
saving={saving}
|
||
onSave={() => void saveGeo()}
|
||
/>
|
||
)}
|
||
|
||
{/* Shell: nav + content */}
|
||
<div className="grid grid-cols-1 lg:grid-cols-[240px_minmax(0,1fr)] gap-4 items-start">
|
||
<aside className="rounded-2xl border border-gray-200 bg-white p-3 shadow-sm lg:sticky lg:top-20 max-h-[calc(100vh-7rem)] overflow-y-auto">
|
||
<p className="px-2 pb-2 text-[10px] font-bold uppercase tracking-wider text-gray-500 flex items-center gap-1.5">
|
||
{pillar === 'geo' ? (
|
||
<>
|
||
<Bot className="w-3.5 h-3.5 text-brand-600" /> Análisis de IA
|
||
</>
|
||
) : (
|
||
<>
|
||
<Leaf className="w-3.5 h-3.5 text-lime-600" /> Suite SEO
|
||
</>
|
||
)}
|
||
</p>
|
||
{navGroups.map((g) => (
|
||
<div key={g.group} className="mb-3">
|
||
<p className="px-2 py-1 text-[10px] font-bold uppercase tracking-wider text-gray-400">
|
||
{g.group}
|
||
</p>
|
||
<ul className="space-y-0.5">
|
||
{g.items.map((item) => {
|
||
const active = item.id === sectionId;
|
||
return (
|
||
<li key={item.id}>
|
||
<button
|
||
type="button"
|
||
onClick={() => setSectionId(item.id)}
|
||
className={`w-full text-left px-2.5 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||
active
|
||
? 'bg-brand-50 text-brand-800 border border-brand-200'
|
||
: 'text-gray-700 hover:bg-gray-50 border border-transparent'
|
||
}`}
|
||
>
|
||
{item.label}
|
||
{item.soon ? (
|
||
<span className="ml-1 text-[9px] text-amber-700">
|
||
pronto
|
||
</span>
|
||
) : null}
|
||
</button>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
</div>
|
||
))}
|
||
</aside>
|
||
|
||
<div className="space-y-4 min-w-0">
|
||
<SectionHero section={section} />
|
||
|
||
{/* Paneles SEO por sección */}
|
||
{section.id === 'seo-overview' && (
|
||
<>
|
||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||
<Kpi label="Clics GSC" value={formatNum(totals.gscClicks)} />
|
||
<Kpi label="Impresiones" value={formatNum(totals.gscImpressions)} />
|
||
<Kpi label="CTR" value={formatPct(totals.gscCtr)} />
|
||
<Kpi
|
||
label="Posición media"
|
||
value={
|
||
totals.gscAvgPosition
|
||
? formatNum(totals.gscAvgPosition, 1)
|
||
: '—'
|
||
}
|
||
/>
|
||
<Kpi
|
||
label="Sesiones orgánicas"
|
||
value={formatNum(totals.ga4Organic)}
|
||
hint="GA4"
|
||
/>
|
||
<Kpi label="Usuarios" value={formatNum(totals.ga4Users)} />
|
||
<Kpi
|
||
label="Conversiones"
|
||
value={formatNum(totals.ga4Conversions)}
|
||
/>
|
||
<Kpi
|
||
label="Días con histórico"
|
||
value={formatNum(totals.days)}
|
||
hint="organic_daily"
|
||
/>
|
||
</div>
|
||
{seoDaily.length > 0 && (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||
<div className="rounded-xl border border-gray-200 bg-white p-3">
|
||
<p className="text-[10px] uppercase tracking-wider text-gray-500 mb-2">
|
||
Clics GSC · 28d
|
||
</p>
|
||
<div className="flex items-end gap-0.5 h-16">
|
||
{seoDaily.map((d) => (
|
||
<div key={`c-${d.day}`} className="flex-1" title={`${d.day}: ${d.clicks}`}>
|
||
<div
|
||
className="w-full rounded-t bg-emerald-400/80"
|
||
style={{ height: `${Math.max(3, (d.clicks / maxClicks) * 100)}%` }}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="rounded-xl border border-gray-200 bg-white p-3">
|
||
<p className="text-[10px] uppercase tracking-wider text-gray-500 mb-2">
|
||
Sesiones orgánicas GA4 · 28d
|
||
</p>
|
||
<div className="flex items-end gap-0.5 h-16">
|
||
{seoDaily.map((d) => (
|
||
<div key={`s-${d.day}`} className="flex-1" title={`${d.day}: ${d.sessions}`}>
|
||
<div
|
||
className="w-full rounded-t bg-sky-400/80"
|
||
style={{ height: `${Math.max(3, (d.sessions / maxSessions) * 100)}%` }}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{totals.days === 0 && (
|
||
<EmptyHint text="Sin filas en organic_daily. Sync GA4/GSC en Agencia." />
|
||
)}
|
||
<SeoTables
|
||
queries={gscQueries.slice(0, 8)}
|
||
pages={gscPages.slice(0, 8)}
|
||
landings={ga4Landings.slice(0, 8)}
|
||
/>
|
||
</>
|
||
)}
|
||
|
||
{section.live === 'seo-foundations' && (
|
||
<SeoAuditPanel
|
||
audits={seoAudits}
|
||
selectedId={selectedAuditId}
|
||
onSelect={setSelectedAuditId}
|
||
onRun={runSeoAudit}
|
||
auditing={auditing}
|
||
isAgency={isAgency}
|
||
gsc={gsc}
|
||
ga4={ga4}
|
||
/>
|
||
)}
|
||
|
||
{section.live === 'seo-rankings' && (
|
||
<div className="space-y-4">
|
||
<div className="rounded-xl border border-sky-200 bg-sky-50 px-3 py-2 text-xs text-sky-900">
|
||
<strong>Sí hay histórico:</strong> posición media diaria en{' '}
|
||
<code className="text-[10px]">organic_daily</code> (acumulamos
|
||
nosotros). Las queries actuales vienen live de GSC (ventana ~28d).
|
||
No es un rank tracker de 10k KW tipo Semrush.
|
||
</div>
|
||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
|
||
<Kpi
|
||
label="Posición media (periodo)"
|
||
value={
|
||
totals.gscAvgPosition
|
||
? formatNum(totals.gscAvgPosition, 1)
|
||
: '—'
|
||
}
|
||
/>
|
||
<Kpi label="Queries con datos" value={formatNum(gscQueries.length)} />
|
||
<Kpi
|
||
label="Días históricos"
|
||
value={formatNum(totals.days)}
|
||
hint="organic_daily"
|
||
/>
|
||
</div>
|
||
{seoDaily.length > 0 && (
|
||
<div className="rounded-xl border border-gray-200 bg-white p-3">
|
||
<p className="text-[10px] uppercase tracking-wider text-gray-500 mb-2">
|
||
Posición media GSC · histórico diario
|
||
</p>
|
||
<div className="flex items-end gap-0.5 h-20">
|
||
{organic
|
||
.filter((r) => r.day)
|
||
.sort((a, b) => String(a.day).localeCompare(String(b.day)))
|
||
.slice(-28)
|
||
.map((r) => {
|
||
const pos = Number(r.gsc_avg_position) || 0;
|
||
const h =
|
||
pos > 0 ? Math.max(8, Math.min(100, (1 / pos) * 100)) : 4;
|
||
return (
|
||
<div
|
||
key={`pos-${r.day}`}
|
||
className="flex-1"
|
||
title={`${String(r.day).slice(0, 10)}: pos ${pos || '—'}`}
|
||
>
|
||
<div
|
||
className="w-full rounded-t bg-violet-400/80"
|
||
style={{ height: `${h}%` }}
|
||
/>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
<p className="text-[10px] text-gray-500 mt-1">
|
||
Barras más altas ≈ mejor posición (inverso)
|
||
</p>
|
||
</div>
|
||
)}
|
||
<SeoTables
|
||
queries={[...gscQueries].sort((a, b) => a.position - b.position)}
|
||
pages={[]}
|
||
landings={[]}
|
||
focus="queries"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{section.live === 'seo-trend' && (
|
||
<div className="space-y-4">
|
||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||
<Kpi label="Clics" value={formatNum(totals.gscClicks)} />
|
||
<Kpi label="Impresiones" value={formatNum(totals.gscImpressions)} />
|
||
<Kpi label="CTR" value={formatPct(totals.gscCtr)} />
|
||
<Kpi
|
||
label="Sesiones orgánicas"
|
||
value={formatNum(totals.ga4Organic)}
|
||
/>
|
||
</div>
|
||
{seoDaily.length > 0 ? (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||
<div className="rounded-xl border border-gray-200 bg-white p-3">
|
||
<p className="text-[10px] uppercase tracking-wider text-gray-500 mb-2">
|
||
Clics · evolución
|
||
</p>
|
||
<div className="flex items-end gap-0.5 h-24">
|
||
{seoDaily.map((d) => (
|
||
<div key={`t-${d.day}`} className="flex-1" title={`${d.day}: ${d.clicks}`}>
|
||
<div
|
||
className="w-full rounded-t bg-emerald-500/80"
|
||
style={{ height: `${Math.max(3, (d.clicks / maxClicks) * 100)}%` }}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="rounded-xl border border-gray-200 bg-white p-3">
|
||
<p className="text-[10px] uppercase tracking-wider text-gray-500 mb-2">
|
||
Sesiones orgánicas · evolución
|
||
</p>
|
||
<div className="flex items-end gap-0.5 h-24">
|
||
{seoDaily.map((d) => (
|
||
<div key={`u-${d.day}`} className="flex-1" title={`${d.day}: ${d.sessions}`}>
|
||
<div
|
||
className="w-full rounded-t bg-sky-500/80"
|
||
style={{ height: `${Math.max(3, (d.sessions / maxSessions) * 100)}%` }}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<EmptyHint text="Sin serie diaria todavía." />
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{section.live === 'seo-pages' && (
|
||
<SeoTables
|
||
queries={[]}
|
||
pages={gscPages}
|
||
landings={ga4Landings}
|
||
focus="pages"
|
||
/>
|
||
)}
|
||
|
||
{section.live === 'seo-queries' && (
|
||
<SeoTables
|
||
queries={gscQueries}
|
||
pages={[]}
|
||
landings={[]}
|
||
focus="queries"
|
||
/>
|
||
)}
|
||
|
||
{section.live === 'seo-cross' && (
|
||
<div className="space-y-4">
|
||
<p className="text-xs text-gray-600">
|
||
Cruce: qué URLs rankean (GSC) vs qué landings reciben sesión
|
||
orgánica (GA4).
|
||
</p>
|
||
<SeoTables
|
||
queries={[]}
|
||
pages={gscPages}
|
||
landings={ga4Landings}
|
||
focus="pages"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{section.live === 'seo-content' && (
|
||
<div className="space-y-3">
|
||
<p className="text-xs text-gray-600">
|
||
Ideas a partir de queries con impresiones altas y CTR bajo
|
||
(oportunidad de mejorar snippet / contenido).
|
||
</p>
|
||
<SeoTables
|
||
queries={[...gscQueries]
|
||
.filter((q) => q.impressions >= 50)
|
||
.sort((a, b) => a.ctr - b.ctr)
|
||
.slice(0, 25)}
|
||
pages={[]}
|
||
landings={[]}
|
||
focus="queries"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{section.live === 'geo-metrics' &&
|
||
(section.id === 'geo-overview' ||
|
||
section.id === 'geo-brand' ||
|
||
section.id === 'geo-tracking') && (
|
||
<>
|
||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||
<Kpi
|
||
accent
|
||
label="Share of Model"
|
||
value={
|
||
latestGeo?.share_of_model != null
|
||
? formatPct(Number(latestGeo.share_of_model))
|
||
: '—'
|
||
}
|
||
hint={latestGeo ? undefined : 'Sin medición aún'}
|
||
/>
|
||
<Kpi
|
||
accent
|
||
label="Citas web oficial"
|
||
value={
|
||
latestGeo?.official_citation_pct != null
|
||
? formatPct(Number(latestGeo.official_citation_pct))
|
||
: '—'
|
||
}
|
||
/>
|
||
<Kpi
|
||
label="Sentimiento"
|
||
value={
|
||
latestGeo?.sentiment_score != null
|
||
? formatNum(Number(latestGeo.sentiment_score), 0)
|
||
: '—'
|
||
}
|
||
hint="0–100"
|
||
/>
|
||
<Kpi
|
||
label="Prompts analizados"
|
||
value={
|
||
latestGeo?.prompts_analyzed != null
|
||
? formatNum(Number(latestGeo.prompts_analyzed))
|
||
: '—'
|
||
}
|
||
/>
|
||
</div>
|
||
<div className="grid grid-cols-1 md: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-3 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-2 h-2 rounded-full bg-gray-100 overflow-hidden">
|
||
<div
|
||
className="h-full bg-brand-500"
|
||
style={{
|
||
width: `${Number.isFinite(v) ? Math.min(100, v) : 0}%`,
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
{!latestGeo && (
|
||
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 space-y-2">
|
||
<p className="text-sm font-semibold text-amber-900">
|
||
Dashboard GEO listo — falta la primera medición
|
||
</p>
|
||
<p className="text-xs text-amber-800">
|
||
A diferencia de SEO (GSC/GA4 por API), Share of Model se
|
||
captura con un estudio de prompts × motores. Usa las
|
||
prioridades expertas y registra el snapshot para rellenar
|
||
estas tarjetas.
|
||
</p>
|
||
{isAgency && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowCapture(true)}
|
||
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg bg-amber-800 text-white"
|
||
>
|
||
<Plus className="w-3.5 h-3.5" />
|
||
Registrar medición GEO
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{section.live === 'geo-competitors' && (
|
||
<CompetitorPanel latest={latestGeo} />
|
||
)}
|
||
|
||
{section.live === 'geo-categories' && (
|
||
<CategoryPanel latest={latestGeo} />
|
||
)}
|
||
|
||
{/* Prioridades + notas solo en visión general SEO/GEO */}
|
||
{(section.id === 'seo-overview' || section.id === 'geo-overview') && (
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||
<section className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm space-y-2">
|
||
<h3 className="text-sm font-semibold text-gray-900 flex items-center gap-2">
|
||
<Sparkles className="w-4 h-4 text-brand-600" />
|
||
Prioridades claras · {pillar === 'geo' ? 'GEO' : 'SEO'}
|
||
</h3>
|
||
<p className="text-[11px] text-gray-600">
|
||
Una por línea. Lo que hay que mejorar primero.
|
||
</p>
|
||
<ul className="space-y-1.5 mb-2">
|
||
{section.actions.map((a) => (
|
||
<li
|
||
key={a}
|
||
className="flex items-start gap-2 text-xs text-gray-700"
|
||
>
|
||
<ArrowRight className="w-3.5 h-3.5 text-brand-500 shrink-0 mt-0.5" />
|
||
{a}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
{isAgency ? (
|
||
<>
|
||
<textarea
|
||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm min-h-[88px]"
|
||
placeholder="Prioridad 1 Prioridad 2 …"
|
||
value={prioritiesText}
|
||
onChange={(e) => setPrioritiesText(e.target.value)}
|
||
/>
|
||
<button
|
||
type="button"
|
||
disabled={saving}
|
||
onClick={() => void savePriorities()}
|
||
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg bg-brand-500 text-white disabled:opacity-50"
|
||
>
|
||
{saving ? (
|
||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||
) : (
|
||
<Check className="w-3.5 h-3.5" />
|
||
)}
|
||
Guardar prioridades
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={saving}
|
||
onClick={() => {
|
||
const def =
|
||
pillar === 'geo'
|
||
? DEFAULT_EXPERT_PRIORITIES.geo
|
||
: DEFAULT_EXPERT_PRIORITIES.seo;
|
||
setPrioritiesText(def.join('\n'));
|
||
}}
|
||
className="ml-2 inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg border border-gray-200 text-gray-700 hover:bg-gray-50"
|
||
>
|
||
Plantilla experto
|
||
</button>
|
||
</>
|
||
) : (
|
||
<ul className="text-sm text-gray-800 space-y-1">
|
||
{displayPriorities.map((p) => (
|
||
<li key={p} className="flex gap-2">
|
||
<span className="text-brand-600">•</span>
|
||
{p}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</section>
|
||
|
||
<section className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm space-y-2">
|
||
<h3 className="text-sm font-semibold text-gray-900 flex items-center gap-2">
|
||
<Search className="w-4 h-4 text-brand-600" />
|
||
Notas · {section.label}
|
||
</h3>
|
||
<p className="text-[11px] text-gray-600">
|
||
Hallazgos, prompts, fuentes o gaps de esta sección.
|
||
</p>
|
||
{isAgency ? (
|
||
<>
|
||
<textarea
|
||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm min-h-[120px]"
|
||
value={sectionNote}
|
||
onChange={(e) => setSectionNote(e.target.value)}
|
||
placeholder="Ej. Rivales aparecen en prompts de «mejor X 2026»; nosotros no. Fuentes: farmacias online…"
|
||
/>
|
||
<button
|
||
type="button"
|
||
disabled={saving}
|
||
onClick={() => void saveSectionNote()}
|
||
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg border border-gray-200 text-gray-800 hover:bg-gray-50 disabled:opacity-50"
|
||
>
|
||
{saving ? (
|
||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||
) : (
|
||
<Check className="w-3.5 h-3.5" />
|
||
)}
|
||
Guardar nota
|
||
</button>
|
||
</>
|
||
) : (
|
||
<p className="text-sm text-gray-700 whitespace-pre-wrap min-h-[80px]">
|
||
{workspace.sectionNotes?.[sectionId] ||
|
||
'Sin notas todavía en esta sección.'}
|
||
</p>
|
||
)}
|
||
</section>
|
||
</div>
|
||
)}
|
||
|
||
{section.soon && (
|
||
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 space-y-2">
|
||
<p className="text-sm font-semibold text-amber-900">
|
||
Requiere herramienta externa
|
||
</p>
|
||
<p className="text-xs text-amber-800">
|
||
Competencia de dominio, keyword gap y backlinks no salen de
|
||
GSC/GA4. Hay que conectar Ahrefs/Semrush (o importar CSV) para
|
||
alimentar este panel. Mientras tanto usa las secciones con datos
|
||
reales: visión general, rankings, keywords y páginas.
|
||
</p>
|
||
<ul className="space-y-1.5 pt-1">
|
||
{section.actions.map((a) => (
|
||
<li
|
||
key={a}
|
||
className="flex items-start gap-2 text-xs text-amber-900"
|
||
>
|
||
<ArrowRight className="w-3.5 h-3.5 shrink-0 mt-0.5" />
|
||
{a}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
|
||
{!section.soon &&
|
||
section.id !== 'seo-overview' &&
|
||
section.id !== 'geo-overview' &&
|
||
!section.live && (
|
||
<div className="rounded-xl border border-gray-200 bg-white p-4 space-y-2">
|
||
<p className="text-sm font-semibold text-gray-900">
|
||
Acciones de esta sección
|
||
</p>
|
||
<ul className="space-y-1.5">
|
||
{section.actions.map((a) => (
|
||
<li
|
||
key={a}
|
||
className="flex items-start gap-2 text-xs text-gray-700"
|
||
>
|
||
<ArrowRight className="w-3.5 h-3.5 text-brand-500 shrink-0 mt-0.5" />
|
||
{a}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
|
||
function SeoTables({
|
||
queries,
|
||
pages,
|
||
landings,
|
||
focus = 'all',
|
||
}: {
|
||
queries: { key: string; clicks: number; impressions: number; ctr: number; position: number }[];
|
||
pages: { key: string; clicks: number; impressions: number; ctr: number; position: number }[];
|
||
landings: { page: string; sessions: number; users: number }[];
|
||
focus?: 'all' | 'queries' | 'pages';
|
||
}) {
|
||
const showQ = focus === 'all' || focus === 'queries';
|
||
const showP = focus === 'all' || focus === 'pages';
|
||
return (
|
||
<div className="space-y-4">
|
||
{showQ && (
|
||
<section className="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||
<div className="px-4 py-3 border-b border-gray-100">
|
||
<h4 className="text-sm font-semibold text-gray-900">
|
||
Top queries GSC (28d)
|
||
</h4>
|
||
</div>
|
||
{queries.length === 0 ? (
|
||
<p className="px-4 py-6 text-xs text-gray-500">
|
||
Sin queries. Verifica Search Console conectado y permisos.
|
||
</p>
|
||
) : (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-left text-sm">
|
||
<thead className="text-[10px] uppercase tracking-wider text-gray-500 bg-gray-50">
|
||
<tr>
|
||
<th className="px-4 py-2">Query</th>
|
||
<th className="px-3 py-2">Clics</th>
|
||
<th className="px-3 py-2">Imp.</th>
|
||
<th className="px-3 py-2">CTR</th>
|
||
<th className="px-3 py-2">Pos.</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{queries.slice(0, 20).map((r) => (
|
||
<tr key={r.key} className="border-t border-gray-100">
|
||
<td className="px-4 py-2 text-gray-800 max-w-[280px] truncate">
|
||
{r.key}
|
||
</td>
|
||
<td className="px-3 py-2 tabular-nums">{formatNum(r.clicks)}</td>
|
||
<td className="px-3 py-2 tabular-nums">
|
||
{formatNum(r.impressions)}
|
||
</td>
|
||
<td className="px-3 py-2 tabular-nums">{formatPct(r.ctr)}</td>
|
||
<td className="px-3 py-2 tabular-nums">
|
||
{formatNum(r.position, 1)}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</section>
|
||
)}
|
||
{showP && (
|
||
<section className="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||
<div className="px-4 py-3 border-b border-gray-100">
|
||
<h4 className="text-sm font-semibold text-gray-900">
|
||
Top páginas GSC (28d)
|
||
</h4>
|
||
</div>
|
||
{pages.length === 0 ? (
|
||
<p className="px-4 py-6 text-xs text-gray-500">Sin páginas GSC.</p>
|
||
) : (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-left text-sm">
|
||
<thead className="text-[10px] uppercase tracking-wider text-gray-500 bg-gray-50">
|
||
<tr>
|
||
<th className="px-4 py-2">URL</th>
|
||
<th className="px-3 py-2">Clics</th>
|
||
<th className="px-3 py-2">Imp.</th>
|
||
<th className="px-3 py-2">Pos.</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{pages.slice(0, 20).map((r) => (
|
||
<tr key={r.key} className="border-t border-gray-100">
|
||
<td className="px-4 py-2 text-gray-800 max-w-[320px] truncate">
|
||
{r.key}
|
||
</td>
|
||
<td className="px-3 py-2 tabular-nums">{formatNum(r.clicks)}</td>
|
||
<td className="px-3 py-2 tabular-nums">
|
||
{formatNum(r.impressions)}
|
||
</td>
|
||
<td className="px-3 py-2 tabular-nums">
|
||
{formatNum(r.position, 1)}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</section>
|
||
)}
|
||
{showP && (
|
||
<section className="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||
<div className="px-4 py-3 border-b border-gray-100">
|
||
<h4 className="text-sm font-semibold text-gray-900">
|
||
Landings orgánicas GA4 (28d)
|
||
</h4>
|
||
</div>
|
||
{landings.length === 0 ? (
|
||
<p className="px-4 py-6 text-xs text-gray-500">
|
||
Sin landings orgánicas GA4.
|
||
</p>
|
||
) : (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-left text-sm">
|
||
<thead className="text-[10px] uppercase tracking-wider text-gray-500 bg-gray-50">
|
||
<tr>
|
||
<th className="px-4 py-2">Landing</th>
|
||
<th className="px-3 py-2">Sesiones</th>
|
||
<th className="px-3 py-2">Usuarios</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{landings.slice(0, 20).map((r) => (
|
||
<tr key={r.page} className="border-t border-gray-100">
|
||
<td className="px-4 py-2 text-gray-800 max-w-[320px] truncate">
|
||
{r.page}
|
||
</td>
|
||
<td className="px-3 py-2 tabular-nums">
|
||
{formatNum(r.sessions)}
|
||
</td>
|
||
<td className="px-3 py-2 tabular-nums">{formatNum(r.users)}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</section>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SectionHero({ section }: { section: OrganicoSection }) {
|
||
return (
|
||
<div className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
|
||
<p className="text-[10px] font-bold uppercase tracking-wider text-gray-500">
|
||
{section.group}
|
||
</p>
|
||
<h2 className="text-lg font-bold text-gray-900 mt-1">{section.label}</h2>
|
||
<p className="text-sm text-gray-600 mt-2 leading-relaxed max-w-3xl">
|
||
{section.blurb}
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function auditStatusClass(status?: string) {
|
||
if (status === 'ok') return 'border-emerald-200 bg-emerald-50 text-emerald-900';
|
||
if (status === 'critical') return 'border-red-200 bg-red-50 text-red-900';
|
||
if (status === 'warn') return 'border-amber-200 bg-amber-50 text-amber-950';
|
||
return 'border-sky-100 bg-sky-50 text-sky-900';
|
||
}
|
||
|
||
function auditStatusLabel(status?: string) {
|
||
if (status === 'ok') return 'OK';
|
||
if (status === 'critical') return 'Crítico';
|
||
if (status === 'warn') return 'Aviso';
|
||
return 'Info';
|
||
}
|
||
|
||
function SeoAuditPanel({
|
||
audits,
|
||
selectedId,
|
||
onSelect,
|
||
onRun,
|
||
auditing,
|
||
isAgency,
|
||
gsc,
|
||
ga4,
|
||
}: {
|
||
audits: SeoAuditRow[];
|
||
selectedId: string | null;
|
||
onSelect: (id: string) => void;
|
||
onRun: () => void;
|
||
auditing: boolean;
|
||
isAgency: boolean;
|
||
gsc?: IntegrationLite;
|
||
ga4?: IntegrationLite;
|
||
}) {
|
||
const selected =
|
||
audits.find((a) => a.id === selectedId) || audits[0] || null;
|
||
const criticals =
|
||
selected?.checks?.filter((c) => c.status === 'critical') || [];
|
||
const warns = selected?.checks?.filter((c) => c.status === 'warn') || [];
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
<div>
|
||
<p className="text-xs text-gray-600">
|
||
Auditoría técnica real: HTTPS, robots, sitemap, title/H1/meta,
|
||
indexación y top páginas GSC. Cada ejecución queda en el histórico.
|
||
</p>
|
||
<p className="text-[11px] text-gray-500 mt-1">
|
||
GSC:{' '}
|
||
{gsc?.status === 'connected' ? 'conectado' : 'sin conectar'}
|
||
{gsc?.external_account_id
|
||
? ` · ${gsc.external_account_id}`
|
||
: ''}{' '}
|
||
· GA4:{' '}
|
||
{ga4?.status === 'connected' ? 'conectado' : 'sin conectar'}
|
||
</p>
|
||
</div>
|
||
{isAgency ? (
|
||
<button
|
||
type="button"
|
||
disabled={auditing}
|
||
onClick={onRun}
|
||
className="inline-flex items-center gap-1.5 text-sm font-semibold px-4 py-2 rounded-lg bg-brand-500 text-white disabled:opacity-50"
|
||
>
|
||
{auditing ? (
|
||
<Loader2 className="w-4 h-4 animate-spin" />
|
||
) : (
|
||
<Search className="w-4 h-4" />
|
||
)}
|
||
{auditing ? 'Auditando…' : 'Ejecutar auditoría'}
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
|
||
{!selected ? (
|
||
<EmptyHint
|
||
text={
|
||
isAgency
|
||
? 'Aún no hay auditorías. Pulsa «Ejecutar auditoría» para generar la primera y guardar el histórico.'
|
||
: 'Aún no hay auditorías de sitio para este cliente.'
|
||
}
|
||
/>
|
||
) : (
|
||
<>
|
||
{(Number(selected.critical_count) > 0 ||
|
||
Number(selected.warning_count) > 0) && (
|
||
<div
|
||
className={`rounded-xl border px-3 py-2.5 text-sm flex gap-2 items-start ${
|
||
Number(selected.critical_count) > 0
|
||
? 'border-red-200 bg-red-50 text-red-900'
|
||
: 'border-amber-200 bg-amber-50 text-amber-950'
|
||
}`}
|
||
>
|
||
{Number(selected.critical_count) > 0 ? (
|
||
<ShieldAlert className="w-4 h-4 mt-0.5 shrink-0" />
|
||
) : (
|
||
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
|
||
)}
|
||
<div>
|
||
<p className="font-semibold">
|
||
{Number(selected.critical_count) > 0
|
||
? `${selected.critical_count} problema(s) crítico(s)`
|
||
: `${selected.warning_count} aviso(s)`}
|
||
</p>
|
||
<p className="text-xs mt-0.5 opacity-90">
|
||
{selected.summary ||
|
||
'Revisa los checks marcados abajo antes del siguiente sprint SEO.'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||
<Kpi
|
||
label="Nota"
|
||
value={`${formatNum(Number(selected.score) || 0)}/100`}
|
||
hint={`Grado ${selected.grade || '—'}`}
|
||
accent
|
||
/>
|
||
<Kpi
|
||
label="Críticos"
|
||
value={formatNum(Number(selected.critical_count) || 0)}
|
||
/>
|
||
<Kpi
|
||
label="Avisos"
|
||
value={formatNum(Number(selected.warning_count) || 0)}
|
||
/>
|
||
<Kpi
|
||
label="Sitio"
|
||
value={(selected.site_url || '—').replace(/^https?:\/\//, '').slice(0, 22)}
|
||
hint={
|
||
selected.created_at
|
||
? new Date(selected.created_at).toLocaleString('es-ES')
|
||
: undefined
|
||
}
|
||
/>
|
||
</div>
|
||
|
||
{audits.length > 1 && (
|
||
<div className="rounded-xl border border-gray-200 bg-white p-3">
|
||
<p className="text-[10px] uppercase tracking-wider text-gray-500 mb-2">
|
||
Histórico ({audits.length})
|
||
</p>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{audits.map((a) => {
|
||
const active = (selected?.id || '') === a.id;
|
||
const bad = Number(a.critical_count) > 0;
|
||
return (
|
||
<button
|
||
key={a.id || a.created_at}
|
||
type="button"
|
||
onClick={() => a.id && onSelect(a.id)}
|
||
className={`text-left rounded-lg border px-2.5 py-1.5 text-xs transition ${
|
||
active
|
||
? 'border-brand-400 bg-brand-50 text-brand-900'
|
||
: 'border-gray-200 bg-gray-50 text-gray-700 hover:border-gray-300'
|
||
}`}
|
||
>
|
||
<span className="font-semibold tabular-nums">
|
||
{a.score}/100 · {a.grade}
|
||
</span>
|
||
{bad ? (
|
||
<span className="ml-1 text-red-600">!</span>
|
||
) : null}
|
||
<span className="block text-[10px] text-gray-500">
|
||
{a.created_at
|
||
? new Date(a.created_at).toLocaleDateString('es-ES', {
|
||
day: '2-digit',
|
||
month: 'short',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
})
|
||
: '—'}
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="space-y-2">
|
||
<p className="text-[10px] uppercase tracking-wider text-gray-500">
|
||
Checks ({selected.checks?.length || 0})
|
||
{criticals.length || warns.length
|
||
? ` · ${criticals.length} críticos · ${warns.length} avisos`
|
||
: ''}
|
||
</p>
|
||
<ul className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||
{(selected.checks || []).map((c) => (
|
||
<li
|
||
key={c.id}
|
||
className={`rounded-lg border px-3 py-2.5 ${auditStatusClass(c.status)}`}
|
||
>
|
||
<div className="flex items-start justify-between gap-2">
|
||
<p className="text-sm font-semibold">
|
||
{c.title}
|
||
<span className="ml-2 text-[10px] uppercase opacity-80">
|
||
{auditStatusLabel(c.status)}
|
||
</span>
|
||
</p>
|
||
<span className="text-[10px] uppercase tracking-wide opacity-70 shrink-0">
|
||
{c.category}
|
||
</span>
|
||
</div>
|
||
<p className="text-[11px] mt-0.5 opacity-90 leading-snug">
|
||
{c.detail}
|
||
</p>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function EmptyHint({ text }: { text: string }) {
|
||
return (
|
||
<p className="text-sm text-gray-600 border border-dashed border-gray-300 rounded-xl px-4 py-6 text-center bg-gray-50">
|
||
{text}
|
||
</p>
|
||
);
|
||
}
|
||
|
||
function CompetitorPanel({ latest }: { latest: GeoSnapshot | null }) {
|
||
if (!latest?.competitors?.length) {
|
||
return (
|
||
<EmptyHint text="Sin ranking competitivo. Añade marcas y Share of Model en una medición GEO." />
|
||
);
|
||
}
|
||
return (
|
||
<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 Voice / Model
|
||
</h3>
|
||
<ul className="space-y-2">
|
||
{latest.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 font-medium text-gray-700">
|
||
{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>
|
||
);
|
||
}
|
||
|
||
function CategoryPanel({ latest }: { latest: GeoSnapshot | null }) {
|
||
if (!latest?.categories?.length) {
|
||
return (
|
||
<EmptyHint text="Sin categorías / intenciones. Añádelas en la medición GEO (ej. facial, niños, compra…)." />
|
||
);
|
||
}
|
||
return (
|
||
<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">
|
||
Visibilidad por intención
|
||
</h3>
|
||
<ul className="space-y-2">
|
||
{latest.categories.map((c, i) => (
|
||
<li
|
||
key={`${c.category}-${i}`}
|
||
className="flex 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>
|
||
);
|
||
}
|
||
|
||
function CaptureSeo({
|
||
form,
|
||
setForm,
|
||
saving,
|
||
onSave,
|
||
}: {
|
||
form: {
|
||
day: string;
|
||
gsc_clicks: string;
|
||
gsc_impressions: string;
|
||
gsc_avg_position: string;
|
||
ga4_organic_sessions: string;
|
||
ga4_sessions: string;
|
||
ga4_users: string;
|
||
ga4_conversions: string;
|
||
};
|
||
setForm: React.Dispatch<
|
||
React.SetStateAction<{
|
||
day: string;
|
||
gsc_clicks: string;
|
||
gsc_impressions: string;
|
||
gsc_avg_position: string;
|
||
ga4_organic_sessions: string;
|
||
ga4_sessions: string;
|
||
ga4_users: string;
|
||
ga4_conversions: string;
|
||
}>
|
||
>;
|
||
saving: boolean;
|
||
onSave: () => void;
|
||
}) {
|
||
const fields = [
|
||
['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;
|
||
return (
|
||
<section className="rounded-xl border border-brand-200 bg-brand-50/50 p-4 space-y-3">
|
||
<h3 className="text-sm font-semibold text-gray-900">Cargar día SEO</h3>
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||
{fields.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={form[key]}
|
||
onChange={(e) =>
|
||
setForm((s) => ({ ...s, [key]: e.target.value }))
|
||
}
|
||
/>
|
||
</label>
|
||
))}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
disabled={saving}
|
||
onClick={onSave}
|
||
className="inline-flex items-center gap-1.5 text-sm font-semibold px-4 py-2 rounded-lg bg-brand-500 text-white disabled:opacity-50"
|
||
>
|
||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
|
||
Guardar día
|
||
</button>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function CaptureGeo({
|
||
form,
|
||
setForm,
|
||
saving,
|
||
onSave,
|
||
}: {
|
||
form: {
|
||
label: string;
|
||
share_of_model: string;
|
||
chatgpt: string;
|
||
gemini: string;
|
||
ai_overviews: string;
|
||
official_citation_pct: string;
|
||
sentiment_score: string;
|
||
prompts_analyzed: string;
|
||
responses_analyzed: string;
|
||
study_url: string;
|
||
notes: string;
|
||
competitors: string;
|
||
categories: string;
|
||
};
|
||
setForm: React.Dispatch<React.SetStateAction<typeof form>>;
|
||
saving: boolean;
|
||
onSave: () => void;
|
||
}) {
|
||
return (
|
||
<section className="rounded-xl border border-brand-200 bg-white p-4 space-y-3 shadow-sm">
|
||
<h3 className="text-sm font-semibold text-gray-900">Nueva medición GEO</h3>
|
||
<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={form.label}
|
||
onChange={(e) => setForm((s) => ({ ...s, label: e.target.value }))}
|
||
/>
|
||
</label>
|
||
{(
|
||
[
|
||
['share_of_model', 'Share of Model %'],
|
||
['official_citation_pct', 'Citas oficial %'],
|
||
['sentiment_score', 'Sentimiento'],
|
||
['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={form[key]}
|
||
onChange={(e) =>
|
||
setForm((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, %)
|
||
</span>
|
||
<textarea
|
||
className="w-full rounded-lg border border-gray-200 px-2.5 py-1.5 text-sm min-h-[80px] font-mono"
|
||
value={form.competitors}
|
||
onChange={(e) =>
|
||
setForm((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, %)
|
||
</span>
|
||
<textarea
|
||
className="w-full rounded-lg border border-gray-200 px-2.5 py-1.5 text-sm min-h-[80px] font-mono"
|
||
value={form.categories}
|
||
onChange={(e) =>
|
||
setForm((s) => ({ ...s, categories: e.target.value }))
|
||
}
|
||
/>
|
||
</label>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
disabled={saving}
|
||
onClick={onSave}
|
||
className="inline-flex items-center gap-1.5 text-sm font-semibold px-4 py-2 rounded-lg bg-brand-500 text-white disabled:opacity-50"
|
||
>
|
||
{saving ? (
|
||
<Loader2 className="w-4 h-4 animate-spin" />
|
||
) : (
|
||
<Sparkles className="w-4 h-4" />
|
||
)}
|
||
Guardar medición
|
||
</button>
|
||
</section>
|
||
);
|
||
}
|