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:
4Ventures
2026-09-24 12:04:05 +02:00
parent 81ede58330
commit aded4c3514
7 changed files with 1034 additions and 9 deletions

View File

@@ -4,6 +4,7 @@ import React, { useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import DriveConnectPanel from '@/components/DriveConnectPanel';
import AdsDiscoverPanel from '@/components/AdsDiscoverPanel';
import GoogleOrganicPanel from '@/components/GoogleOrganicPanel';
import SuperhumanSyncPanel from '@/components/SuperhumanSyncPanel';
import {
AlertTriangle,
@@ -181,6 +182,7 @@ export default function AgencyPortfolioBoard() {
return (
<div className="space-y-6">
<DriveConnectPanel />
<GoogleOrganicPanel />
<AdsDiscoverPanel />
<SuperhumanSyncPanel />

View File

@@ -92,11 +92,12 @@ export default function DriveConnectPanel() {
<HardDrive className="w-5 h-5 text-brand-400 mt-0.5 shrink-0" />
<div>
<h3 className="text-sm font-semibold text-gray-900">
Google Drive (agencia)
Google agencia (Drive + GA4 + GSC)
</h3>
<p className="text-xs text-gray-400 mt-0.5 max-w-xl">
Conecta <strong className="text-gray-600">marketing@4ventures.es</strong>.
Emparejamos carpetas con clientes por nombre automáticamente.
<p className="text-xs text-gray-600 mt-0.5 max-w-xl">
Conecta <strong className="text-gray-800">marketing@4ventures.es</strong>.
Concede Drive, Analytics y Search Console. Luego usa el panel
orgánico para emparejar y sincronizar.
</p>
</div>
</div>
@@ -121,7 +122,7 @@ export default function DriveConnectPanel() {
href="/api/admin/drive/connect"
className="inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg bg-brand-500/90 hover:bg-brand-500 text-white"
>
{connected ? 'Reconectar' : 'Conectar Drive'}
{connected ? 'Reconectar Google' : 'Conectar Google'}
</a>
) : (
<span className="text-[11px] text-amber-400 px-2 py-1.5">

View 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>
);
}