Files
4ventures-client-portal/src/components/OrganicoDashboard.tsx
4Ventures e3180ceb51 feat: sync Clientify CRM + botón Sincronizar por pestaña
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>
2026-09-30 15:34:40 +02:00

1744 lines
62 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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&#10;Prioridad 2&#10;…"
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>
);
}