feat: previews de creatividades en Ad Tracker (estilo Brava)

Meta Graph en vivo + ficha Google; galería por anuncio en resumen/Meta/Google.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-23 18:16:14 +02:00
parent be45df300b
commit 86225a701e
3 changed files with 663 additions and 3 deletions

View File

@@ -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<AdDailyRow[]>(
`/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,
});
}

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import React, { useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { import {
AD_TRACKER_PRESETS, AD_TRACKER_PRESETS,
RangePreset, RangePreset,
@@ -8,6 +8,8 @@ import {
resolveAdRange, resolveAdRange,
} from '@/lib/ad-tracker-range'; } from '@/lib/ad-tracker-range';
import { AdDailyRow, formatEur, formatNum } from '@/lib/metrics'; import { AdDailyRow, formatEur, formatNum } from '@/lib/metrics';
import type { AdCreativeCard } from '@/lib/ad-creatives-meta';
import { ImageIcon, Play } from 'lucide-react';
type PlatformKpis = { type PlatformKpis = {
spend: number; 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 (
<section className="space-y-3">
<div>
<p className="text-[10px] uppercase tracking-wider text-gray-500">
Creatividades · estilo Brava Ad Tracker
</p>
<h3 className="text-lg font-bold text-white">
{platform === 'meta' ? 'Meta Ads' : 'Google Ads'} · preview por anuncio
</h3>
<p className="text-xs text-gray-500 mt-1">
Preview serving / asset + KPIs del rango. Meta tira creatividades en
vivo; Google muestra ficha (assets RSA en siguiente iteración).
</p>
</div>
{warning ? (
<p className="text-xs text-amber-400/90 border border-amber-500/20 rounded-lg px-3 py-2">
{warning}
</p>
) : null}
{loading ? (
<p className="text-sm text-gray-500 py-8 text-center">
Cargando creatividades…
</p>
) : filtered.length === 0 ? (
<p className="text-sm text-gray-500 py-8 text-center border border-dashed border-gray-800 rounded-xl">
Sin creatividades para este periodo.
</p>
) : (
<div className="space-y-3">
{filtered.map((ad) => (
<CreativeCard key={`${ad.platform}-${ad.adId}`} ad={ad} />
))}
</div>
)}
</section>
);
}
function CreativeCard({ ad }: { ad: AdCreativeCard }) {
const preview = ad.imageUrl || ad.thumbnailUrl;
const isVideo = ad.mediaType === 'video';
return (
<article className="grid gap-4 rounded-2xl border border-gray-800 bg-[#111827] p-4 md:grid-cols-[minmax(220px,320px)_1fr]">
<div className="relative overflow-hidden rounded-xl bg-black/40 border border-gray-800 min-h-[220px] flex items-center justify-center">
{preview ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={preview}
alt={ad.adName}
className="w-full h-full object-cover max-h-[420px]"
loading="lazy"
referrerPolicy="no-referrer"
/>
) : ad.platform === 'google' ? (
<div className="w-full p-4 space-y-2 text-left">
<p className="text-[10px] uppercase tracking-wider text-sky-400">
Google Ads · {ad.mediaType || 'rsa'}
</p>
<p className="text-sm font-semibold text-blue-300 leading-snug">
{(ad.headline || ad.adName).split('\n')[0]}
</p>
<p className="text-xs text-gray-400 line-clamp-3">
{(ad.primaryText || ad.campaignName || '').split('\n')[0]}
</p>
</div>
) : (
<div className="text-center text-gray-500 text-xs px-4">
<ImageIcon className="w-8 h-8 mx-auto mb-2 opacity-40" />
Sin preview
</div>
)}
{isVideo && preview ? (
<span className="absolute bottom-2 left-2 inline-flex items-center gap-1 rounded-full bg-black/70 px-2 py-1 text-[10px] text-white">
<Play className="w-3 h-3" /> Vídeo
</span>
) : null}
</div>
<div className="min-w-0 space-y-3">
<div>
<div className="flex flex-wrap items-start justify-between gap-2">
<h4 className="text-base font-semibold text-white leading-snug">
{ad.adName || 'Sin nombre'}
</h4>
<span className="text-[10px] uppercase tracking-wider text-gray-500 border border-gray-700 px-2 py-0.5 rounded">
{ad.status || ad.platform}
</span>
</div>
<p className="text-xs text-gray-500 mt-1">{ad.campaignName || '—'}</p>
{ad.adsetName ? (
<p className="text-xs text-gray-400">Conjunto: {ad.adsetName}</p>
) : null}
<div className="flex flex-wrap gap-1.5 mt-2">
<span
className={`rounded-full px-2 py-0.5 text-[10px] uppercase tracking-wide ${
ad.platform === 'meta'
? 'border border-violet-500/40 text-violet-300'
: 'border border-sky-500/40 text-sky-300'
}`}
>
{ad.platform === 'meta' ? 'Meta' : 'Google'}
</span>
{ad.mediaType && ad.mediaType !== 'unknown' ? (
<span className="rounded-full border border-gray-700 px-2 py-0.5 text-[10px] uppercase text-gray-400">
{ad.mediaType}
</span>
) : null}
</div>
</div>
{(ad.headline || ad.primaryText) && (
<div className="space-y-1 text-xs">
{ad.headline ? (
<p className="text-gray-200 font-medium line-clamp-2">
{ad.headline.split('\n')[0]}
</p>
) : null}
{ad.primaryText ? (
<p className="text-gray-500 line-clamp-3">
{ad.primaryText.split('\n')[0]}
</p>
) : null}
</div>
)}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
<MiniKpi label="Gasto" value={formatEur(ad.spend)} />
<MiniKpi label="Clics" value={formatNum(ad.clicks)} />
<MiniKpi label="Conv." value={formatNum(ad.conversions)} />
<MiniKpi
label="CPL"
value={ad.cpl != null ? formatEur(ad.cpl) : '—'}
/>
</div>
</div>
</article>
);
}
function MiniKpi({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border border-gray-800 bg-black/25 px-2.5 py-2">
<p className="text-[9px] uppercase tracking-wider text-gray-500">{label}</p>
<p className="text-sm font-semibold text-white mt-0.5 tabular-nums">
{value}
</p>
</div>
);
}
export default function AdTrackerClient({ export default function AdTrackerClient({
label, label,
tagline, tagline,
@@ -314,9 +475,53 @@ export default function AdTrackerClient({
const [preset, setPreset] = useState<RangePreset>('last_7d'); const [preset, setPreset] = useState<RangePreset>('last_7d');
const [tab, setTab] = useState<Tab>('resumen'); const [tab, setTab] = useState<Tab>('resumen');
const [chartMode, setChartMode] = useState<ChartMode>('combined'); const [chartMode, setChartMode] = useState<ChartMode>('combined');
const [creatives, setCreatives] = useState<AdCreativeCard[]>([]);
const [creativesLoading, setCreativesLoading] = useState(false);
const [creativesWarning, setCreativesWarning] = useState<string | null>(null);
const range = useMemo(() => resolveAdRange(preset), [preset]); 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( const filtered = useMemo(
() => rows.filter((r) => inRange(r.date, range.since, range.until)), () => rows.filter((r) => inRange(r.date, range.since, range.until)),
[rows, range.since, range.until] [rows, range.since, range.until]
@@ -366,8 +571,18 @@ export default function AdTrackerClient({
{tagline || 'Meta + Google Ads'} {tagline || 'Meta + Google Ads'}
</h2> </h2>
<p className="text-sm text-gray-400 mt-2 max-w-2xl"> <p className="text-sm text-gray-400 mt-2 max-w-2xl">
Resumen agregado de Meta y Google Ads: gasto, clics, conversiones y Resumen Meta + Google: gasto, clics, conversiones y{' '}
campañas activas en el periodo. <span className="text-gray-300">preview de creatividades</span> por
anuncio (como en{' '}
<a
href="https://4ventures.es/adtracker/brava-dermoestetica"
target="_blank"
rel="noreferrer"
className="text-sky-400 hover:underline"
>
Ad Tracker Brava
</a>
). Ventana: {range.since} → {range.until}.
</p> </p>
</div> </div>
</div> </div>
@@ -594,6 +809,19 @@ export default function AdTrackerClient({
empty="Sin campañas Google activas en este periodo" empty="Sin campañas Google activas en este periodo"
/> />
</div> </div>
<CreativesGallery
platform="meta"
ads={creatives}
loading={creativesLoading}
warning={creativesWarning}
/>
<CreativesGallery
platform="google"
ads={creatives}
loading={false}
warning={null}
/>
</div> </div>
)} )}
@@ -625,6 +853,12 @@ export default function AdTrackerClient({
rows={metaCampaigns} rows={metaCampaigns}
empty="Sin campañas Meta activas en este periodo" empty="Sin campañas Meta activas en este periodo"
/> />
<CreativesGallery
platform="meta"
ads={creatives}
loading={creativesLoading}
warning={creativesWarning}
/>
</div> </div>
)} )}
@@ -656,6 +890,12 @@ export default function AdTrackerClient({
rows={googleCampaigns} rows={googleCampaigns}
empty="Sin campañas Google activas en este periodo" empty="Sin campañas Google activas en este periodo"
/> />
<CreativesGallery
platform="google"
ads={creatives}
loading={creativesLoading}
warning={creativesWarning}
/>
</div> </div>
)} )}
</div> </div>

View File

@@ -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, unknown>): string {
if (creative.video_id) return 'video';
const feed = creative.asset_feed_spec as Record<string, unknown> | undefined;
if (feed?.videos || feed?.video_ids) return 'video';
const oss = creative.object_story_spec as Record<string, unknown> | undefined;
const link = oss?.link_data as Record<string, unknown> | 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<string, unknown>): {
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<string, unknown> | undefined;
const link = oss?.link_data as Record<string, unknown> | undefined;
const video = oss?.video_data as Record<string, unknown> | 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<string, unknown> | 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<T>(
path: string,
params: Record<string, string>,
token: string
): Promise<T> {
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<string | null> {
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<string, DailyAgg>();
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<string, unknown>;
} | null> {
try {
const raw = await graphGet<{
id?: string;
name?: string;
effective_status?: string;
campaign?: { name?: string };
adset?: { name?: string };
creative?: Record<string, unknown>;
}>(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<string, unknown>;
const cid = str(creative.id);
if (cid) {
try {
const detail = await graphGet<Record<string, unknown>>(
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<typeof aggregateDailyByAd>[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<typeof aggregateDailyByAd>[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,
}));
}