Files
4ventures-client-portal/src/components/GoogleOrganicPanel.tsx
4Ventures 5d04a79de7 fix: una sola conexión Google en Agencia (quitar botón duplicado)
El panel orgánico ya no ofrece Conectar: solo emparejar/actualizar
sobre el OAuth del panel 1.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 12:13:28 +02:00

253 lines
8.5 KiB
TypeScript

'use client';
import React, { useEffect, useState } from 'react';
import {
Leaf,
Loader2,
RefreshCw,
Search,
Sparkles,
} from 'lucide-react';
type MatchRow = {
tenantId: string;
tenantName: string;
tenantSlug: string;
ga4?: {
propertyId: string;
measurementId: string | null;
displayName: string;
score: number;
} | null;
gsc?: { siteUrl: string; score: number } | null;
};
export default function GoogleOrganicPanel() {
const [loading, setLoading] = useState(true);
const [connected, setConnected] = useState(false);
const [email, setEmail] = useState<string | null>(null);
const [hasAnalytics, setHasAnalytics] = useState(false);
const [hasGsc, setHasGsc] = useState(false);
const [ga4Count, setGa4Count] = useState(0);
const [gscCount, setGscCount] = useState(0);
const [matches, setMatches] = useState<MatchRow[] | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [msg, setMsg] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null);
async function loadStatus() {
setLoading(true);
setErr(null);
const res = await fetch('/api/admin/google/organic');
const data = await res.json();
setConnected(Boolean(data.connected));
setEmail(data.email || null);
setHasAnalytics(Boolean(data.hasAnalytics));
setHasGsc(Boolean(data.hasSearchConsole));
setGa4Count((data.ga4 || []).length);
setGscCount((data.gsc || []).length);
if (data.warning) setMsg(data.warning);
if (!res.ok && data.error) setErr(data.error);
setLoading(false);
}
useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (params.get('drive') === 'ok') {
setMsg(
'Google reconectado. Pulsa «Descubrir y emparejar» para GA4/GSC.'
);
}
void loadStatus();
}, []);
async function discover(apply: boolean) {
setBusy('match');
setErr(null);
const res = await fetch('/api/admin/google/organic', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'match', apply }),
});
const data = await res.json();
if (!res.ok) {
setBusy(null);
setErr(data.error || 'Discover falló');
return;
}
setMatches(data.matches || []);
setGa4Count(data.ga4Count || 0);
setGscCount(data.gscCount || 0);
setMsg(
apply
? `Emparejado · GA4 ${data.applied?.ga4 || 0} · GSC ${data.applied?.gsc || 0}. Actualizando datos…`
: `Vistas ${data.ga4Count} props GA4 · ${data.gscCount} sites GSC (sin escribir)`
);
if (apply) {
await syncAll();
} else {
setBusy(null);
}
}
async function syncAll() {
setBusy('sync');
setErr(null);
const res = await fetch('/api/admin/google/organic', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'sync', days: 28 }),
});
const data = await res.json();
setBusy(null);
if (!res.ok) {
setErr(data.error || 'Actualización falló');
return;
}
const wrote = (data.results || []).reduce(
(a: number, r: { daysWritten?: number }) => a + (r.daysWritten || 0),
0
);
setMsg(
`Datos al día · ${data.synced || 0} clientes · ${wrote} días en Orgánico (se actualiza solo al abrir la vista y cada noche)`
);
}
if (loading) {
return (
<p className="text-sm text-gray-500 flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" /> Google orgánico…
</p>
);
}
return (
<section className="rounded-xl border border-gray-200 bg-white p-5 space-y-4 shadow-sm">
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-3">
<div className="flex items-start gap-3">
<Leaf className="w-5 h-5 text-lime-600 mt-0.5 shrink-0" />
<div>
<h3 className="text-sm font-semibold text-gray-900">
2 · Orgánico (GA4 + Search Console)
</h3>
<p className="text-xs text-gray-600 mt-0.5 max-w-xl">
No es otra cuenta: usa la conexión del panel 1. Aquí solo
emparejas propiedades/sites a cada cliente y actualizas datos.
Luego se mantienen al día solos.
</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
disabled={!connected || busy !== null}
onClick={() => void discover(true)}
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-40"
>
{busy === 'match' ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Search className="w-3.5 h-3.5" />
)}
Descubrir y emparejar
</button>
<button
type="button"
disabled={!connected || busy !== null}
onClick={() => void syncAll()}
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg border border-lime-300 text-lime-800 bg-lime-50 hover:bg-lime-100 disabled:opacity-40"
>
{busy === 'sync' ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<RefreshCw className="w-3.5 h-3.5" />
)}
Actualizar ahora
</button>
</div>
</div>
{connected ? (
<p className="text-xs text-emerald-700">
Misma conexión{email ? ` · ${email}` : ''}
{hasAnalytics ? ' · Analytics ✓' : ' · Analytics ✗'}
{hasGsc ? ' · GSC ✓' : ' · GSC ✗'}
{ga4Count || gscCount
? ` · vistas: ${ga4Count} props / ${gscCount} sites`
: ''}
</p>
) : (
<p className="text-xs text-amber-800 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
Primero pulsa «Conectar Google» en el panel 1 (arriba) y acepta
Analytics + Search Console además de Drive.
</p>
)}
{msg && (
<p className="text-xs text-gray-700 flex items-start gap-1.5">
<Sparkles className="w-3.5 h-3.5 text-brand-600 shrink-0 mt-0.5" />
{msg}
</p>
)}
{err && (
<p className="text-xs text-red-700 bg-red-50 border border-red-200 rounded-lg px-3 py-2">
{err}
</p>
)}
{matches && matches.length > 0 && (
<div className="overflow-x-auto max-h-80 overflow-y-auto border border-gray-100 rounded-lg">
<table className="w-full text-left text-xs">
<thead className="text-gray-600 border-b border-gray-200 sticky top-0 bg-white">
<tr>
<th className="py-2 px-3 font-semibold">Cliente</th>
<th className="py-2 px-3 font-semibold">GA4</th>
<th className="py-2 px-3 font-semibold">GSC</th>
</tr>
</thead>
<tbody>
{matches.map((m) => (
<tr
key={m.tenantId}
className="border-b border-gray-50 hover:bg-gray-50"
>
<td className="py-2 px-3 font-medium text-gray-900">
{m.tenantName}
</td>
<td className="py-2 px-3 text-gray-700">
{m.ga4 ? (
<>
{m.ga4.displayName}
<span className="text-gray-400">
{' '}
· {m.ga4.measurementId || m.ga4.propertyId} · score{' '}
{m.ga4.score}
</span>
</>
) : (
<span className="text-gray-400">—</span>
)}
</td>
<td className="py-2 px-3 text-gray-700">
{m.gsc ? (
<>
{m.gsc.siteUrl}
<span className="text-gray-400">
{' '}
· score {m.gsc.score}
</span>
</>
) : (
<span className="text-gray-400">—</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
}