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:
324
src/lib/ad-creatives-meta.ts
Normal file
324
src/lib/ad-creatives-meta.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user