From b9ddc7b80614637dae0c996b405ad26c2cde48e2 Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Tue, 6 Oct 2026 13:38:24 +0200 Subject: [PATCH] Show every Google PMax text, image and YouTube asset in Ads zoom. Co-authored-by: Cursor --- src/app/api/ad-tracker/creatives/route.ts | 2 +- src/components/AdCreativesGallery.tsx | 184 ++++++++++---- src/lib/ad-creatives-google.ts | 288 +++++++++++++++------- src/lib/ad-creatives-meta.ts | 10 +- 4 files changed, 350 insertions(+), 134 deletions(-) diff --git a/src/app/api/ad-tracker/creatives/route.ts b/src/app/api/ad-tracker/creatives/route.ts index 0e7811e..8233e23 100644 --- a/src/app/api/ad-tracker/creatives/route.ts +++ b/src/app/api/ad-tracker/creatives/route.ts @@ -183,7 +183,7 @@ export async function GET(req: NextRequest) { dailyRows: daily, since, until, - limit: 48, + limit: 80, }); googleAds = google.ads.length ? google.ads diff --git a/src/components/AdCreativesGallery.tsx b/src/components/AdCreativesGallery.tsx index ccda847..428b795 100644 --- a/src/components/AdCreativesGallery.tsx +++ b/src/components/AdCreativesGallery.tsx @@ -17,26 +17,29 @@ export function proxiedMediaUrl(raw: string): string { } function adHeadlines(ad: AdCreativeCard): string[] { - if (ad.headlines?.length) return ad.headlines.slice(0, 5); - return (ad.headline || '') - .split('\n') - .map((s) => s.trim()) - .filter(Boolean) - .slice(0, 5); + const all = ad.headlines?.length + ? ad.headlines + : (ad.headline || '') + .split('\n') + .map((s) => s.trim()) + .filter(Boolean); + if (ad.platform === 'google') return all; + return all.slice(0, 5); } function adBodies(ad: AdCreativeCard): string[] { - if (ad.bodies?.length) return ad.bodies.slice(0, 5); - // Legacy: primaryText used to hold bodies - return (ad.primaryText || '') - .split('\n') - .map((s) => s.trim()) - .filter(Boolean) - .slice(0, 5); + const all = ad.bodies?.length + ? ad.bodies + : (ad.primaryText || '') + .split('\n') + .map((s) => s.trim()) + .filter(Boolean); + if (ad.platform === 'google') return all; + return all.slice(0, 5); } function adLinkDescriptions(ad: AdCreativeCard): string[] { - if (ad.descriptions?.length) return ad.descriptions.slice(0, 5); + if (ad.descriptions?.length) return ad.descriptions; return []; } @@ -158,19 +161,19 @@ function previewFor(ad: AdCreativeCard): { url: string; aspect: CreativeAspect } function formatThumbs(ad: AdCreativeCard): { url: string; aspect: CreativeAspect; label: string }[] { if (ad.mediaType !== 'pmax' && ad.channel !== 'pmax') return []; const assets = adAssets(ad).filter( - (a) => (a.kind === 'image' || a.kind === 'logo' || a.kind === 'youtube') && a.url + (a) => + (a.kind === 'image' || a.kind === 'logo' || a.kind === 'youtube') && a.url ); - const order: CreativeAspect[] = ['1:1', '4:5', '16:9', '9:16']; - const out: { url: string; aspect: CreativeAspect; label: string }[] = []; const seen = new Set(); - for (const asp of order) { - const hit = assets.find((a) => resolveAspect(a) === asp && !seen.has(a.url)); - if (!hit) continue; - seen.add(hit.url); + const out: { url: string; aspect: CreativeAspect; label: string }[] = []; + for (const a of assets) { + if (seen.has(a.url)) continue; + seen.add(a.url); + const asp = resolveAspect(a); out.push({ - url: proxiedMediaUrl(hit.url), - aspect: asp, - label: asp, + url: proxiedMediaUrl(a.url), + aspect: asp === 'unknown' ? '1:1' : asp, + label: a.label || asp, }); } return out; @@ -451,15 +454,15 @@ function CreativeCard({ Zoom - {pmaxFormats.length > 1 ? ( + {pmaxFormats.length > 0 ? (
- {pmaxFormats.map((f) => ( + {pmaxFormats.slice(0, 12).map((f, i) => ( ))} + {pmaxFormats.length > 12 ? ( +

+ +{pmaxFormats.length - 12} en Zoom +

+ ) : null}
) : null} @@ -585,21 +593,24 @@ export function CreativeZoomModal({ const images = assets.filter((a) => a.kind === 'image' || a.kind === 'logo'); const videos = assets.filter((a) => a.kind === 'youtube' || a.kind === 'video'); const { url: main, aspect: mainAspect } = previewFor(ad); - const previewAssetUrl = main.replace(/^\/api\/ad-tracker\/media\?url=/, ''); - const otherImages = images.filter((a) => { - if (!a.url) return false; - if (!previewAssetUrl) return true; - try { - return decodeURIComponent(previewAssetUrl) !== a.url && a.url !== main; - } catch { - return a.url !== main; - } - }); - // Prefer showing all format variants in zoom for Meta carousel / Google PMax const zoomImages = - otherImages.length > 0 - ? otherImages - : images.filter((a) => a.url && proxiedMediaUrl(a.url) !== main); + ad.platform === 'google' + ? images.filter((a) => a.url) + : images.filter((a) => { + if (!a.url) return false; + const previewAssetUrl = main.replace( + /^\/api\/ad-tracker\/media\?url=/, + '' + ); + if (!previewAssetUrl) return true; + try { + return ( + decodeURIComponent(previewAssetUrl) !== a.url && a.url !== main + ); + } catch { + return a.url !== main; + } + }); useEffect(() => { const onKey = (e: KeyboardEvent) => { @@ -714,7 +725,9 @@ export function CreativeZoomModal({ {zoomImages.length > 0 ? (

- Otras piezas ({zoomImages.length}) + {ad.platform === 'google' + ? `Imágenes y logos (${zoomImages.length})` + : `Otras piezas (${zoomImages.length})`}

{zoomImages.map((asset, i) => { @@ -810,7 +823,7 @@ export function CreativeZoomModal({ Titulares largos ({longH.length})
    - {longH.slice(0, 5).map((h) => ( + {longH.map((h) => (
) : null} + + {ad.businessName ? ( +
+

+ Nombre de negocio +

+

+ {ad.businessName} +

+
+ ) : null} + + {(ad.ctas || []).length > 0 ? ( +
+

+ CTA ({ad.ctas!.length}) +

+
    + {ad.ctas!.map((c) => ( +
  • + {c} +
  • + ))} +
+
+ ) : null} + + {(ad.sitelinks || []).length > 0 ? ( +
+

+ Sitelinks ({ad.sitelinks!.length}) +

+
    + {ad.sitelinks!.map((s) => ( +
  • + {s} +
  • + ))} +
+
+ ) : null} + + {(ad.callouts || []).length > 0 ? ( +
+

+ Callouts ({ad.callouts!.length}) +

+
    + {ad.callouts!.map((s) => ( +
  • + {s} +
  • + ))} +
+
+ ) : null} + + {(ad.snippets || []).length > 0 ? ( +
+

+ Structured snippets ({ad.snippets!.length}) +

+
    + {ad.snippets!.map((s) => ( +
  • + {s} +
  • + ))} +
+
+ ) : null} diff --git a/src/lib/ad-creatives-google.ts b/src/lib/ad-creatives-google.ts index b16c967..3614ba1 100644 --- a/src/lib/ad-creatives-google.ts +++ b/src/lib/ad-creatives-google.ts @@ -8,7 +8,10 @@ import type { CreativeAsset, GoogleCreativeChannel, } from '@/lib/ad-creatives-meta'; -import { aggregateDailyByAd } from '@/lib/ad-creatives-meta'; +import { + aggregateDailyByAd, + inferCreativeAspect, +} from '@/lib/ad-creatives-meta'; import { friendlyGoogleCreativeWarning, resolveGoogleAdsAccessToken, @@ -58,13 +61,23 @@ type GaqlRow = { asset?: { id?: string; type?: string; + name?: string; textAsset?: { text?: string }; - imageAsset?: { fullSize?: { url?: string } }; + imageAsset?: { + fullSize?: { url?: string; widthPixels?: number; heightPixels?: number }; + }; youtubeVideoAsset?: { youtubeVideoId?: string; youtubeVideoTitle?: string; }; callToActionAsset?: { callToAction?: string }; + sitelinkAsset?: { + linkText?: string; + description1?: string; + description2?: string; + }; + calloutAsset?: { calloutText?: string }; + structuredSnippetAsset?: { header?: string; values?: string[] }; }; metrics?: { costMicros?: string; @@ -211,9 +224,14 @@ function ytThumb(youtubeId: string): string { function googleAspectFromFieldType(ft: string): CreativeAspect { const u = ft.toUpperCase(); - if (u.includes('PORTRAIT') || u.includes('TALL')) return '4:5'; - if (u.includes('SQUARE') || u === 'LOGO') return '1:1'; - if (u.includes('YOUTUBE') || u.includes('LANDSCAPE') || u === 'MARKETING_IMAGE') { + if (u.includes('TALL_PORTRAIT') || u.includes('VERTICAL')) return '9:16'; + if (u.includes('PORTRAIT')) return '4:5'; + if (u.includes('SQUARE') || u === 'LOGO' || u === 'BUSINESS_LOGO') return '1:1'; + if ( + u.includes('YOUTUBE') || + u.includes('LANDSCAPE') || + u === 'MARKETING_IMAGE' + ) { return '16:9'; } return 'unknown'; @@ -235,11 +253,6 @@ function pickVerticalGoogle(assets: CreativeAsset[]): string { return images[0]?.url || ''; } -function withAspect(asset: CreativeAsset, ft: string): CreativeAsset { - if (asset.aspect && asset.aspect !== 'unknown') return asset; - return { ...asset, aspect: googleAspectFromFieldType(ft) }; -} - /** Keep Search / PMax / Display represented in the gallery, not only top spend. */ function balanceGoogleCards(cards: AdCreativeCard[], limit: number): AdCreativeCard[] { const buckets: Record = { @@ -259,7 +272,7 @@ function balanceGoogleCards(cards: AdCreativeCard[], limit: number): AdCreativeC const quotas: Record = { search_brand: Math.max(8, Math.floor(limit * 0.2)), search_generic: Math.max(8, Math.floor(limit * 0.2)), - pmax: Math.max(12, Math.floor(limit * 0.35)), + pmax: Math.max(24, Math.floor(limit * 0.4)), display: Math.max(8, Math.floor(limit * 0.2)), other: Math.max(4, Math.floor(limit * 0.1)), }; @@ -295,9 +308,103 @@ type AgBucket = { longHeadlines: string[]; descriptions: string[]; ctas: string[]; + businessNames: string[]; + sitelinks: string[]; + callouts: string[]; + snippets: string[]; assets: CreativeAsset[]; }; +function pushGoogleImage( + list: CreativeAsset[], + img: string, + ft: string, + kind: CreativeAsset['kind'], + width?: number, + height?: number +) { + if (!img || list.some((a) => a.url === img)) return; + const fromPx = inferCreativeAspect(ft, width, height); + const fromFt = googleAspectFromFieldType(ft); + list.push({ + kind, + url: img, + label: ft || 'IMAGE', + aspect: fromPx !== 'unknown' ? fromPx : fromFt, + }); +} + +function pushGoogleYoutube( + list: CreativeAsset[], + yt: string, + title?: string +) { + if (!yt || list.some((a) => a.youtubeId === yt)) return; + list.push({ + kind: 'youtube', + url: ytThumb(yt), + label: 'YOUTUBE_VIDEO', + youtubeId: yt, + title: title || yt, + aspect: '16:9', + }); +} + +const PMAX_ASSET_QUERY = ` +SELECT + campaign.id, + campaign.name, + campaign.advertising_channel_type, + asset_group.id, + asset_group.name, + asset_group.status, + asset_group.final_urls, + asset_group_asset.field_type, + asset.id, + asset.type, + asset.name, + asset.text_asset.text, + asset.image_asset.full_size.url, + asset.image_asset.full_size.width_pixels, + asset.image_asset.full_size.height_pixels, + asset.youtube_video_asset.youtube_video_id, + asset.youtube_video_asset.youtube_video_title, + asset.call_to_action_asset.call_to_action, + asset.sitelink_asset.link_text, + asset.sitelink_asset.description1, + asset.sitelink_asset.description2, + asset.callout_asset.callout_text, + asset.structured_snippet_asset.header, + asset.structured_snippet_asset.values +FROM asset_group_asset +WHERE asset_group.status != 'REMOVED' + AND asset_group_asset.status != 'REMOVED' +`; + +const PMAX_ASSET_QUERY_BASIC = ` +SELECT + campaign.id, + campaign.name, + campaign.advertising_channel_type, + asset_group.id, + asset_group.name, + asset_group.status, + asset_group.final_urls, + asset_group_asset.field_type, + asset.id, + asset.type, + asset.text_asset.text, + asset.image_asset.full_size.url, + asset.image_asset.full_size.width_pixels, + asset.image_asset.full_size.height_pixels, + asset.youtube_video_asset.youtube_video_id, + 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' +`; + function cardsFromDaily( dailyRows: Parameters[0], since: string, @@ -398,30 +505,8 @@ export async function fetchGoogleCreativeCards(opts: { try { const [agAssets, agMetrics, adRows, adMetrics, adAssets] = await Promise.all([ - searchGoogle( - customerId, - accessToken, - ` -SELECT - campaign.id, - campaign.name, - campaign.advertising_channel_type, - asset_group.id, - asset_group.name, - asset_group.status, - asset_group.final_urls, - asset_group_asset.field_type, - asset.id, - asset.type, - 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.call_to_action_asset.call_to_action -FROM asset_group_asset -WHERE asset_group.status != 'REMOVED' - AND asset_group_asset.status != 'REMOVED' -` + searchGoogle(customerId, accessToken, PMAX_ASSET_QUERY).catch(() => + searchGoogle(customerId, accessToken, PMAX_ASSET_QUERY_BASIC) ), searchGoogle( customerId, @@ -466,7 +551,6 @@ SELECT FROM ad_group_ad WHERE ad_group_ad.status != 'REMOVED' AND campaign.status != 'REMOVED' -LIMIT 120 ` ), searchGoogle( @@ -500,11 +584,13 @@ WHERE ad_group_ad.status != 'REMOVED' 'MARKETING_IMAGE', 'SQUARE_MARKETING_IMAGE', 'PORTRAIT_MARKETING_IMAGE', + 'TALL_PORTRAIT_MARKETING_IMAGE', 'LOGO', 'LANDSCAPE_LOGO', + 'BUSINESS_LOGO', + 'AD_IMAGE', 'YOUTUBE_VIDEO' ) -LIMIT 400 ` ).catch(() => [] as GaqlRow[]), ]); @@ -516,30 +602,21 @@ LIMIT 400 const list = assetsByAdId.get(adId) || []; const ft = enumName(row.adGroupAdAssetView?.fieldType).toUpperCase(); const img = str(row.asset?.imageAsset?.fullSize?.url); + const w = n(row.asset?.imageAsset?.fullSize?.widthPixels) || undefined; + const h = n(row.asset?.imageAsset?.fullSize?.heightPixels) || undefined; const yt = str(row.asset?.youtubeVideoAsset?.youtubeVideoId); const ytTitle = str(row.asset?.youtubeVideoAsset?.youtubeVideoTitle); - if (img && !list.some((a) => a.url === img)) { - list.push( - withAspect( - { - kind: ft.includes('LOGO') ? 'logo' : 'image', - url: img, - label: ft || 'MARKETING_IMAGE', - }, - ft || 'MARKETING_IMAGE' - ) + if (img) { + pushGoogleImage( + list, + img, + ft || 'MARKETING_IMAGE', + ft.includes('LOGO') ? 'logo' : 'image', + w, + h ); } - if (yt && !list.some((a) => a.youtubeId === yt)) { - list.push({ - kind: 'youtube', - url: ytThumb(yt), - label: 'YOUTUBE_VIDEO', - youtubeId: yt, - title: ytTitle || yt, - aspect: '16:9', - }); - } + if (yt) pushGoogleYoutube(list, yt, ytTitle); assetsByAdId.set(adId, list); } @@ -613,6 +690,10 @@ LIMIT 400 longHeadlines: [], descriptions: [], ctas: [], + businessNames: [], + sitelinks: [], + callouts: [], + snippets: [], assets: [], }; agMap.set(agid, entry); @@ -620,37 +701,51 @@ LIMIT 400 const ft = enumName(row.assetGroupAsset?.fieldType).toUpperCase(); const text = str(row.asset?.textAsset?.text); const img = str(row.asset?.imageAsset?.fullSize?.url); + const imgW = n(row.asset?.imageAsset?.fullSize?.widthPixels) || undefined; + const imgH = n(row.asset?.imageAsset?.fullSize?.heightPixels) || undefined; const yt = str(row.asset?.youtubeVideoAsset?.youtubeVideoId); const ytTitle = str(row.asset?.youtubeVideoAsset?.youtubeVideoTitle); const cta = enumName(row.asset?.callToActionAsset?.callToAction); + const sitelink = row.asset?.sitelinkAsset; + const callout = str(row.asset?.calloutAsset?.calloutText); + const snippet = row.asset?.structuredSnippetAsset; - if (ft === '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 === '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( - withAspect({ kind: 'image', url: img, label: ft }, ft) - ); - } - } else if ((ft === 'LOGO' || ft === 'LANDSCAPE_LOGO') && img) { - if (!entry.assets.some((a) => a.url === img)) { - entry.assets.push( - withAspect({ kind: 'logo', url: img, label: ft }, ft) - ); - } - } else if (ft === 'YOUTUBE_VIDEO' && 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, - aspect: '16:9', - }); - } + if ((ft === 'HEADLINE' || ft === 'MANDATORY_AD_TEXT') && 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 === 'BUSINESS_NAME' && text) { + entry.businessNames.push(text); + } else if (ft === 'CALL_TO_ACTION_SELECTION' && cta) { + entry.ctas.push(cta); + } else if (ft === 'SITELINK' && sitelink) { + const line = [ + str(sitelink.linkText), + str(sitelink.description1), + str(sitelink.description2), + ] + .filter(Boolean) + .join(' · '); + if (line) entry.sitelinks.push(line); + } else if (ft === 'CALLOUT' && callout) { + entry.callouts.push(callout); + } else if (ft === 'STRUCTURED_SNIPPET' && snippet) { + const vals = (snippet.values || []).map(str).filter(Boolean).join(', '); + const line = [str(snippet.header), vals].filter(Boolean).join(': '); + if (line) entry.snippets.push(line); + } else if (img && (/IMAGE|LOGO/.test(ft) || row.asset?.type === 'IMAGE')) { + pushGoogleImage( + entry.assets, + img, + ft || 'IMAGE', + /LOGO/.test(ft) ? 'logo' : 'image', + imgW, + imgH + ); + } else if (yt && (/VIDEO/.test(ft) || row.asset?.type === 'YOUTUBE_VIDEO')) { + pushGoogleYoutube(entry.assets, yt, ytTitle); } } @@ -665,11 +760,25 @@ LIMIT 400 const impressions = m?.impressions ?? camp?.impressions ?? 0; const clicks = m?.clicks ?? camp?.clicks ?? 0; const conversions = m?.conversions ?? camp?.conversions ?? 0; - if (spend <= 0 && impressions <= 0 && clicks <= 0 && !m) continue; + const isPmax = + /PERFORMANCE_MAX/i.test(entry.channelType) || + /P[\s_-]?MAX|PERFORMANCE[\s_-]?MAX/i.test(entry.campaignName); + const hasPack = + entry.assets.length > 0 || + entry.headlines.length > 0 || + entry.descriptions.length > 0; + if (spend <= 0 && impressions <= 0 && clicks <= 0 && !m && !(isPmax && hasPack)) { + continue; + } const headlines = uniq(entry.headlines); const longHeadlines = uniq(entry.longHeadlines); const descriptions = uniq(entry.descriptions); + const sitelinks = uniq(entry.sitelinks); + const callouts = uniq(entry.callouts); + const snippets = uniq(entry.snippets); + const ctas = uniq(entry.ctas); + const businessName = uniq(entry.businessNames)[0]; const preview = pickVerticalGoogle(entry.assets) || entry.assets.find((a) => a.kind === 'youtube')?.url || @@ -701,9 +810,14 @@ LIMIT 400 bodies: descriptions, longHeadlines, descriptions: [], + sitelinks, + callouts, + snippets, + ctas, assets: entry.assets, linkUrl: entry.finalUrls[0], - cta: uniq(entry.ctas)[0], + cta: ctas[0], + businessName, status: entry.status, youtubeId: firstYt, }); @@ -827,6 +941,8 @@ LIMIT 400 c.impressions > 0 || c.clicks > 0 || c.conversions > 0 || + (c.mediaType === 'pmax' && + ((c.assets?.length || 0) > 0 || (c.headlines?.length || 0) > 0)) || (c.mediaType === 'rsa' && (c.headlines?.length || 0) > 0) || ((c.mediaType === 'rda' || c.mediaType === 'image') && (c.assets?.length || 0) > 0) diff --git a/src/lib/ad-creatives-meta.ts b/src/lib/ad-creatives-meta.ts index be5adfb..f0b5673 100644 --- a/src/lib/ad-creatives-meta.ts +++ b/src/lib/ad-creatives-meta.ts @@ -61,13 +61,17 @@ export type AdCreativeCard = { /** Joined for backwards compat; prefer headlines/bodies/descriptions arrays. */ headline: string; primaryText: string; - /** Titulares (Meta titles / Google headlines), máx. 5. */ + /** Titulares (Meta titles / Google headlines). */ headlines: string[]; - /** Textos principales (Meta bodies), máx. 5. */ + /** Textos principales (Meta bodies / Google descriptions). */ bodies: string[]; - /** Descripción del enlace (Meta descriptions), suele ser 1. */ + /** Descripción del enlace (Meta) u otros textos cortos. */ descriptions: string[]; longHeadlines?: string[]; + sitelinks?: string[]; + callouts?: string[]; + snippets?: string[]; + ctas?: string[]; assets: CreativeAsset[]; linkUrl?: string; cta?: string;