Series IG/FB (alcance, impresiones, clics, engagement, fans) según el rango del header; Meta y Google quedan como tokens separados. Co-authored-by: Cursor <cursoragent@cursor.com>
319 lines
11 KiB
TypeScript
319 lines
11 KiB
TypeScript
'use client';
|
|
|
|
import React, { useEffect, useState } from 'react';
|
|
import { Loader2, Megaphone, RefreshCw } from 'lucide-react';
|
|
|
|
type MatchRow = {
|
|
tenantId: string;
|
|
tenantName: string;
|
|
tenantSlug: string;
|
|
accountId: string | null;
|
|
accountName: string | null;
|
|
score: number;
|
|
status: string;
|
|
candidates?: { id: string; name: string; score: number }[];
|
|
};
|
|
|
|
type ProviderResult = {
|
|
accounts: number;
|
|
linked: number;
|
|
matches: MatchRow[];
|
|
error?: string;
|
|
};
|
|
|
|
export default function AdsDiscoverPanel() {
|
|
const [metaOk, setMetaOk] = useState(false);
|
|
const [metaPending, setMetaPending] = useState(true);
|
|
const [gadsOk, setGadsOk] = useState(false);
|
|
const [loading, setLoading] = useState(true);
|
|
const [running, setRunning] = useState(false);
|
|
const [syncing, setSyncing] = useState(false);
|
|
const [savingToken, setSavingToken] = useState(false);
|
|
const [metaToken, setMetaToken] = useState('');
|
|
const [meta, setMeta] = useState<ProviderResult | null>(null);
|
|
const [google, setGoogle] = useState<ProviderResult | null>(null);
|
|
const [msg, setMsg] = useState<string | null>(null);
|
|
const [err, setErr] = useState<string | null>(null);
|
|
|
|
async function loadStatus() {
|
|
setLoading(true);
|
|
const res = await fetch('/api/admin/ads/status');
|
|
const data = await res.json();
|
|
setMetaOk(Boolean(data.meta?.configured));
|
|
setMetaPending(Boolean(data.meta?.pendingReview));
|
|
setGadsOk(Boolean(data.googleAds?.configured));
|
|
setLoading(false);
|
|
}
|
|
|
|
useEffect(() => {
|
|
void loadStatus();
|
|
}, []);
|
|
|
|
async function saveToken() {
|
|
setSavingToken(true);
|
|
setErr(null);
|
|
const res = await fetch('/api/admin/ads/meta-token', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ token: metaToken }),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
setErr(data.error || 'No se pudo guardar el token');
|
|
} else {
|
|
setMsg(`Token Meta guardado${data.name ? ` · ${data.name}` : ''}`);
|
|
setMetaToken('');
|
|
await loadStatus();
|
|
}
|
|
setSavingToken(false);
|
|
}
|
|
|
|
async function discover(providers?: Array<'meta_ads' | 'google_ads'>) {
|
|
setRunning(true);
|
|
setErr(null);
|
|
setMsg(null);
|
|
const res = await fetch('/api/admin/ads/discover', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ apply: true, providers }),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
setErr(data.error || 'Discover falló');
|
|
} else {
|
|
setMeta(data.meta || null);
|
|
setGoogle(data.google || null);
|
|
const parts = [];
|
|
if (data.meta) {
|
|
parts.push(
|
|
`Meta Ads: ${data.meta.linked}/${data.meta.accounts}` +
|
|
(data.meta.pagesLinked
|
|
? ` · FB ${data.meta.pagesLinked}`
|
|
: '') +
|
|
(data.meta.igLinked ? ` · IG ${data.meta.igLinked}` : '') +
|
|
(data.meta.error ? ` (${data.meta.error})` : '')
|
|
);
|
|
}
|
|
if (data.google) {
|
|
parts.push(
|
|
`Google Ads: ${data.google.linked}/${data.google.accounts} enlazadas` +
|
|
(data.google.error ? ` (${data.google.error})` : '')
|
|
);
|
|
}
|
|
setMsg(parts.join(' · '));
|
|
}
|
|
setRunning(false);
|
|
}
|
|
|
|
async function syncMetrics() {
|
|
setSyncing(true);
|
|
setErr(null);
|
|
setMsg(null);
|
|
const res = await fetch('/api/admin/ads/sync', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ days: 30 }),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
setErr(data.error || 'Sync falló');
|
|
} else {
|
|
const parts = (data.results || [])
|
|
.filter(
|
|
(r: { metaRows?: number; googleRows?: number }) =>
|
|
(r.metaRows || 0) + (r.googleRows || 0) > 0
|
|
)
|
|
.map(
|
|
(r: {
|
|
tenantName: string;
|
|
metaRows: number;
|
|
googleRows: number;
|
|
}) =>
|
|
`${r.tenantName}: M${r.metaRows}/G${r.googleRows}`
|
|
);
|
|
setMsg(
|
|
`Métricas sincronizadas (${data.synced || 0} clientes, ${data.days || 30}d)` +
|
|
(parts.length ? ` · ${parts.slice(0, 6).join(' · ')}` : '') +
|
|
(data.withErrors ? ` · ${data.withErrors} con avisos` : '')
|
|
);
|
|
}
|
|
setSyncing(false);
|
|
}
|
|
|
|
async function assign(
|
|
provider: 'meta_ads' | 'google_ads',
|
|
tenantId: string,
|
|
accountId: string,
|
|
accountName: string
|
|
) {
|
|
await fetch('/api/admin/ads/discover', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
assign: { provider, tenantId, accountId, accountName },
|
|
}),
|
|
});
|
|
await discover([provider]);
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<p className="text-sm text-gray-500 flex items-center gap-2">
|
|
<Loader2 className="w-4 h-4 animate-spin" /> Ads…
|
|
</p>
|
|
);
|
|
}
|
|
|
|
const ambiguous = [
|
|
...(meta?.matches || [])
|
|
.filter((m) => m.status === 'ambiguous')
|
|
.map((m) => ({ ...m, provider: 'meta_ads' as const })),
|
|
...(google?.matches || [])
|
|
.filter((m) => m.status === 'ambiguous')
|
|
.map((m) => ({ ...m, provider: 'google_ads' as const })),
|
|
];
|
|
|
|
return (
|
|
<section className="rounded-xl border border-gray-200 bg-white p-5 space-y-4">
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
|
<div className="flex items-start gap-3">
|
|
<Megaphone className="w-5 h-5 text-brand-400 mt-0.5 shrink-0" />
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-gray-900">
|
|
Meta Ads + Google Ads (agencia)
|
|
</h3>
|
|
<p className="text-xs text-gray-400 mt-0.5 max-w-xl">
|
|
Meta (FB/IG/Ads) usa el system user token de la app{' '}
|
|
<strong className="text-gray-700">4V2030</strong> (
|
|
<code className="text-[10px]">META_ACCESS_TOKEN</code> en Dokploy
|
|
o pegado en Agencia). Google Ads/Drive/GA4 usan el OAuth de{' '}
|
|
<code className="text-[10px]">marketing@</code> — son tokens
|
|
distintos.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => void syncMetrics()}
|
|
disabled={syncing || (!metaOk && !gadsOk)}
|
|
className="inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg border border-emerald-700/60 text-emerald-200 hover:border-emerald-500 disabled:opacity-40"
|
|
>
|
|
{syncing ? (
|
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
) : (
|
|
<RefreshCw className="w-3.5 h-3.5" />
|
|
)}
|
|
Sincronizar métricas
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => void discover(['google_ads'])}
|
|
disabled={running || !gadsOk}
|
|
className="inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg border border-gray-200 text-gray-800 hover:border-gray-500 disabled:opacity-40"
|
|
>
|
|
{running ? (
|
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
) : (
|
|
<RefreshCw className="w-3.5 h-3.5" />
|
|
)}
|
|
Solo Google Ads
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => void discover()}
|
|
disabled={running || (!metaOk && !gadsOk)}
|
|
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 disabled:opacity-40"
|
|
>
|
|
{running ? (
|
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
) : (
|
|
<RefreshCw className="w-3.5 h-3.5" />
|
|
)}
|
|
Descubrir ambos
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-3 text-[11px]">
|
|
<span className={metaOk ? 'text-emerald-400' : 'text-amber-400'}>
|
|
Meta {metaOk ? 'listo' : 'pendiente App Review / token'}
|
|
</span>
|
|
<span className={gadsOk ? 'text-emerald-400' : 'text-amber-400'}>
|
|
Google Ads {gadsOk ? 'listo' : 'falta GOOGLE_ADS_*'}
|
|
</span>
|
|
</div>
|
|
|
|
{metaPending && (
|
|
<div className="rounded-lg border border-amber-500/20 bg-amber-500/5 px-3 py-3 space-y-2">
|
|
<p className="text-xs text-amber-200/90">
|
|
Cuando Meta apruebe la solicitud, genera el token del usuario del
|
|
sistema y pégalo aquí (se guarda sin redeploy).
|
|
</p>
|
|
<div className="flex flex-col sm:flex-row gap-2">
|
|
<input
|
|
type="password"
|
|
autoComplete="off"
|
|
className="flex-1 text-xs rounded-lg bg-gray-900 border border-gray-200 px-3 py-2 text-gray-900 placeholder:text-gray-600"
|
|
placeholder="EAAG… system user token"
|
|
value={metaToken}
|
|
onChange={(e) => setMetaToken(e.target.value)}
|
|
/>
|
|
<button
|
|
type="button"
|
|
disabled={savingToken || metaToken.trim().length < 20}
|
|
onClick={() => void saveToken()}
|
|
className="text-xs px-3 py-2 rounded-lg border border-amber-500/40 text-amber-200 hover:bg-amber-500/10 disabled:opacity-40"
|
|
>
|
|
{savingToken ? (
|
|
<Loader2 className="w-3.5 h-3.5 animate-spin inline" />
|
|
) : (
|
|
'Guardar token Meta'
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{msg && <p className="text-xs text-gray-600">{msg}</p>}
|
|
{err && <p className="text-xs text-red-400">{err}</p>}
|
|
|
|
{ambiguous.length > 0 && (
|
|
<ul className="space-y-2 max-h-56 overflow-y-auto">
|
|
{ambiguous.map((m) => (
|
|
<li
|
|
key={`${m.provider}-${m.tenantId}`}
|
|
className="flex flex-col sm:flex-row sm:items-center gap-2 text-xs border border-gray-200 rounded-lg px-3 py-2"
|
|
>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-gray-900 font-medium truncate">
|
|
{m.tenantName}{' '}
|
|
<span className="text-gray-500">
|
|
· {m.provider === 'meta_ads' ? 'Meta' : 'GAds'}
|
|
</span>
|
|
</p>
|
|
<p className="text-gray-500">Ambiguo — elige cuenta</p>
|
|
</div>
|
|
<div className="flex flex-wrap gap-1">
|
|
{(m.candidates || []).map((c) => (
|
|
<button
|
|
key={c.id}
|
|
type="button"
|
|
onClick={() =>
|
|
void assign(m.provider, m.tenantId, c.id, c.name)
|
|
}
|
|
className="px-2 py-1 rounded border border-gray-200 text-gray-600 hover:text-gray-900"
|
|
title={`score ${c.score}`}
|
|
>
|
|
{c.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|