diff --git a/src/components/AdCreativesGallery.tsx b/src/components/AdCreativesGallery.tsx index 1c00239..ccda847 100644 --- a/src/components/AdCreativesGallery.tsx +++ b/src/components/AdCreativesGallery.tsx @@ -6,6 +6,7 @@ import type { CreativeAspect, CreativeAsset, } from '@/lib/ad-creatives-meta'; +import { pickMetaPreviewAsset } from '@/lib/ad-creatives-meta'; import { formatEur, formatEurCents, formatNum } from '@/lib/metrics'; import { ImageIcon, Play, Maximize2, X, ExternalLink } from 'lucide-react'; @@ -61,7 +62,7 @@ function resolveAspect(asset: CreativeAsset): CreativeAspect { const L = (asset.label || '').toLowerCase(); if (/9\s*:?\s*16|story|reels|vertical|tall/.test(L)) return '9:16'; if (/4\s*:?\s*5|portrait/.test(L)) return '4:5'; - if (/16\s*:?\s*9|1\.91|landscape|horizontal|youtube|video/.test(L)) { + if (/16\s*:?\s*9|1\.91|landscape|horizontal|youtube/.test(L)) { return '16:9'; } if (/1\s*:?\s*1|square|logo/.test(L)) return '1:1'; @@ -83,17 +84,55 @@ function aspectClass(aspect: CreativeAspect): string { } } -function previewUrlFor(ad: AdCreativeCard): string { +/** Meta: frame 9:16 by default; carousel 1:1 then 4:5. Google PMax stays portrait-first. */ +function previewMeta(ad: AdCreativeCard): { url: string; aspect: CreativeAspect } { const assets = adAssets(ad); - const nine = assets.find( - (a) => a.kind === 'image' && resolveAspect(a) === '9:16' + const picked = pickMetaPreviewAsset( + assets, + ad.mediaType || 'image', + ad.imageUrl || ad.thumbnailUrl || '' ); - const fourFive = assets.find( - (a) => a.kind === 'image' && resolveAspect(a) === '4:5' + // Frame rules (user): vertical default; carousel square / 4:5 + const aspect: CreativeAspect = + ad.mediaType === 'carousel' + ? picked.aspect === '4:5' + ? '4:5' + : '1:1' + : '9:16'; + const raw = + picked.url || + ad.imageUrl || + ad.thumbnailUrl || + assets.find((a) => a.url)?.url || + ''; + return { url: raw ? proxiedMediaUrl(raw) : '', aspect }; +} + +function previewGoogle(ad: AdCreativeCard): { url: string; aspect: CreativeAspect } { + const assets = adAssets(ad); + const isSearch = + ad.mediaType === 'rsa' || + ad.channel === 'search_brand' || + ad.channel === 'search_generic'; + if (isSearch) { + return { url: '', aspect: '9:16' }; + } + const portrait = assets.find( + (a) => + (a.kind === 'image' || a.kind === 'logo') && + (resolveAspect(a) === '4:5' || resolveAspect(a) === '9:16') + ); + const square = assets.find( + (a) => + (a.kind === 'image' || a.kind === 'logo') && resolveAspect(a) === '1:1' + ); + const landscape = assets.find( + (a) => a.kind === 'image' && resolveAspect(a) === '16:9' ); const raw = - nine?.url || - fourFive?.url || + portrait?.url || + square?.url || + landscape?.url || ad.imageUrl || ad.thumbnailUrl || assets.find((a) => a.kind === 'image')?.url || @@ -102,7 +141,39 @@ function previewUrlFor(ad: AdCreativeCard): string { : '') || assets.find((a) => a.youtubeId)?.url || ''; - return raw ? proxiedMediaUrl(raw) : ''; + const aspect: CreativeAspect = portrait + ? resolveAspect(portrait) + : square + ? '1:1' + : landscape + ? '16:9' + : '4:5'; + return { url: raw ? proxiedMediaUrl(raw) : '', aspect }; +} + +function previewFor(ad: AdCreativeCard): { url: string; aspect: CreativeAspect } { + return ad.platform === 'meta' ? previewMeta(ad) : previewGoogle(ad); +} + +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 + ); + 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); + out.push({ + url: proxiedMediaUrl(hit.url), + aspect: asp, + label: asp, + }); + } + return out; } function MiniKpi({ label, value }: { label: string; value: string }) { @@ -225,8 +296,8 @@ export function CreativesGallery({

{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.'} + ? 'Agrupado por Search Brand / Generic, Performance Max y Display. Search muestra preview tipo SERP; PMax muestra todos los formatos (1:1, 4:5, 16:9).' + : 'Meta: preview vertical 9:16 por defecto; carrusel en 1:1 (o 4:5). Pulsa Zoom para el resto de piezas.'}

{platformWarning ? ( @@ -284,94 +355,129 @@ function CreativeCard({ ad: AdCreativeCard; onZoom: () => void; }) { - const preview = previewUrlFor(ad); + const { url: preview, aspect: frameAspect } = previewFor(ad); const isVideo = ad.mediaType === 'video' || Boolean(ad.youtubeId); const headlines = adHeadlines(ad); const bodies = adBodies(ad); const linkDescs = adLinkDescriptions(ad); const assets = adAssets(ad); const extraCount = Math.max(0, assets.length - 1); + const pmaxFormats = formatThumbs(ad); + const isSearch = + ad.platform === 'google' && + (ad.mediaType === 'rsa' || + ad.channel === 'search_brand' || + ad.channel === 'search_generic'); return ( -
- + {pmaxFormats.length > 1 ? ( +
+ {pmaxFormats.map((f) => ( +
- ) : ad.platform === 'google' && headlines.length ? ( -
-

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

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

- {h} -

+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {f.label} +
+

+ {f.label} +

+ ))}
- ) : ( -
- - Sin preview -
- )} - {isVideo && preview ? ( - - Vídeo - ) : null} - {extraCount > 0 ? ( - - +{extraCount} - - ) : null} - - Zoom - - +
@@ -478,10 +584,22 @@ export function CreativeZoomModal({ const assets = adAssets(ad); const images = assets.filter((a) => a.kind === 'image' || a.kind === 'logo'); const videos = assets.filter((a) => a.kind === 'youtube' || a.kind === 'video'); - const main = previewUrlFor(ad); - const previewAsset = - images.find((a) => resolveAspect(a) === '9:16') || images[0]; - const otherImages = images.filter((a) => a.url !== previewAsset?.url); + 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); useEffect(() => { const onKey = (e: KeyboardEvent) => { @@ -534,7 +652,9 @@ export function CreativeZoomModal({
-
+
{main ? ( // eslint-disable-next-line @next/next/no-img-element

- Preview 9:16 + Preview {mainAspect}

@@ -591,13 +711,13 @@ export function CreativeZoomModal({
- {otherImages.length > 0 ? ( + {zoomImages.length > 0 ? (

- Otras piezas ({otherImages.length}) + Otras piezas ({zoomImages.length})

- {otherImages.map((asset, i) => { + {zoomImages.map((asset, i) => { const aspect = resolveAspect(asset); return (
a.kind === 'image' && a.url); - const portrait = images.find((a) => - /PORTRAIT|TALL|VERTICAL/i.test(a.label || '') + const portrait = images.find( + (a) => + a.aspect === '4:5' || + a.aspect === '9:16' || + /PORTRAIT|TALL|VERTICAL/i.test(a.label || '') ); if (portrait) return portrait.url; - const square = images.find((a) => /SQUARE/i.test(a.label || '')); + const square = images.find( + (a) => a.aspect === '1:1' || /SQUARE|LOGO/i.test(a.label || '') + ); if (square) return square.url; 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 = { + search_brand: [], + search_generic: [], + pmax: [], + display: [], + other: [], + }; + for (const c of cards) { + const key = c.channel && buckets[c.channel] ? c.channel : 'other'; + buckets[key].push(c); + } + for (const k of Object.keys(buckets)) { + buckets[k].sort((a, b) => b.spend - a.spend); + } + 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)), + display: Math.max(8, Math.floor(limit * 0.2)), + other: Math.max(4, Math.floor(limit * 0.1)), + }; + const picked: AdCreativeCard[] = []; + const seen = new Set(); + for (const [key, quota] of Object.entries(quotas)) { + for (const c of buckets[key].slice(0, quota)) { + if (seen.has(c.adId)) continue; + seen.add(c.adId); + picked.push(c); + } + } + // Fill remaining slots by spend across leftovers + const leftovers = cards + .filter((c) => !seen.has(c.adId)) + .sort((a, b) => b.spend - a.spend); + for (const c of leftovers) { + if (picked.length >= limit) break; + picked.push(c); + } + return picked.sort((a, b) => b.spend - a.spend).slice(0, limit); +} + type AgBucket = { adId: string; adName: string; @@ -455,11 +519,16 @@ LIMIT 400 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', - }); + list.push( + withAspect( + { + kind: ft.includes('LOGO') ? 'logo' : 'image', + url: img, + label: ft || 'MARKETING_IMAGE', + }, + ft || 'MARKETING_IMAGE' + ) + ); } if (yt && !list.some((a) => a.youtubeId === yt)) { list.push({ @@ -468,6 +537,7 @@ LIMIT 400 label: 'YOUTUBE_VIDEO', youtubeId: yt, title: ytTitle || yt, + aspect: '16:9', }); } assetsByAdId.set(adId, list); @@ -560,11 +630,15 @@ LIMIT 400 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 }); + 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({ kind: 'logo', url: img, label: ft }); + 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)) { @@ -574,6 +648,7 @@ LIMIT 400 label: 'YOUTUBE_VIDEO', youtubeId: yt, title: ytTitle || yt, + aspect: '16:9', }); } } @@ -639,12 +714,6 @@ LIMIT 400 const adId = str(ad?.id); if (!adId) continue; const m = adMetricMap.get(adId); - const spend = m?.spend ?? 0; - const impressions = m?.impressions ?? 0; - const clicks = m?.clicks ?? 0; - const conversions = m?.conversions ?? 0; - if (!m && cards.length >= limit) continue; - const rsa = ad?.responsiveSearchAd; const rda = ad?.responsiveDisplayAd; const headlines = uniq([ @@ -660,7 +729,12 @@ LIMIT 400 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' }); + assets.push({ + kind: 'image', + url: imageUrl, + label: 'IMAGE_AD', + aspect: '1:1', + }); } const adType = enumName(ad?.type).toLowerCase() || 'rsa'; const mediaType = rsa @@ -674,6 +748,31 @@ LIMIT 400 const channelType = enumName(row.campaign?.advertisingChannelType); const campaignName = str(row.campaign?.name); const adsetName = str(row.adGroup?.name); + const campaignId = str(row.campaign?.id); + // ad_daily is often campaign-level for Google → fallback so Search/Display appear + const camp = + campaignSpend.get(campaignId) || + campaignSpend.get(campaignName.toLowerCase()); + const spend = m?.spend ?? camp?.spend ?? 0; + const impressions = m?.impressions ?? camp?.impressions ?? 0; + const clicks = m?.clicks ?? camp?.clicks ?? 0; + const conversions = m?.conversions ?? camp?.conversions ?? 0; + const hasCreative = + headlines.length > 0 || + descriptions.length > 0 || + assets.length > 0 || + Boolean(rsa) || + Boolean(rda); + if ( + spend <= 0 && + impressions <= 0 && + clicks <= 0 && + !m && + !(hasCreative && camp && (camp.spend > 0 || camp.impressions > 0)) + ) { + continue; + } + const preview = pickVerticalGoogle(assets) || assets.find((a) => a.kind === 'image')?.url || @@ -722,13 +821,17 @@ LIMIT 400 } } - cards.sort((a, b) => b.spend - a.spend); - const sliced = cards - .filter( - (c) => - c.spend > 0 || c.impressions > 0 || c.clicks > 0 || c.conversions > 0 - ) - .slice(0, limit); + const active = cards.filter( + (c) => + c.spend > 0 || + c.impressions > 0 || + c.clicks > 0 || + c.conversions > 0 || + (c.mediaType === 'rsa' && (c.headlines?.length || 0) > 0) || + ((c.mediaType === 'rda' || c.mediaType === 'image') && + (c.assets?.length || 0) > 0) + ); + const sliced = balanceGoogleCards(active, limit); if (!sliced.length) { return warehouseFallback( diff --git a/src/lib/ad-creatives-meta.ts b/src/lib/ad-creatives-meta.ts index 80a344b..be5adfb 100644 --- a/src/lib/ad-creatives-meta.ts +++ b/src/lib/ad-creatives-meta.ts @@ -105,12 +105,7 @@ export function inferCreativeAspect( height?: number ): CreativeAspect { const L = (label || '').toLowerCase(); - if (/9\s*:?\s*16|story|reels|vertical|tall|portrait_9/.test(L)) return '9:16'; - if (/4\s*:?\s*5|portrait|feed_portrait/.test(L)) return '4:5'; - if (/16\s*:?\s*9|1\.91|landscape|horizontal|right_column|video/.test(L)) { - return '16:9'; - } - if (/1\s*:?\s*1|square/.test(L)) return '1:1'; + // Dimensiones primero: labels tipo "video" no deben forzar landscape. if (width && height && width > 0 && height > 0) { const r = width / height; if (r <= 0.62) return '9:16'; @@ -118,6 +113,12 @@ export function inferCreativeAspect( if (r >= 1.5) return '16:9'; if (Math.abs(r - 1) < 0.12) return '1:1'; } + if (/9\s*:?\s*16|story|reels|vertical|tall|portrait_9/.test(L)) return '9:16'; + if (/4\s*:?\s*5|portrait|feed_portrait/.test(L)) return '4:5'; + if (/16\s*:?\s*9|1\.91|landscape|horizontal|right_column|youtube/.test(L)) { + return '16:9'; + } + if (/1\s*:?\s*1|square/.test(L)) return '1:1'; return 'unknown'; } @@ -201,7 +202,8 @@ function extractCreativePack(creative: Record): Extracted { if (Array.isArray(link?.child_attachments)) { (link!.child_attachments as Record[]).forEach((c, i) => { - pushImg(c.picture || c.image_url, `carousel-${i + 1} 4:5`, 1080, 1350); + // Carrusel: preferimos 1:1; si el asset es más alto, se reclasifica por ratio. + pushImg(c.picture || c.image_url, `carousel-${i + 1} 1:1`, 1080, 1080); pushHash(c.image_hash); if (c.name) headlines.push(str(c.name)); if (c.message) bodies.push(str(c.message)); @@ -269,22 +271,56 @@ function extractCreativePack(creative: Record): Extracted { }; } -/** Prefer 9:16 story; then 4:5; else first image. */ -function pickVerticalPreview(assets: CreativeAsset[], fallback: string): string { - const images = assets.filter((a) => a.kind === 'image' && a.url); - const nine = images.find((a) => a.aspect === '9:16'); - if (nine) return nine.url; - const portrait = images.find( +/** + * Meta preview picker: + * - Default: vertical 9:16 (vídeo/imagen), then 4:5 + * - Carousel: 1:1, then 4:5 + */ +export function pickMetaPreviewAsset( + assets: CreativeAsset[], + mediaType: string, + fallbackUrl = '' +): { url: string; aspect: CreativeAspect } { + const withUrl = assets.filter((a) => a.url); + const aspectOf = (a: CreativeAsset) => + a.aspect && a.aspect !== 'unknown' + ? a.aspect + : inferCreativeAspect(a.label); + + if (mediaType === 'carousel') { + const square = withUrl.find((a) => aspectOf(a) === '1:1'); + if (square) return { url: square.url, aspect: '1:1' }; + const fourFive = withUrl.find((a) => aspectOf(a) === '4:5'); + if (fourFive) return { url: fourFive.url, aspect: '4:5' }; + const carousel = withUrl.find((a) => /carousel/i.test(a.label || '')); + if (carousel) return { url: carousel.url, aspect: '1:1' }; + if (withUrl[0]) return { url: withUrl[0].url, aspect: '1:1' }; + return { url: fallbackUrl, aspect: '1:1' }; + } + + // Prefer video poster/frame labeled 9:16, then any 9:16 image, then 4:5 + const videoNine = withUrl.find( (a) => - a.aspect === '4:5' || - /portrait|vertical|9.?16|4.?5|tall|story/i.test(a.label || '') + (a.kind === 'video' || /video|reel|story/i.test(a.label || '')) && + aspectOf(a) === '9:16' ); - if (portrait) return portrait.url; - const nonSquare = images.find( - (a) => a.label?.includes('thumbnail') || a.label?.includes('video_poster') + if (videoNine) return { url: videoNine.url, aspect: '9:16' }; + + const nine = withUrl.find((a) => aspectOf(a) === '9:16'); + if (nine) return { url: nine.url, aspect: '9:16' }; + + const fourFive = withUrl.find((a) => aspectOf(a) === '4:5'); + if (fourFive) return { url: fourFive.url, aspect: '4:5' }; + + const thumb = withUrl.find((a) => + /thumbnail|video_poster|video_feed/i.test(a.label || '') ); - if (nonSquare) return nonSquare.url; - return images[0]?.url || fallback; + if (thumb) return { url: thumb.url, aspect: '9:16' }; + + if (withUrl[0]) { + return { url: withUrl[0].url, aspect: aspectOf(withUrl[0]) || '9:16' }; + } + return { url: fallbackUrl, aspect: '9:16' }; } async function sleep(ms: number) { @@ -657,7 +693,8 @@ function cardFromBundle( const creative = b?.creative || {}; const fallback = str(creative.image_url) || str(creative.thumbnail_url) || ''; - const preview = pickVerticalPreview(assets, fallback); + const mediaType = detectMediaType(creative); + const picked = pickMetaPreviewAsset(assets, mediaType, fallback); const headlines = pack.headlines; const bodies = pack.bodies; const descriptions = pack.descriptions; @@ -672,9 +709,9 @@ function cardFromBundle( clicks: agg.clicks, conversions: agg.conversions, cpl: agg.conversions > 0 ? agg.spend / agg.conversions : null, - mediaType: detectMediaType(creative), - thumbnailUrl: preview, - imageUrl: preview, + mediaType, + thumbnailUrl: picked.url, + imageUrl: picked.url, headline: headlines.join('\n'), primaryText: bodies.join('\n') || descriptions.join('\n'), headlines,