El panel orgánico ya no ofrece Conectar: solo emparejar/actualizar sobre el OAuth del panel 1. Co-authored-by: Cursor <cursoragent@cursor.com>
253 lines
8.5 KiB
TypeScript
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>
|
|
);
|
|
}
|