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,8 +1,8 @@
/** Google Ads creatives for Ad Tracker (PMax asset groups + RSA/RDA). */
/** Google Ads creatives for Ad Tracker (PMax asset groups + RSA/RDA, full assets). */
import { googleAdsConfigured } from '@/lib/ads-discovery';
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
import type { AdCreativeCard } from '@/lib/ad-creatives-meta';
import type { AdCreativeCard, CreativeAsset } from '@/lib/ad-creatives-meta';
import { aggregateDailyByAd } from '@/lib/ad-creatives-meta';
const GADS_API = 'https://googleads.googleapis.com/v22';
@@ -20,14 +20,15 @@ type GaqlRow = {
responsiveSearchAd?: {
headlines?: { text?: string }[];
descriptions?: { text?: string }[];
path1?: string;
path2?: string;
};
responsiveDisplayAd?: {
headlines?: { text?: string }[];
longHeadline?: { text?: string };
descriptions?: { text?: string }[];
businessName?: string;
marketingImages?: { asset?: string }[];
squareMarketingImages?: { asset?: string }[];
callToActionText?: string;
};
imageAd?: { imageUrl?: string; name?: string };
};
@@ -48,6 +49,7 @@ type GaqlRow = {
youtubeVideoId?: string;
youtubeVideoTitle?: string;
};
callToActionAsset?: { callToAction?: string };
};
metrics?: {
costMicros?: string;
@@ -70,6 +72,10 @@ function enumName(v?: string): string {
return (v || '').replace(/^[A-Z0-9]+_/, '') || str(v);
}
function uniq(arr: string[]): string[] {
return [...new Set(arr.filter(Boolean))];
}
async function googleAccessToken(): Promise<string> {
const refresh =
process.env.GOOGLE_ADS_REFRESH_TOKEN?.trim() ||
@@ -159,21 +165,34 @@ function ytThumb(youtubeId: string): string {
return `https://i.ytimg.com/vi/${youtubeId}/hqdefault.jpg`;
}
function pickVerticalGoogle(assets: CreativeAsset[]): string {
const images = assets.filter((a) => a.kind === 'image' && a.url);
const portrait = images.find((a) =>
/PORTRAIT|TALL|VERTICAL/i.test(a.label || '')
);
if (portrait) return portrait.url;
const square = images.find((a) => /SQUARE/i.test(a.label || ''));
if (square) return square.url;
return images[0]?.url || '';
}
type AgBucket = {
adId: string;
adName: string;
campaignId: string;
campaignName: string;
status: string;
finalUrls: string[];
headlines: string[];
longHeadlines: string[];
descriptions: string[];
images: string[];
youtubeIds: string[];
ctas: string[];
assets: CreativeAsset[];
};
/**
* Live Google creatives: Performance Max asset groups + RSA/RDA ads.
* Metrics: prefer asset_group / ad metrics in range; else fall back to campaign ad_daily.
* Returns full asset packs (images, videos, logos, texts) for zoom UI.
*/
export async function fetchGoogleCreativeCards(opts: {
tenantId: string;
@@ -226,7 +245,8 @@ SELECT
asset.text_asset.text,
asset.image_asset.full_size.url,
asset.youtube_video_asset.youtube_video_id,
asset.youtube_video_asset.youtube_video_title
asset.youtube_video_asset.youtube_video_title,
asset.call_to_action_asset.call_to_action
FROM asset_group_asset
WHERE asset_group.status != 'REMOVED'
AND asset_group_asset.status != 'REMOVED'
@@ -262,10 +282,13 @@ SELECT
ad_group_ad.ad.final_urls,
ad_group_ad.ad.responsive_search_ad.headlines,
ad_group_ad.ad.responsive_search_ad.descriptions,
ad_group_ad.ad.responsive_search_ad.path1,
ad_group_ad.ad.responsive_search_ad.path2,
ad_group_ad.ad.responsive_display_ad.headlines,
ad_group_ad.ad.responsive_display_ad.long_headline,
ad_group_ad.ad.responsive_display_ad.descriptions,
ad_group_ad.ad.responsive_display_ad.business_name,
ad_group_ad.ad.responsive_display_ad.call_to_action_text,
ad_group_ad.ad.image_ad.image_url,
ad_group_ad.ad.image_ad.name
FROM ad_group_ad
@@ -330,7 +353,6 @@ WHERE ${dateWhere}
adMetricMap.set(id, cur);
}
// Campaign-level spend from ad_daily as fallback for PMax
const dailyAggs = aggregateDailyByAd(
opts.dailyRows,
'google',
@@ -339,7 +361,7 @@ WHERE ${dateWhere}
);
const campaignSpend = new Map<string, (typeof dailyAggs)[number]>();
for (const a of dailyAggs) {
campaignSpend.set(a.adId, a); // portal stores campaign id as ad_id
campaignSpend.set(a.adId, a);
if (a.campaignName) campaignSpend.set(a.campaignName.toLowerCase(), a);
}
@@ -355,10 +377,12 @@ WHERE ${dateWhere}
campaignId: str(row.campaign?.id),
campaignName: str(row.campaign?.name),
status: enumName(row.assetGroup?.status) || 'ENABLED',
finalUrls: (row.assetGroup?.finalUrls || []).map(str).filter(Boolean),
headlines: [],
longHeadlines: [],
descriptions: [],
images: [],
youtubeIds: [],
ctas: [],
assets: [],
};
agMap.set(agid, entry);
}
@@ -366,16 +390,31 @@ WHERE ${dateWhere}
const text = str(row.asset?.textAsset?.text);
const img = str(row.asset?.imageAsset?.fullSize?.url);
const yt = str(row.asset?.youtubeVideoAsset?.youtubeVideoId);
const ytTitle = str(row.asset?.youtubeVideoAsset?.youtubeVideoTitle);
const cta = enumName(row.asset?.callToActionAsset?.callToAction);
if (ft === 'HEADLINE' && text) entry.headlines.push(text);
else if (ft === 'LONG_HEADLINE' && text) entry.headlines.push(text);
else if (ft === 'LONG_HEADLINE' && text) entry.longHeadlines.push(text);
else if (ft === 'DESCRIPTION' && text) entry.descriptions.push(text);
else if (
(ft.includes('MARKETING_IMAGE') || ft === 'LOGO' || ft === 'LANDSCAPE_LOGO') &&
img
) {
if (!entry.images.includes(img)) entry.images.push(img);
else if (ft === 'CALL_TO_ACTION_SELECTION' && cta) entry.ctas.push(cta);
else if (ft.includes('MARKETING_IMAGE') && img) {
if (!entry.assets.some((a) => a.url === img)) {
entry.assets.push({ kind: 'image', url: img, label: ft });
}
} else if ((ft === 'LOGO' || ft === 'LANDSCAPE_LOGO') && img) {
if (!entry.assets.some((a) => a.url === img)) {
entry.assets.push({ kind: 'logo', url: img, label: ft });
}
} else if (ft === 'YOUTUBE_VIDEO' && yt) {
if (!entry.youtubeIds.includes(yt)) entry.youtubeIds.push(yt);
if (!entry.assets.some((a) => a.youtubeId === yt)) {
entry.assets.push({
kind: 'youtube',
url: ytThumb(yt),
label: 'YOUTUBE_VIDEO',
youtubeId: yt,
title: ytTitle || yt,
});
}
}
}
@@ -390,11 +429,17 @@ WHERE ${dateWhere}
const impressions = m?.impressions ?? camp?.impressions ?? 0;
const clicks = m?.clicks ?? camp?.clicks ?? 0;
const conversions = m?.conversions ?? camp?.conversions ?? 0;
// Skip zero-activity asset groups when we have spend elsewhere, keep if any spend
if (spend <= 0 && impressions <= 0 && clicks <= 0 && !m) continue;
const yt = entry.youtubeIds[0] || '';
const imageUrl = entry.images[0] || (yt ? ytThumb(yt) : '');
const headlines = uniq(entry.headlines);
const longHeadlines = uniq(entry.longHeadlines);
const descriptions = uniq(entry.descriptions);
const preview =
pickVerticalGoogle(entry.assets) ||
entry.assets.find((a) => a.kind === 'youtube')?.url ||
'';
const firstYt = entry.assets.find((a) => a.youtubeId)?.youtubeId;
cards.push({
platform: 'google',
adId: entry.adId,
@@ -406,13 +451,19 @@ WHERE ${dateWhere}
clicks,
conversions: Math.round(conversions * 100) / 100,
cpl: conversions > 0 ? spend / conversions : null,
mediaType: yt && !entry.images[0] ? 'video' : entry.images[0] ? 'pmax' : 'pmax',
thumbnailUrl: imageUrl,
imageUrl,
headline: [...new Set(entry.headlines)].slice(0, 5).join('\n'),
primaryText: [...new Set(entry.descriptions)].slice(0, 3).join('\n'),
mediaType: 'pmax',
thumbnailUrl: preview,
imageUrl: preview,
headline: [...headlines, ...longHeadlines].join('\n'),
primaryText: descriptions.join('\n'),
headlines,
longHeadlines,
descriptions,
assets: entry.assets,
linkUrl: entry.finalUrls[0],
cta: uniq(entry.ctas)[0],
status: entry.status,
youtubeId: yt || undefined,
youtubeId: firstYt,
});
}
@@ -421,7 +472,6 @@ WHERE ${dateWhere}
const adId = str(ad?.id);
if (!adId) continue;
const m = adMetricMap.get(adId);
// Prefer ads with activity in range; keep a few without if needed
const spend = m?.spend ?? 0;
const impressions = m?.impressions ?? 0;
const clicks = m?.clicks ?? 0;
@@ -430,24 +480,21 @@ WHERE ${dateWhere}
const rsa = ad?.responsiveSearchAd;
const rda = ad?.responsiveDisplayAd;
const headlines = [
const headlines = uniq([
...(rsa?.headlines || []).map((h) => str(h.text)),
...(rda?.headlines || []).map((h) => str(h.text)),
str(rda?.longHeadline?.text),
].filter(Boolean);
const descriptions = [
]);
const longHeadlines = uniq([str(rda?.longHeadline?.text)]);
const descriptions = uniq([
...(rsa?.descriptions || []).map((d) => str(d.text)),
...(rda?.descriptions || []).map((d) => str(d.text)),
].filter(Boolean);
]);
const imageUrl = str(ad?.imageAd?.imageUrl);
const assets: CreativeAsset[] = [];
if (imageUrl) assets.push({ kind: 'image', url: imageUrl, label: 'IMAGE_AD' });
const adType = enumName(ad?.type).toLowerCase() || 'rsa';
const mediaType = rsa
? 'rsa'
: rda
? 'rda'
: imageUrl
? 'image'
: adType;
const mediaType = rsa ? 'rsa' : rda ? 'rda' : imageUrl ? 'image' : adType;
const paths = [str(rsa?.path1), str(rsa?.path2)].filter(Boolean);
cards.push({
platform: 'google',
@@ -463,17 +510,30 @@ WHERE ${dateWhere}
mediaType,
thumbnailUrl: imageUrl,
imageUrl,
headline: [...new Set(headlines)].slice(0, 5).join('\n'),
primaryText: [...new Set(descriptions)].slice(0, 3).join('\n'),
headline: [...headlines, ...longHeadlines].join('\n'),
primaryText: descriptions.join('\n'),
headlines,
longHeadlines,
descriptions,
assets,
linkUrl: (ad?.finalUrls || []).map(str).filter(Boolean)[0],
cta: str(rda?.callToActionText) || undefined,
businessName: str(rda?.businessName) || undefined,
status: enumName(row.adGroupAd?.status),
});
if (paths.length) {
// encode paths into descriptions note for zoom
cards[cards.length - 1].descriptions = [
...descriptions,
`Path: /${paths.join('/')}`,
];
}
}
cards.sort((a, b) => b.spend - a.spend);
const sliced = cards.slice(0, limit);
if (!sliced.length) {
// Fallback: campaign cards from ad_daily (text only)
const fallback = dailyAggs.slice(0, limit).map((agg) => ({
platform: 'google' as const,
adId: agg.adId,
@@ -489,6 +549,9 @@ WHERE ${dateWhere}
imageUrl: '',
headline: agg.campaignName || agg.adName,
primaryText: 'Sin assets creativos en la API',
headlines: [agg.campaignName || agg.adName].filter(Boolean),
descriptions: ['Sin assets creativos en la API'],
assets: [] as CreativeAsset[],
status: undefined,
}));
return {