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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user