From a75f3ae30c43a13c3b5cc9afa7b548031fba1aac Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Tue, 29 Sep 2026 09:35:45 +0200 Subject: [PATCH] perf: creatividades warehouse-first + aviso OAuth Ads Production MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carga métricas desde ad_daily al instante (mode=fast) y enriquece previews en vivo; la UI de Agencia avisa si falta scope Ads o consent Testing. Co-authored-by: Cursor --- src/app/api/ad-tracker/creatives/route.ts | 103 ++++++++++++++++++---- src/components/AdTrackerClient.tsx | 61 +++++++++---- src/components/DriveConnectPanel.tsx | 21 ++++- src/lib/ad-creatives-google.ts | 16 ++++ src/lib/ad-creatives-meta.ts | 9 ++ 5 files changed, 174 insertions(+), 36 deletions(-) diff --git a/src/app/api/ad-tracker/creatives/route.ts b/src/app/api/ad-tracker/creatives/route.ts index f12c2d4..6519ee9 100644 --- a/src/app/api/ad-tracker/creatives/route.ts +++ b/src/app/api/ad-tracker/creatives/route.ts @@ -2,19 +2,49 @@ import { NextRequest, NextResponse } from 'next/server'; import { createClient } from '@/lib/supabase/server'; import { resolveActiveTenant, restGet } from '@/lib/tenant-context'; import { isAgencyRole, userRole } from '@/lib/agency'; -import { AdDailyRow } from '@/lib/metrics'; +import { AdDailyRow, linkClicksOf } from '@/lib/metrics'; +import { normalizePlatform } from '@/lib/ad-tracker-range'; import { AdCreativeCard, + buildMetaWarehouseCards, fetchMetaAccountClickSplit, fetchMetaCreativeCards, } from '@/lib/ad-creatives-meta'; -import { fetchGoogleCreativeCards } from '@/lib/ad-creatives-google'; +import { + buildGoogleCardsFromDaily, + fetchGoogleCreativeCards, +} from '@/lib/ad-creatives-google'; export const dynamic = 'force-dynamic'; +function metaClickSplitFromDaily( + daily: AdDailyRow[], + since: string, + until: string +): { clicks: number; linkClicks: number; spend: number } { + let clicks = 0; + let linkClicks = 0; + let spend = 0; + for (const r of daily) { + if (normalizePlatform(r.platform) !== 'meta') continue; + const d = (r.date || '').slice(0, 10); + if (d < since || d > until) continue; + clicks += Number(r.clicks || 0) || 0; + linkClicks += linkClicksOf(r); + spend += Number(r.spend || 0) || 0; + } + return { + clicks, + linkClicks: linkClicks || clicks, + spend: Math.round(spend * 100) / 100, + }; +} + /** - * GET /api/ad-tracker/creatives?since=YYYY-MM-DD&until=YYYY-MM-DD&platform=meta|google|all - * Returns ad cards with creative preview URLs (Meta live; Google text fallback). + * GET /api/ad-tracker/creatives?since=&until=&platform=meta|google|all&mode=fast|live + * + * mode=fast → warehouse only (instant, same metrics as Ads sync) + * mode=live → enrich with Meta/Google creative assets (slower) */ export async function GET(req: NextRequest) { const supabase = await createClient(); @@ -36,6 +66,7 @@ export async function GET(req: NextRequest) { const since = req.nextUrl.searchParams.get('since') || ''; const until = req.nextUrl.searchParams.get('until') || ''; const platform = (req.nextUrl.searchParams.get('platform') || 'all').toLowerCase(); + const mode = (req.nextUrl.searchParams.get('mode') || 'live').toLowerCase(); if (!/^\d{4}-\d{2}-\d{2}$/.test(since) || !/^\d{4}-\d{2}-\d{2}$/.test(until)) { return NextResponse.json( { error: 'bad_range', hint: 'since/until YYYY-MM-DD' }, @@ -73,6 +104,29 @@ export async function GET(req: NextRequest) { spend: number; } | null = null; + // Fast path: warehouse only — correct spend/clicks/leads from sync, no Graph wait + if (mode === 'fast') { + if (platform === 'meta' || platform === 'all') { + metaAds = buildMetaWarehouseCards(daily, since, until, 16); + metaClickSplit = metaClickSplitFromDaily(daily, since, until); + } + if (platform === 'google' || platform === 'all') { + googleAds = buildGoogleCardsFromDaily(daily, since, until, 24); + } + return NextResponse.json({ + ok: true, + mode: 'fast', + tenantId: ctx.tenantId, + since, + until, + ads: [...metaAds, ...googleAds], + metaCount: metaAds.length, + googleCount: googleAds.length, + metaClickSplit, + warnings, + }); + } + if (platform === 'meta' || platform === 'all') { try { const [meta, clickSplit] = await Promise.all([ @@ -89,9 +143,11 @@ export async function GET(req: NextRequest) { until, }).catch(() => null), ]); - metaAds = meta.ads; - metaClickSplit = clickSplit; - // Only keep hard configuration warnings (no Graph soft-fail noise) + metaAds = meta.ads.length + ? meta.ads + : buildMetaWarehouseCards(daily, since, until, 16); + metaClickSplit = + clickSplit || metaClickSplitFromDaily(daily, since, until); if ( meta.warning && /sin token meta|sin cuenta meta|sin actividad meta/i.test(meta.warning) @@ -99,24 +155,37 @@ export async function GET(req: NextRequest) { warnings.push(meta.warning); } } catch { - metaAds = []; + metaAds = buildMetaWarehouseCards(daily, since, until, 16); + metaClickSplit = metaClickSplitFromDaily(daily, since, until); } } if (platform === 'google' || platform === 'all') { - const google = await fetchGoogleCreativeCards({ - tenantId: ctx.tenantId, - dailyRows: daily, - since, - until, - limit: 24, - }); - googleAds = google.ads; - if (google.warning) warnings.push(google.warning); + try { + const google = await fetchGoogleCreativeCards({ + tenantId: ctx.tenantId, + dailyRows: daily, + since, + until, + limit: 24, + }); + googleAds = google.ads.length + ? google.ads + : buildGoogleCardsFromDaily(daily, since, until, 24); + if ( + google.warning && + /caducado|oauth|no configurada|sin cuenta/i.test(google.warning) + ) { + warnings.push(google.warning); + } + } catch { + googleAds = buildGoogleCardsFromDaily(daily, since, until, 24); + } } return NextResponse.json({ ok: true, + mode: 'live', tenantId: ctx.tenantId, since, until, diff --git a/src/components/AdTrackerClient.tsx b/src/components/AdTrackerClient.tsx index edfafe3..9c8c85b 100644 --- a/src/components/AdTrackerClient.tsx +++ b/src/components/AdTrackerClient.tsx @@ -552,39 +552,70 @@ export default function AdTrackerClient({ async function load() { setCreativesLoading(true); setCreativesWarning(null); + const platform = tab === 'meta' ? 'meta' : 'google'; + const baseQs = { + since: range.since, + until: range.until, + platform, + }; + + let hasFast = false; + + // 1) Warehouse first — instant metrics (source of truth from sync) try { - const platform = tab === 'meta' ? 'meta' : 'google'; - const qs = new URLSearchParams({ - since: range.since, - until: range.until, - platform, + const qs = new URLSearchParams({ ...baseQs, mode: 'fast' }); + const r = await fetch(`/api/ad-tracker/creatives?${qs}`, { + cache: 'no-store', }); + const data = await r.json().catch(() => ({})); + if (cancelled) return; + if (r.ok) { + hasFast = true; + setCreatives((data.ads as AdCreativeCard[]) || []); + if ( + data.metaClickSplit && + typeof data.metaClickSplit.clicks === 'number' + ) { + setMetaClickSplit(data.metaClickSplit); + } else if (platform === 'google') { + setMetaClickSplit(null); + } + setCreativesLoading(false); + } + } catch { + /* continue to live */ + } + + // 2) Live enrich (previews) — never blocks empty UI + try { + const qs = new URLSearchParams({ ...baseQs, mode: 'live' }); const r = await fetch(`/api/ad-tracker/creatives?${qs}`, { cache: 'no-store', }); const data = await r.json().catch(() => ({})); if (cancelled) return; if (!r.ok) { - setCreatives([]); - setMetaClickSplit(null); - setCreativesWarning(data.hint || data.error || 'No se pudieron cargar creatividades'); + if (!hasFast) { + setCreativesWarning( + data.hint || data.error || 'No se pudieron cargar creatividades' + ); + } return; } - setCreatives((data.ads as AdCreativeCard[]) || []); + const liveAds = (data.ads as AdCreativeCard[]) || []; + if (liveAds.length) setCreatives(liveAds); if ( data.metaClickSplit && typeof data.metaClickSplit.clicks === 'number' ) { setMetaClickSplit(data.metaClickSplit); - } else if (platform === 'google') { - setMetaClickSplit(null); } - const w = Array.isArray(data.warnings) ? data.warnings.filter(Boolean) : []; + const w = Array.isArray(data.warnings) + ? data.warnings.filter(Boolean) + : []; setCreativesWarning(w.length ? w.join(' · ') : null); } catch { - if (!cancelled) { - setCreatives([]); - setMetaClickSplit(null); + if (!cancelled && !hasFast) { setCreativesWarning('Error al cargar creatividades'); } } finally { diff --git a/src/components/DriveConnectPanel.tsx b/src/components/DriveConnectPanel.tsx index 189641e..db0feec 100644 --- a/src/components/DriveConnectPanel.tsx +++ b/src/components/DriveConnectPanel.tsx @@ -18,6 +18,7 @@ export default function DriveConnectPanel() { const [configured, setConfigured] = useState(false); const [connected, setConnected] = useState(false); const [email, setEmail] = useState(null); + const [hasAds, setHasAds] = useState(false); const [loading, setLoading] = useState(true); const [matching, setMatching] = useState(false); const [matches, setMatches] = useState(null); @@ -31,6 +32,7 @@ export default function DriveConnectPanel() { setConfigured(Boolean(data.configured)); setConnected(Boolean(data.connected)); setEmail(data.email || null); + setHasAds(Boolean(data.hasAds)); setLoading(false); } @@ -145,12 +147,23 @@ export default function DriveConnectPanel() { {connected ? ( -

- Conectado{email ? ` · ${email}` : ''} -

+
+

+ Conectado{email ? ` · ${email}` : ''} + {hasAds ? ' · Ads OK' : ''} +

+ {!hasAds ? ( +

+ Falta el scope de Google Ads. Pulsa{' '} + Reconectar Google y acepta Ads en el + consentimiento (consent screen en Production). +

+ ) : null} +
) : (

- Aún no conectado — pulsa el botón naranja. + Aún no conectado — pulsa el botón naranja con marketing@4ventures.es. + Los clientes no necesitan hacer login en Google.

)} {msg &&

{msg}

} diff --git a/src/lib/ad-creatives-google.ts b/src/lib/ad-creatives-google.ts index d3bd76c..16114ab 100644 --- a/src/lib/ad-creatives-google.ts +++ b/src/lib/ad-creatives-google.ts @@ -217,6 +217,22 @@ function cardsFromDaily( })); } +/** Warehouse-only Google cards (fast path). */ +export function buildGoogleCardsFromDaily( + dailyRows: Parameters[0], + since: string, + until: string, + limit = 24 +): AdCreativeCard[] { + return cardsFromDaily( + dailyRows, + since, + until, + limit, + 'Métricas desde warehouse (sync Ads)' + ); +} + /** * Live Google creatives: Performance Max asset groups + RSA/RDA ads. * Always falls back to ad_daily campaign cards when the API/token fails. diff --git a/src/lib/ad-creatives-meta.ts b/src/lib/ad-creatives-meta.ts index e1efb06..a3b28f7 100644 --- a/src/lib/ad-creatives-meta.ts +++ b/src/lib/ad-creatives-meta.ts @@ -317,6 +317,15 @@ function cleanMetaAdName(name: string): string { } /** Campaign-level cards from warehouse — always available, never throws. */ +export function buildMetaWarehouseCards( + dailyRows: Parameters[0], + since: string, + until: string, + limit = 16 +): AdCreativeCard[] { + return metaCardsFromDaily(dailyRows, since, until, limit); +} + function metaCardsFromDaily( dailyRows: Parameters[0], since: string,