From ce6dc4e0f0e1bf3f502334ff119e8ac1ff892fab Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Mon, 5 Oct 2026 17:56:05 +0200 Subject: [PATCH] fix: split Google Ads creatives by Search / PMax / Display Fetch display/RDA assets, classify Brand vs Generic Search, and group the Ad Tracker gallery so previews are not a single undifferentiated image. Co-authored-by: Cursor --- src/app/api/ad-tracker/creatives/route.ts | 2 +- src/components/AdCreativesGallery.tsx | 123 ++++++++++++++++- src/lib/ad-creatives-google.ts | 158 ++++++++++++++++++++-- src/lib/ad-creatives-meta.ts | 10 ++ 4 files changed, 278 insertions(+), 15 deletions(-) diff --git a/src/app/api/ad-tracker/creatives/route.ts b/src/app/api/ad-tracker/creatives/route.ts index 8b64faf..0e7811e 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: 24, + limit: 48, }); googleAds = google.ads.length ? google.ads diff --git a/src/components/AdCreativesGallery.tsx b/src/components/AdCreativesGallery.tsx index f2335a6..1c00239 100644 --- a/src/components/AdCreativesGallery.tsx +++ b/src/components/AdCreativesGallery.tsx @@ -148,6 +148,72 @@ export function CreativesGallery({ ? warning : null); + const googleGroups: { + key: string; + title: string; + hint: string; + items: AdCreativeCard[]; + }[] = + platform === 'google' + ? [ + { + key: 'search_brand', + title: 'Search · Brand', + hint: 'RSA de marca (titulares + URL)', + items: filtered.filter((a) => a.channel === 'search_brand'), + }, + { + key: 'search_generic', + title: 'Search · Generic', + hint: 'RSA genéricos / captación', + items: filtered.filter( + (a) => + a.channel === 'search_generic' || + (!a.channel && a.mediaType === 'rsa') + ), + }, + { + key: 'pmax', + title: 'Performance Max', + hint: 'Asset groups · imágenes, logos y vídeo', + items: filtered.filter( + (a) => a.channel === 'pmax' || a.mediaType === 'pmax' + ), + }, + { + key: 'display', + title: 'Display · Remarketing', + hint: 'RDA / image ads · creatividades visuales', + items: filtered.filter( + (a) => + a.channel === 'display' || + a.mediaType === 'rda' || + a.mediaType === 'image' + ), + }, + { + key: 'other', + title: 'Otros Google', + hint: 'Campañas sin clasificar', + items: filtered.filter((a) => { + const ch = a.channel; + if ( + ch === 'search_brand' || + ch === 'search_generic' || + ch === 'pmax' || + ch === 'display' + ) { + return false; + } + if (!ch && (a.mediaType === 'rsa' || a.mediaType === 'pmax' || a.mediaType === 'rda' || a.mediaType === 'image')) { + return false; + } + return true; + }), + }, + ].filter((g) => g.items.length > 0) + : []; + return (
@@ -158,7 +224,9 @@ export function CreativesGallery({ {platform === 'meta' ? 'Meta Ads' : 'Google Ads'} · creatividades

- Preview 9:16. Pulsa Zoom para ver el resto de piezas en su formato. + {platform === 'google' + ? 'Agrupado por Search Brand / Generic, Performance Max y Display. Search muestra preview tipo SERP; PMax y Display muestran assets.' + : 'Preview 9:16. Pulsa Zoom para ver el resto de piezas en su formato.'}

{platformWarning ? ( @@ -174,6 +242,26 @@ export function CreativesGallery({

Sin creatividades para este periodo.

+ ) : platform === 'google' && googleGroups.length > 0 ? ( +
+ {googleGroups.map((g) => ( +
+
+

{g.title}

+

{g.hint} · {g.items.length}

+
+
+ {g.items.map((ad) => ( + onZoom(ad)} + /> + ))} +
+
+ ))} +
) : (
{filtered.map((ad) => ( @@ -219,10 +307,41 @@ function CreativeCard({ className="w-full h-full object-cover" loading="lazy" /> + ) : ad.platform === 'google' && + (ad.mediaType === 'rsa' || + ad.channel === 'search_brand' || + ad.channel === 'search_generic') && + headlines.length ? ( +
+

+ Anuncio ·{' '} + {ad.channel === 'search_brand' + ? 'Search Brand' + : ad.channel === 'search_generic' + ? 'Search Generic' + : 'Search'} +

+

+ {(ad.linkUrl || 'https://ejemplo.com').replace(/^https?:\/\//, '')} +

+ {headlines.slice(0, 2).map((h) => ( +

+ {h} +

+ ))} + {bodies[0] ? ( +

+ {bodies[0]} +

+ ) : null} +
) : ad.platform === 'google' && headlines.length ? (

- Google · {ad.mediaType || 'rsa'} + Google · {ad.mediaType || 'ad'}

{headlines.slice(0, 3).map((h) => (

{ - const limit = opts.limit ?? 24; + const limit = opts.limit ?? 48; const warehouseFallback = (warning: string) => { const ads = cardsFromDaily( opts.dailyRows, @@ -284,7 +333,7 @@ export async function fetchGoogleCreativeCards(opts: { const dateWhere = `segments.date BETWEEN '${opts.since}' AND '${opts.until}'`; try { - const [agAssets, agMetrics, adRows, adMetrics] = await Promise.all([ + const [agAssets, agMetrics, adRows, adMetrics, adAssets] = await Promise.all([ searchGoogle( customerId, accessToken, @@ -292,6 +341,7 @@ export async function fetchGoogleCreativeCards(opts: { SELECT campaign.id, campaign.name, + campaign.advertising_channel_type, asset_group.id, asset_group.name, asset_group.status, @@ -330,6 +380,7 @@ WHERE ${dateWhere} SELECT campaign.id, campaign.name, + campaign.advertising_channel_type, ad_group.id, ad_group.name, ad_group_ad.status, @@ -351,7 +402,7 @@ SELECT FROM ad_group_ad WHERE ad_group_ad.status != 'REMOVED' AND campaign.status != 'REMOVED' -LIMIT 80 +LIMIT 120 ` ), searchGoogle( @@ -368,8 +419,60 @@ FROM ad_group_ad WHERE ${dateWhere} ` ), + searchGoogle( + customerId, + accessToken, + ` +SELECT + ad_group_ad.ad.id, + ad_group_ad_asset_view.field_type, + asset.image_asset.full_size.url, + asset.youtube_video_asset.youtube_video_id, + asset.youtube_video_asset.youtube_video_title +FROM ad_group_ad_asset_view +WHERE ad_group_ad.status != 'REMOVED' + AND campaign.status != 'REMOVED' + AND ad_group_ad_asset_view.field_type IN ( + 'MARKETING_IMAGE', + 'SQUARE_MARKETING_IMAGE', + 'PORTRAIT_MARKETING_IMAGE', + 'LOGO', + 'LANDSCAPE_LOGO', + 'YOUTUBE_VIDEO' + ) +LIMIT 400 +` + ).catch(() => [] as GaqlRow[]), ]); + const assetsByAdId = new Map(); + for (const row of adAssets) { + const adId = str(row.adGroupAd?.ad?.id); + if (!adId) continue; + const list = assetsByAdId.get(adId) || []; + const ft = enumName(row.adGroupAdAssetView?.fieldType).toUpperCase(); + const img = str(row.asset?.imageAsset?.fullSize?.url); + const yt = str(row.asset?.youtubeVideoAsset?.youtubeVideoId); + const ytTitle = str(row.asset?.youtubeVideoAsset?.youtubeVideoTitle); + if (img && !list.some((a) => a.url === img)) { + list.push({ + kind: ft.includes('LOGO') ? 'logo' : 'image', + url: img, + label: ft || 'MARKETING_IMAGE', + }); + } + if (yt && !list.some((a) => a.youtubeId === yt)) { + list.push({ + kind: 'youtube', + url: ytThumb(yt), + label: 'YOUTUBE_VIDEO', + youtubeId: yt, + title: ytTitle || yt, + }); + } + assetsByAdId.set(adId, list); + } + const agMetricMap = new Map< string, { spend: number; impressions: number; clicks: number; conversions: number } @@ -433,6 +536,7 @@ WHERE ${dateWhere} adName: str(row.assetGroup?.name) || `Asset group ${agid}`, campaignId: str(row.campaign?.id), campaignName: str(row.campaign?.name), + channelType: enumName(row.campaign?.advertisingChannelType), status: enumName(row.assetGroup?.status) || 'ENABLED', finalUrls: (row.assetGroup?.finalUrls || []).map(str).filter(Boolean), headlines: [], @@ -509,6 +613,11 @@ WHERE ${dateWhere} conversions: Math.round(conversions * 100) / 100, cpl: conversions > 0 ? spend / conversions : null, mediaType: 'pmax', + channel: classifyGoogleChannel({ + campaignName: entry.campaignName, + channelType: entry.channelType, + mediaType: 'pmax', + }), thumbnailUrl: preview, imageUrl: preview, headline: [...headlines, ...longHeadlines].join('\n'), @@ -548,26 +657,50 @@ WHERE ${dateWhere} ...(rda?.descriptions || []).map((d) => str(d.text)), ]); const imageUrl = str(ad?.imageAd?.imageUrl); - const assets: CreativeAsset[] = []; - if (imageUrl) assets.push({ kind: 'image', url: imageUrl, label: 'IMAGE_AD' }); + const linkedAssets = assetsByAdId.get(adId) || []; + const assets: CreativeAsset[] = [...linkedAssets]; + if (imageUrl && !assets.some((a) => a.url === 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 || linkedAssets.length + ? 'image' + : adType; const paths = [str(rsa?.path1), str(rsa?.path2)].filter(Boolean); + const channelType = enumName(row.campaign?.advertisingChannelType); + const campaignName = str(row.campaign?.name); + const adsetName = str(row.adGroup?.name); + const preview = + pickVerticalGoogle(assets) || + assets.find((a) => a.kind === 'image')?.url || + assets.find((a) => a.kind === 'youtube')?.url || + ''; + const firstYt = assets.find((a) => a.youtubeId)?.youtubeId; cards.push({ platform: 'google', adId, adName: str(ad?.name) || headlines[0] || `Ad ${adId}`, - campaignName: str(row.campaign?.name), - adsetName: str(row.adGroup?.name), + campaignName, + adsetName, spend: Math.round(spend * 100) / 100, impressions, clicks, conversions: Math.round(conversions * 100) / 100, cpl: conversions > 0 ? spend / conversions : null, mediaType, - thumbnailUrl: imageUrl, - imageUrl, + channel: classifyGoogleChannel({ + campaignName, + channelType, + mediaType, + adsetName, + }), + thumbnailUrl: preview, + imageUrl: preview, headline: [...headlines, ...longHeadlines].join('\n'), primaryText: descriptions.join('\n'), headlines, @@ -579,6 +712,7 @@ WHERE ${dateWhere} cta: str(rda?.callToActionText) || undefined, businessName: str(rda?.businessName) || undefined, status: enumName(row.adGroupAd?.status), + youtubeId: firstYt, }); if (paths.length) { cards[cards.length - 1].bodies = [ diff --git a/src/lib/ad-creatives-meta.ts b/src/lib/ad-creatives-meta.ts index f73784f..80a344b 100644 --- a/src/lib/ad-creatives-meta.ts +++ b/src/lib/ad-creatives-meta.ts @@ -33,6 +33,14 @@ export type CreativeAsset = { title?: string; }; +/** Google Ads channel bucket for gallery grouping. */ +export type GoogleCreativeChannel = + | 'search_brand' + | 'search_generic' + | 'pmax' + | 'display' + | 'other'; + export type AdCreativeCard = { platform: 'meta' | 'google'; adId: string; @@ -45,6 +53,8 @@ export type AdCreativeCard = { conversions: number; cpl: number | null; mediaType: string; + /** Google: search_brand | search_generic | pmax | display | other */ + channel?: GoogleCreativeChannel; /** Best preview URL (prefer vertical / portrait). */ thumbnailUrl: string; imageUrl: string;