feat: OAuth Google unificado (Drive+GA4+GSC) y sync a Orgánico
Conectar marketing@ con scopes Analytics/GSC; descubrir propiedades, emparejar fichas y sincronizar organic_daily 28d. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
254
src/components/GoogleOrganicPanel.tsx
Normal file
254
src/components/GoogleOrganicPanel.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
'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();
|
||||
setBusy(null);
|
||||
if (!res.ok) {
|
||||
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} (de ${data.ga4Count} props / ${data.gscCount} sites)`
|
||||
: `Vistas ${data.ga4Count} props GA4 · ${data.gscCount} sites GSC (sin escribir)`
|
||||
);
|
||||
}
|
||||
|
||||
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 || 'Sync falló');
|
||||
return;
|
||||
}
|
||||
const wrote = (data.results || []).reduce(
|
||||
(a: number, r: { daysWritten?: number }) => a + (r.daysWritten || 0),
|
||||
0
|
||||
);
|
||||
setMsg(
|
||||
`Sync orgánico: ${data.synced || 0} clientes · ${wrote} días escritos en organic_daily`
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
Google orgánico · GA4 + Search Console
|
||||
</h3>
|
||||
<p className="text-xs text-gray-600 mt-0.5 max-w-xl">
|
||||
Usa el mismo OAuth de{' '}
|
||||
<strong className="text-gray-800">marketing@4ventures.es</strong>{' '}
|
||||
(Drive + Analytics + GSC). Empareja propiedades/sites a fichas y
|
||||
sincroniza a Orgánico.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<a
|
||||
href="/api/admin/drive/connect"
|
||||
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"
|
||||
>
|
||||
{connected ? 'Reconectar Google' : 'Conectar Google'}
|
||||
</a>
|
||||
<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" />
|
||||
)}
|
||||
Sync 28d → Orgánico
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{connected ? (
|
||||
<p className="text-xs text-emerald-700">
|
||||
Conectado{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">
|
||||
Conecta Google (botón arriba o panel Drive). Debe aceptar scopes de
|
||||
Analytics y 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user