diff --git a/src/app/api/ad-tracker/creatives/route.ts b/src/app/api/ad-tracker/creatives/route.ts new file mode 100644 index 0000000..c4823a1 --- /dev/null +++ b/src/app/api/ad-tracker/creatives/route.ts @@ -0,0 +1,96 @@ +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 { + AdCreativeCard, + buildGoogleCardsFromDaily, + fetchMetaCreativeCards, +} from '@/lib/ad-creatives-meta'; + +export const dynamic = 'force-dynamic'; + +/** + * 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). + */ +export async function GET(req: NextRequest) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: 'unauthorized' }, { status: 401 }); + } + + const ctx = await resolveActiveTenant(user); + if (!ctx.tenantId || ctx.portfolioWide) { + return NextResponse.json( + { error: 'select_tenant', hint: 'Elige un cliente en Contexto' }, + { status: 400 } + ); + } + + const since = req.nextUrl.searchParams.get('since') || ''; + const until = req.nextUrl.searchParams.get('until') || ''; + const platform = (req.nextUrl.searchParams.get('platform') || 'all').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' }, + { status: 400 } + ); + } + + const agency = isAgencyRole(userRole(user)); + let daily: AdDailyRow[] = []; + if (agency && ctx.viaView) { + const tid = encodeURIComponent(ctx.tenantId); + const d = await restGet( + `/rest/v1/ad_daily?tenant_id=eq.${tid}&date=gte.${since}&date=lte.${until}&select=date,platform,campaign_name,ad_id,ad_name,impressions,clicks,spend,platform_leads&order=date.desc&limit=5000` + ); + daily = d.data || []; + } else { + const { data } = await supabase + .from('ad_daily') + .select( + 'date,platform,campaign_name,ad_id,ad_name,impressions,clicks,spend,platform_leads' + ) + .gte('date', since) + .lte('date', until) + .order('date', { ascending: false }) + .limit(5000); + daily = (data as AdDailyRow[]) || []; + } + + const warnings: string[] = []; + let metaAds: AdCreativeCard[] = []; + let googleAds: AdCreativeCard[] = []; + + if (platform === 'meta' || platform === 'all') { + const meta = await fetchMetaCreativeCards({ + tenantId: ctx.tenantId, + dailyRows: daily, + since, + until, + limit: 40, + }); + metaAds = meta.ads; + if (meta.warning) warnings.push(meta.warning); + } + + if (platform === 'google' || platform === 'all') { + googleAds = buildGoogleCardsFromDaily(daily, since, until, 40); + } + + return NextResponse.json({ + ok: true, + tenantId: ctx.tenantId, + since, + until, + ads: [...metaAds, ...googleAds], + metaCount: metaAds.length, + googleCount: googleAds.length, + warnings, + }); +} diff --git a/src/components/AdTrackerClient.tsx b/src/components/AdTrackerClient.tsx index 25e7d8b..52ee94c 100644 --- a/src/components/AdTrackerClient.tsx +++ b/src/components/AdTrackerClient.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { AD_TRACKER_PRESETS, RangePreset, @@ -8,6 +8,8 @@ import { resolveAdRange, } from '@/lib/ad-tracker-range'; import { AdDailyRow, formatEur, formatNum } from '@/lib/metrics'; +import type { AdCreativeCard } from '@/lib/ad-creatives-meta'; +import { ImageIcon, Play } from 'lucide-react'; type PlatformKpis = { spend: number; @@ -302,6 +304,165 @@ function CampaignTable({ ); } +function CreativesGallery({ + platform, + ads, + loading, + warning, +}: { + platform: 'meta' | 'google'; + ads: AdCreativeCard[]; + loading: boolean; + warning?: string | null; +}) { + const filtered = ads.filter((a) => a.platform === platform); + return ( +
+
+

+ Creatividades · estilo Brava Ad Tracker +

+

+ {platform === 'meta' ? 'Meta Ads' : 'Google Ads'} · preview por anuncio +

+

+ Preview serving / asset + KPIs del rango. Meta tira creatividades en + vivo; Google muestra ficha (assets RSA en siguiente iteración). +

