feat: auditoría SEO real con histórico y avisos

Crawl técnico (HTTPS/robots/sitemap/on-page) + tabla seo_audits; UI con nota, checks y alertas.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-24 16:55:58 +02:00
parent d896041bed
commit 06024cf473
6 changed files with 1008 additions and 67 deletions

View File

@@ -3,6 +3,25 @@
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,
@@ -11,27 +30,10 @@ import {
Loader2,
Plus,
Search,
ShieldAlert,
Sparkles,
} 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,
DEFAULT_EXPERT_PRIORITIES,
} from '@/lib/organic';
import { isoYesterday } from '@/lib/ad-tracker-range';
type Props = {
label: string;
@@ -103,6 +105,9 @@ export default function OrganicoDashboard({
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);
@@ -147,6 +152,10 @@ export default function OrganicoDashboard({
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(
@@ -282,6 +291,26 @@ export default function OrganicoDashboard({
});
}
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);
@@ -624,49 +653,16 @@ export default function OrganicoDashboard({
)}
{section.live === 'seo-foundations' && (
<div className="space-y-3">
<p className="text-xs text-gray-600">
Checklist de auditoría operativa. El estado «OK» de medición
refleja si GA4 + GSC están conectados; el resto lo cierra el equipo.
</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-white'
}`}
>
<p className="text-sm font-semibold text-gray-900">
{f.title}
{ok ? (
<span className="ml-2 text-[10px] uppercase text-emerald-700">
OK
</span>
) : (
<span className="ml-2 text-[10px] uppercase text-amber-700">
Revisar
</span>
)}
</p>
<p className="text-[11px] text-gray-600 mt-0.5">{f.hint}</p>
</li>
);
})}
</ul>
<div className="rounded-xl border border-gray-200 bg-white p-3 text-xs text-gray-700 space-y-1">
<p className="font-semibold text-gray-900">Estado conexiones</p>
<p>GSC: {gsc?.status === 'connected' ? 'conectado' : 'sin conectar'}{gsc?.external_account_id ? ` · ${gsc.external_account_id}` : ''}</p>
<p>GA4: {ga4?.status === 'connected' ? 'conectado' : 'sin conectar'}{ga4?.external_account_id ? ` · ${ga4.external_account_id}` : ''}</p>
</div>
</div>
<SeoAuditPanel
audits={seoAudits}
selectedId={selectedAuditId}
onSelect={setSelectedAuditId}
onRun={runSeoAudit}
auditing={auditing}
isAgency={isAgency}
gsc={gsc}
ga4={ga4}
/>
)}
{section.live === 'seo-rankings' && (
@@ -1264,6 +1260,224 @@ function SectionHero({ section }: { section: OrganicoSection }) {
);
}
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">