feat: zoom de creatividades con assets verticales Meta y PMax completo

Preview 4:5 + textos en card; modal con todas las imágenes, vídeos y copy.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-23 18:38:02 +02:00
parent d05df8c371
commit 8091498508
4 changed files with 951 additions and 308 deletions

View File

@@ -1,4 +1,4 @@
/** Meta ad creatives for Ad Tracker (Brava-style previews). */
/** Meta ad creatives for Ad Tracker (vertical previews + full copy/assets). */
import { resolveMetaAccessToken } from '@/lib/ads-discovery';
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
@@ -10,6 +10,8 @@ const CREATIVE_FIELDS = [
'name',
'title',
'body',
'call_to_action_type',
'link_url',
'thumbnail_url',
'image_url',
'image_hash',
@@ -19,6 +21,14 @@ const CREATIVE_FIELDS = [
'effective_object_story_id',
].join(',');
export type CreativeAsset = {
kind: 'image' | 'video' | 'logo' | 'youtube';
url: string;
label?: string;
youtubeId?: string;
title?: string;
};
export type AdCreativeCard = {
platform: 'meta' | 'google';
adId: string;
@@ -31,10 +41,19 @@ export type AdCreativeCard = {
conversions: number;
cpl: number | null;
mediaType: string;
/** Best preview URL (prefer vertical / portrait). */
thumbnailUrl: string;
imageUrl: string;
/** Joined for backwards compat; prefer headlines/descriptions arrays. */
headline: string;
primaryText: string;
headlines: string[];
descriptions: string[];
longHeadlines?: string[];
assets: CreativeAsset[];
linkUrl?: string;
cta?: string;
businessName?: string;
status?: string;
youtubeId?: string;
};
@@ -58,6 +77,10 @@ function str(v: unknown): string {
return v == null ? '' : String(v).trim();
}
function uniq(arr: string[]): string[] {
return [...new Set(arr.filter(Boolean))];
}
function detectMediaType(creative: Record<string, unknown>): string {
if (creative.video_id) return 'video';
const feed = creative.asset_feed_spec as Record<string, unknown> | undefined;
@@ -71,32 +94,125 @@ function detectMediaType(creative: Record<string, unknown>): string {
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));
type Extracted = {
headlines: string[];
descriptions: string[];
assets: CreativeAsset[];
linkUrl: string;
cta: string;
hashes: string[];
};
function extractCreativePack(creative: Record<string, unknown>): Extracted {
const headlines: string[] = [];
const descriptions: string[] = [];
const assets: CreativeAsset[] = [];
const hashes: string[] = [];
const pushHash = (h: unknown) => {
const s = str(h);
if (/^[a-f0-9]{16,64}$/i.test(s)) hashes.push(s);
};
const pushImg = (url: unknown, label?: string) => {
const u = str(url);
if (!/^https?:\/\//i.test(u)) return;
if (assets.some((a) => a.url === u)) return;
assets.push({ kind: 'image', url: u, label });
};
if (creative.title) headlines.push(str(creative.title));
if (creative.body) descriptions.push(str(creative.body));
pushImg(creative.image_url, 'image');
pushImg(creative.thumbnail_url, 'thumbnail');
pushHash(creative.image_hash);
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 photo = oss?.photo_data as Record<string, unknown> | undefined;
if (link?.name) headlines.push(str(link.name));
if (link?.message) descriptions.push(str(link.message));
if (link?.description) descriptions.push(str(link.description));
if (link?.caption) descriptions.push(str(link.caption));
pushImg(link?.picture, 'story');
pushImg(link?.image_url, 'story');
pushHash(link?.image_hash);
if (video?.title) headlines.push(str(video.title));
if (video?.message) descriptions.push(str(video.message));
if (video?.link_description) descriptions.push(str(video.link_description));
pushImg(video?.image_url, 'video_poster');
pushHash(video?.image_hash);
pushImg(photo?.url, 'photo');
pushHash(photo?.image_hash);
if (Array.isArray(link?.child_attachments)) {
(link!.child_attachments as Record<string, unknown>[]).forEach((c, i) => {
pushImg(c.picture || c.image_url, `carousel-${i + 1}`);
pushHash(c.image_hash);
if (c.name) headlines.push(str(c.name));
if (c.description) descriptions.push(str(c.description));
if (c.message) descriptions.push(str(c.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));
if (feed) {
for (const t of (feed.titles as { text?: string }[]) || []) {
if (t?.text) headlines.push(str(t.text));
}
for (const b of (feed.bodies as { text?: string }[]) || []) {
if (b?.text) descriptions.push(str(b.text));
}
for (const d of (feed.descriptions as { text?: string }[]) || []) {
if (d?.text) descriptions.push(str(d.text));
}
for (const img of (feed.images as Record<string, unknown>[]) || []) {
pushImg(img.url || img.permalink_url, 'feed');
pushHash(img.hash);
}
for (const v of (feed.videos as Record<string, unknown>[]) || []) {
pushImg(v.thumbnail_url || v.picture, 'video_feed');
if (v.video_id) {
assets.push({
kind: 'video',
url: str(v.thumbnail_url || v.picture),
label: 'video',
title: str(v.video_id),
});
}
}
}
const linkUrl =
str(creative.link_url) ||
str(link?.link) ||
str(video?.link) ||
'';
const ctaRaw = (link?.call_to_action as { type?: string } | undefined)?.type;
const cta = str(creative.call_to_action_type) || str(ctaRaw);
return {
headline: [...new Set(titles.filter(Boolean))].slice(0, 5).join('\n'),
primaryText: [...new Set(bodies.filter(Boolean))].slice(0, 3).join('\n'),
headlines: uniq(headlines),
descriptions: uniq(descriptions),
assets,
linkUrl,
cta,
hashes: uniq(hashes),
};
}
/** Prefer portrait/vertical-looking assets; else first image. */
function pickVerticalPreview(assets: CreativeAsset[], fallback: string): string {
const images = assets.filter((a) => a.kind === 'image' && a.url);
const portrait = images.find((a) =>
/portrait|vertical|9.?16|4.?5|tall|story/i.test(a.label || '')
);
if (portrait) return portrait.url;
// Meta often serves taller thumbnails when requested 1080x1920 — thumbnail label last
const nonSquare = images.find((a) => a.label === 'thumbnail' || a.label === 'video_poster');
if (nonSquare) return nonSquare.url;
return images[0]?.url || fallback;
}
async function graphGet<T>(
path: string,
params: Record<string, string>,
@@ -124,6 +240,37 @@ async function resolveMetaAccountId(tenantId: string): Promise<string | null> {
return id || null;
}
async function resolveImageHashes(
accountId: string,
hashes: string[],
token: string
): Promise<Map<string, string>> {
const out = new Map<string, string>();
const unique = uniq(hashes).slice(0, 40);
if (!unique.length) return out;
const act = accountId.startsWith('act_') ? accountId : `act_${accountId}`;
try {
const raw = await graphGet<{
data?: { hash?: string; url?: string; permalink_url?: string }[];
}>(
`${act}/adimages`,
{
hashes: JSON.stringify(unique),
fields: 'hash,url,permalink_url',
},
token
);
for (const row of raw.data || []) {
const h = str(row.hash);
const u = str(row.url) || str(row.permalink_url);
if (h && u) out.set(h, u);
}
} catch {
/* ignore */
}
return out;
}
/** Aggregate ad_daily rows by ad_id for a platform. */
export function aggregateDailyByAd(
rows: {
@@ -187,29 +334,33 @@ async function fetchMetaCreativeBundle(
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);
}>(
adId,
{
fields: `id,name,effective_status,campaign{name},adset{name},creative{${CREATIVE_FIELDS}}`,
// Prefer vertical crop for Stories / Reels style
thumbnail_width: '1080',
thumbnail_height: '1920',
},
token
);
let creative = (raw.creative || {}) as Record<string, unknown>;
// Only hydrate creative detail when thumbnail/image missing
const cid = str(creative.id);
if (cid && !str(creative.thumbnail_url) && !str(creative.image_url)) {
if (cid) {
try {
const detail = await graphGet<Record<string, unknown>>(
cid,
{
fields: CREATIVE_FIELDS,
thumbnail_width: '1080',
thumbnail_height: '1080',
thumbnail_height: '1920',
},
token
);
creative = { ...creative, ...detail };
} catch {
/* keep lightweight */
/* keep */
}
}
@@ -256,48 +407,68 @@ export async function fetchMetaCreativeCards(opts: {
return { ads: [], warning: 'Sin ads Meta en ad_daily para este periodo' };
}
const out: AdCreativeCard[] = [];
// Parallel batches of 8 — single Graph call per ad (thumbnail already in creative{})
for (let i = 0; i < aggs.length; i += 8) {
const chunk = aggs.slice(i, i + 8);
const bundles = await Promise.all(
chunk.map((a) => fetchMetaCreativeBundle(a.adId, token))
const bundles: (Awaited<ReturnType<typeof fetchMetaCreativeBundle>>)[] = [];
for (let i = 0; i < aggs.length; i += 6) {
const chunk = aggs.slice(i, i + 6);
bundles.push(
...(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,
});
});
}
const allHashes: string[] = [];
const packs = bundles.map((b) => {
const pack = extractCreativePack(b?.creative || {});
allHashes.push(...pack.hashes);
return pack;
});
const hashMap = await resolveImageHashes(account, allHashes, token);
const out: AdCreativeCard[] = [];
aggs.forEach((agg, idx) => {
const b = bundles[idx];
const pack = packs[idx];
const assets = [...pack.assets];
for (const h of pack.hashes) {
const u = hashMap.get(h);
if (u && !assets.some((a) => a.url === u)) {
assets.push({ kind: 'image', url: u, label: 'hash' });
}
}
const creative = b?.creative || {};
const fallback =
str(creative.image_url) || str(creative.thumbnail_url) || '';
const preview = pickVerticalPreview(assets, fallback);
const headlines = pack.headlines;
const descriptions = pack.descriptions;
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: preview,
imageUrl: preview,
headline: headlines.join('\n'),
primaryText: descriptions.join('\n'),
headlines,
descriptions,
assets,
linkUrl: pack.linkUrl || undefined,
cta: pack.cta || undefined,
status: b?.status,
});
});
return { ads: out };
}
/** Google cards from ad_daily only (no asset pull yet — text fallback). */
/** Google cards from ad_daily only (text fallback). */
export function buildGoogleCardsFromDaily(
dailyRows: Parameters<typeof aggregateDailyByAd>[0],
since: string,
@@ -321,6 +492,9 @@ export function buildGoogleCardsFromDaily(
imageUrl: '',
headline: agg.adName,
primaryText: agg.campaignName,
headlines: agg.adName ? [agg.adName] : [],
descriptions: agg.campaignName ? [agg.campaignName] : [],
assets: [],
status: undefined,
}));
}