feat: auto-discover Meta and Google Ads accounts for tenants
Agency panel lists accessible ad accounts and matches them to clients by name, same pattern as Drive folders. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
197
src/components/AdsDiscoverPanel.tsx
Normal file
197
src/components/AdsDiscoverPanel.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
'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 [gadsOk, setGadsOk] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [running, setRunning] = useState(false);
|
||||
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));
|
||||
setGadsOk(Boolean(data.googleAds?.configured));
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadStatus();
|
||||
}, []);
|
||||
|
||||
async function discover() {
|
||||
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 }),
|
||||
});
|
||||
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: ${data.meta.linked}/${data.meta.accounts} enlazadas` +
|
||||
(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 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();
|
||||
}
|
||||
|
||||
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-800 bg-[#111827] 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-white">
|
||||
Meta Ads + Google Ads (agencia)
|
||||
</h3>
|
||||
<p className="text-xs text-gray-400 mt-0.5 max-w-xl">
|
||||
Lista las cuentas accesibles con marketing@ / token de agencia y
|
||||
las enlaza a cada cliente por nombre.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<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 y emparejar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3 text-[11px]">
|
||||
<span
|
||||
className={metaOk ? 'text-emerald-400' : 'text-amber-400'}
|
||||
>
|
||||
Meta {metaOk ? 'listo' : 'falta META_ACCESS_TOKEN'}
|
||||
</span>
|
||||
<span
|
||||
className={gadsOk ? 'text-emerald-400' : 'text-amber-400'}
|
||||
>
|
||||
Google Ads{' '}
|
||||
{gadsOk ? 'listo' : 'falta GOOGLE_ADS_DEVELOPER_TOKEN + refresh'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{msg && <p className="text-xs text-gray-300">{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-800 rounded-lg px-3 py-2"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white 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-700 text-gray-300 hover:text-white"
|
||||
title={`score ${c.score}`}
|
||||
>
|
||||
{c.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import DriveConnectPanel from '@/components/DriveConnectPanel';
|
||||
import AdsDiscoverPanel from '@/components/AdsDiscoverPanel';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Bell,
|
||||
@@ -179,6 +180,7 @@ export default function AgencyPortfolioBoard() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<DriveConnectPanel />
|
||||
<AdsDiscoverPanel />
|
||||
|
||||
{warning && (
|
||||
<p className="text-xs text-amber-400/90 border border-amber-500/20 rounded-lg px-3 py-2">
|
||||
|
||||
Reference in New Issue
Block a user