+
+ {warning ? ( +

+ {warning} +

+ ) : null} + {loading ? ( +

+ Cargando creatividades… +

+ ) : filtered.length === 0 ? ( +

+ Sin creatividades para este periodo. +

+ ) : ( +
+ {filtered.map((ad) => ( + + ))} +
+ )} +
+ ); +} + +function CreativeCard({ ad }: { ad: AdCreativeCard }) { + const preview = ad.imageUrl || ad.thumbnailUrl; + const isVideo = ad.mediaType === 'video'; + return ( +
+
+ {preview ? ( + // eslint-disable-next-line @next/next/no-img-element + {ad.adName} + ) : ad.platform === 'google' ? ( +
+

+ Google Ads · {ad.mediaType || 'rsa'} +

+

+ {(ad.headline || ad.adName).split('\n')[0]} +

+

+ {(ad.primaryText || ad.campaignName || '').split('\n')[0]} +

+
+ ) : ( +
+ + Sin preview +
+ )} + {isVideo && preview ? ( + + Vídeo + + ) : null} +
+
+
+
+

+ {ad.adName || 'Sin nombre'} +

+ + {ad.status || ad.platform} + +
+

{ad.campaignName || '—'}

+ {ad.adsetName ? ( +

Conjunto: {ad.adsetName}

+ ) : null} +
+ + {ad.platform === 'meta' ? 'Meta' : 'Google'} + + {ad.mediaType && ad.mediaType !== 'unknown' ? ( + + {ad.mediaType} + + ) : null} +
+
+ {(ad.headline || ad.primaryText) && ( +
+ {ad.headline ? ( +

+ {ad.headline.split('\n')[0]} +

+ ) : null} + {ad.primaryText ? ( +

+ {ad.primaryText.split('\n')[0]} +

+ ) : null} +
+ )} +
+ + + + +
+
+
+ ); +} + +function MiniKpi({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

+ {value} +

+
+ ); +} + export default function AdTrackerClient({ label, tagline, @@ -314,9 +475,53 @@ export default function AdTrackerClient({ const [preset, setPreset] = useState('last_7d'); const [tab, setTab] = useState('resumen'); const [chartMode, setChartMode] = useState('combined'); + const [creatives, setCreatives] = useState([]); + const [creativesLoading, setCreativesLoading] = useState(false); + const [creativesWarning, setCreativesWarning] = useState(null); const range = useMemo(() => resolveAdRange(preset), [preset]); + useEffect(() => { + let cancelled = false; + async function load() { + setCreativesLoading(true); + setCreativesWarning(null); + try { + const platform = + tab === 'meta' ? 'meta' : tab === 'google' ? 'google' : 'all'; + const qs = new URLSearchParams({ + since: range.since, + until: range.until, + platform, + }); + 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([]); + setCreativesWarning(data.hint || data.error || 'No se pudieron cargar creatividades'); + return; + } + setCreatives((data.ads as AdCreativeCard[]) || []); + const w = Array.isArray(data.warnings) ? data.warnings.filter(Boolean) : []; + setCreativesWarning(w.length ? w.join(' · ') : null); + } catch { + if (!cancelled) { + setCreatives([]); + setCreativesWarning('Error al cargar creatividades'); + } + } finally { + if (!cancelled) setCreativesLoading(false); + } + } + void load(); + return () => { + cancelled = true; + }; + }, [range.since, range.until, tab]); + const filtered = useMemo( () => rows.filter((r) => inRange(r.date, range.since, range.until)), [rows, range.since, range.until] @@ -366,8 +571,18 @@ export default function AdTrackerClient({ {tagline || 'Meta + Google Ads'}

- Resumen agregado de Meta y Google Ads: gasto, clics, conversiones y - campañas activas en el periodo. + Resumen Meta + Google: gasto, clics, conversiones y{' '} + preview de creatividades por + anuncio (como en{' '} + + Ad Tracker Brava + + ). Ventana: {range.since} → {range.until}.

@@ -594,6 +809,19 @@ export default function AdTrackerClient({ empty="Sin campañas Google activas en este periodo" /> + + + )} @@ -625,6 +853,12 @@ export default function AdTrackerClient({ rows={metaCampaigns} empty="Sin campañas Meta activas en este periodo" /> + )} @@ -656,6 +890,12 @@ export default function AdTrackerClient({ rows={googleCampaigns} empty="Sin campañas Google activas en este periodo" /> + )} diff --git a/src/lib/ad-creatives-meta.ts b/src/lib/ad-creatives-meta.ts new file mode 100644 index 0000000..d41ed95 --- /dev/null +++ b/src/lib/ad-creatives-meta.ts @@ -0,0 +1,324 @@ +/** Meta ad creatives for Ad Tracker (Brava-style previews). */ + +import { resolveMetaAccessToken } from '@/lib/ads-discovery'; +import { serviceHeaders, supabaseUrl } from '@/lib/agency'; + +const META_GRAPH = 'https://graph.facebook.com/v21.0'; + +const CREATIVE_FIELDS = [ + 'id', + 'name', + 'title', + 'body', + 'thumbnail_url', + 'image_url', + 'image_hash', + 'video_id', + 'object_story_spec', + 'asset_feed_spec', + 'effective_object_story_id', +].join(','); + +export type AdCreativeCard = { + platform: 'meta' | 'google'; + adId: string; + adName: string; + campaignName: string; + adsetName?: string; + spend: number; + impressions: number; + clicks: number; + conversions: number; + cpl: number | null; + mediaType: string; + thumbnailUrl: string; + imageUrl: string; + headline: string; + primaryText: string; + status?: string; +}; + +type DailyAgg = { + adId: string; + adName: string; + campaignName: string; + spend: number; + impressions: number; + clicks: number; + conversions: number; +}; + +function n(v: unknown): number { + const x = Number(v); + return Number.isFinite(x) ? x : 0; +} + +function str(v: unknown): string { + return v == null ? '' : String(v).trim(); +} + +function detectMediaType(creative: Record): string { + if (creative.video_id) return 'video'; + const feed = creative.asset_feed_spec as Record | undefined; + if (feed?.videos || feed?.video_ids) return 'video'; + const oss = creative.object_story_spec as Record | undefined; + const link = oss?.link_data as Record | undefined; + if (Array.isArray(link?.child_attachments) && (link!.child_attachments as unknown[]).length > 1) { + return 'carousel'; + } + if (creative.image_url || creative.image_hash || creative.thumbnail_url) return 'image'; + return 'unknown'; +} + +function extractTexts(creative: Record): { + headline: string; + primaryText: string; +} { + const titles: string[] = []; + const bodies: string[] = []; + if (creative.title) titles.push(str(creative.title)); + if (creative.body) bodies.push(str(creative.body)); + const oss = creative.object_story_spec as Record | undefined; + const link = oss?.link_data as Record | undefined; + const video = oss?.video_data as Record | undefined; + if (link?.name) titles.push(str(link.name)); + if (link?.message) bodies.push(str(link.message)); + if (video?.title) titles.push(str(video.title)); + if (video?.message) bodies.push(str(video.message)); + const feed = creative.asset_feed_spec as Record | undefined; + const feedTitles = feed?.titles as { text?: string }[] | undefined; + const feedBodies = feed?.bodies as { text?: string }[] | undefined; + for (const t of feedTitles || []) if (t?.text) titles.push(str(t.text)); + for (const b of feedBodies || []) if (b?.text) bodies.push(str(b.text)); + return { + headline: [...new Set(titles.filter(Boolean))].slice(0, 5).join('\n'), + primaryText: [...new Set(bodies.filter(Boolean))].slice(0, 3).join('\n'), + }; +} + +async function graphGet( + path: string, + params: Record, + token: string +): Promise { + const q = new URLSearchParams({ ...params, access_token: token }); + const r = await fetch(`${META_GRAPH}/${path}?${q}`, { cache: 'no-store' }); + if (!r.ok) { + throw new Error(`Meta ${path}: ${(await r.text()).slice(0, 220)}`); + } + return (await r.json()) as T; +} + +async function resolveMetaAccountId(tenantId: string): Promise { + const headers = serviceHeaders(); + const base = supabaseUrl(); + if (!headers || !base) return null; + const r = await fetch( + `${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.meta_ads&select=external_account_id,status&limit=1`, + { headers, cache: 'no-store' } + ); + if (!r.ok) return null; + const rows = (await r.json()) as { external_account_id?: string | null }[]; + const id = rows[0]?.external_account_id?.trim(); + return id || null; +} + +/** Aggregate ad_daily rows by ad_id for a platform. */ +export function aggregateDailyByAd( + rows: { + platform?: string; + ad_id?: string | null; + ad_name?: string | null; + campaign_name?: string | null; + spend?: number; + impressions?: number; + clicks?: number; + platform_leads?: number; + conversions?: number; + date?: string; + }[], + platform: 'meta' | 'google', + since: string, + until: string +): DailyAgg[] { + const map = new Map(); + for (const r of rows) { + if ((r.platform || '').toLowerCase() !== platform) continue; + const d = (r.date || '').slice(0, 10); + if (d < since || d > until) continue; + const adId = str(r.ad_id); + if (!adId) continue; + const cur = map.get(adId) || { + adId, + adName: str(r.ad_name) || adId, + campaignName: str(r.campaign_name), + spend: 0, + impressions: 0, + clicks: 0, + conversions: 0, + }; + cur.spend += n(r.spend); + cur.impressions += n(r.impressions); + cur.clicks += n(r.clicks); + cur.conversions += n(r.platform_leads ?? r.conversions); + if (str(r.ad_name)) cur.adName = str(r.ad_name); + if (str(r.campaign_name)) cur.campaignName = str(r.campaign_name); + map.set(adId, cur); + } + return [...map.values()].sort((a, b) => b.spend - a.spend); +} + +async function fetchMetaCreativeBundle( + adId: string, + token: string +): Promise<{ + adName: string; + campaignName: string; + adsetName: string; + status: string; + creative: Record; +} | null> { + try { + const raw = await graphGet<{ + id?: string; + name?: string; + effective_status?: string; + campaign?: { name?: string }; + adset?: { name?: string }; + creative?: Record; + }>(adId, { + fields: `id,name,effective_status,campaign{name},adset{name},creative{${CREATIVE_FIELDS}}`, + thumbnail_width: '1080', + thumbnail_height: '1080', + }, token); + + let creative = (raw.creative || {}) as Record; + const cid = str(creative.id); + if (cid) { + try { + const detail = await graphGet>( + cid, + { + fields: CREATIVE_FIELDS, + thumbnail_width: '1080', + thumbnail_height: '1080', + }, + token + ); + creative = { ...creative, ...detail }; + } catch { + /* keep lightweight */ + } + } + + return { + adName: str(raw.name), + campaignName: str(raw.campaign?.name), + adsetName: str(raw.adset?.name), + status: str(raw.effective_status), + creative, + }; + } catch { + return null; + } +} + +/** + * Build Meta creative cards for a tenant: metrics from ad_daily + live Meta creative assets. + */ +export async function fetchMetaCreativeCards(opts: { + tenantId: string; + dailyRows: Parameters[0]; + since: string; + until: string; + limit?: number; +}): Promise<{ ads: AdCreativeCard[]; warning?: string }> { + const token = await resolveMetaAccessToken(); + if (!token) { + return { ads: [], warning: 'Sin token Meta (META_ACCESS_TOKEN / agency_oauth)' }; + } + + const account = await resolveMetaAccountId(opts.tenantId); + if (!account) { + return { ads: [], warning: 'Cliente sin cuenta Meta Ads enlazada' }; + } + + const aggs = aggregateDailyByAd( + opts.dailyRows, + 'meta', + opts.since, + opts.until + ).slice(0, opts.limit ?? 40); + + if (!aggs.length) { + return { ads: [], warning: 'Sin ads Meta en ad_daily para este periodo' }; + } + + const out: AdCreativeCard[] = []; + // Sequential batches of 5 to respect rate limits + for (let i = 0; i < aggs.length; i += 5) { + const chunk = aggs.slice(i, i + 5); + const bundles = await Promise.all( + chunk.map((a) => fetchMetaCreativeBundle(a.adId, token)) + ); + chunk.forEach((agg, idx) => { + const b = bundles[idx]; + const creative = b?.creative || {}; + const texts = extractTexts(creative); + const imageUrl = + str(creative.image_url) || + str(creative.thumbnail_url) || + ''; + const thumbnailUrl = + str(creative.thumbnail_url) || imageUrl; + out.push({ + platform: 'meta', + adId: agg.adId, + adName: b?.adName || agg.adName, + campaignName: b?.campaignName || agg.campaignName, + adsetName: b?.adsetName, + spend: Math.round(agg.spend * 100) / 100, + impressions: agg.impressions, + clicks: agg.clicks, + conversions: agg.conversions, + cpl: agg.conversions > 0 ? agg.spend / agg.conversions : null, + mediaType: detectMediaType(creative), + thumbnailUrl, + imageUrl: imageUrl || thumbnailUrl, + headline: texts.headline, + primaryText: texts.primaryText, + status: b?.status, + }); + }); + } + + return { ads: out }; +} + +/** Google cards from ad_daily only (no asset pull yet — text fallback). */ +export function buildGoogleCardsFromDaily( + dailyRows: Parameters[0], + since: string, + until: string, + limit = 40 +): AdCreativeCard[] { + return aggregateDailyByAd(dailyRows, 'google', since, until) + .slice(0, limit) + .map((agg) => ({ + platform: 'google' as const, + adId: agg.adId, + adName: agg.adName, + campaignName: agg.campaignName, + spend: Math.round(agg.spend * 100) / 100, + impressions: agg.impressions, + clicks: agg.clicks, + conversions: agg.conversions, + cpl: agg.conversions > 0 ? agg.spend / agg.conversions : null, + mediaType: 'rsa', + thumbnailUrl: '', + imageUrl: '', + headline: agg.adName, + primaryText: agg.campaignName, + status: undefined, + })); +}