fix: Meta previews vertical by default; show all Google ad formats
Meta cards use a 9:16 frame (carousel 1:1 then 4:5). Google gallery balances Search/PMax/Display and surfaces PMax multi-format thumbs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import type {
|
|||||||
CreativeAspect,
|
CreativeAspect,
|
||||||
CreativeAsset,
|
CreativeAsset,
|
||||||
} from '@/lib/ad-creatives-meta';
|
} from '@/lib/ad-creatives-meta';
|
||||||
|
import { pickMetaPreviewAsset } from '@/lib/ad-creatives-meta';
|
||||||
import { formatEur, formatEurCents, formatNum } from '@/lib/metrics';
|
import { formatEur, formatEurCents, formatNum } from '@/lib/metrics';
|
||||||
import { ImageIcon, Play, Maximize2, X, ExternalLink } from 'lucide-react';
|
import { ImageIcon, Play, Maximize2, X, ExternalLink } from 'lucide-react';
|
||||||
|
|
||||||
@@ -61,7 +62,7 @@ function resolveAspect(asset: CreativeAsset): CreativeAspect {
|
|||||||
const L = (asset.label || '').toLowerCase();
|
const L = (asset.label || '').toLowerCase();
|
||||||
if (/9\s*:?\s*16|story|reels|vertical|tall/.test(L)) return '9:16';
|
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 (/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';
|
return '16:9';
|
||||||
}
|
}
|
||||||
if (/1\s*:?\s*1|square|logo/.test(L)) return '1:1';
|
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 assets = adAssets(ad);
|
||||||
const nine = assets.find(
|
const picked = pickMetaPreviewAsset(
|
||||||
(a) => a.kind === 'image' && resolveAspect(a) === '9:16'
|
assets,
|
||||||
|
ad.mediaType || 'image',
|
||||||
|
ad.imageUrl || ad.thumbnailUrl || ''
|
||||||
);
|
);
|
||||||
const fourFive = assets.find(
|
// Frame rules (user): vertical default; carousel square / 4:5
|
||||||
(a) => a.kind === 'image' && resolveAspect(a) === '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 =
|
const raw =
|
||||||
nine?.url ||
|
portrait?.url ||
|
||||||
fourFive?.url ||
|
square?.url ||
|
||||||
|
landscape?.url ||
|
||||||
ad.imageUrl ||
|
ad.imageUrl ||
|
||||||
ad.thumbnailUrl ||
|
ad.thumbnailUrl ||
|
||||||
assets.find((a) => a.kind === 'image')?.url ||
|
assets.find((a) => a.kind === 'image')?.url ||
|
||||||
@@ -102,7 +141,39 @@ function previewUrlFor(ad: AdCreativeCard): string {
|
|||||||
: '') ||
|
: '') ||
|
||||||
assets.find((a) => a.youtubeId)?.url ||
|
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<string>();
|
||||||
|
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 }) {
|
function MiniKpi({ label, value }: { label: string; value: string }) {
|
||||||
@@ -225,8 +296,8 @@ export function CreativesGallery({
|
|||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
{platform === 'google'
|
{platform === 'google'
|
||||||
? 'Agrupado por Search Brand / Generic, Performance Max y Display. Search muestra preview tipo SERP; PMax y Display muestran assets.'
|
? '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).'
|
||||||
: 'Preview 9:16. Pulsa Zoom para ver el resto de piezas en su formato.'}
|
: 'Meta: preview vertical 9:16 por defecto; carrusel en 1:1 (o 4:5). Pulsa Zoom para el resto de piezas.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{platformWarning ? (
|
{platformWarning ? (
|
||||||
@@ -284,20 +355,27 @@ function CreativeCard({
|
|||||||
ad: AdCreativeCard;
|
ad: AdCreativeCard;
|
||||||
onZoom: () => void;
|
onZoom: () => void;
|
||||||
}) {
|
}) {
|
||||||
const preview = previewUrlFor(ad);
|
const { url: preview, aspect: frameAspect } = previewFor(ad);
|
||||||
const isVideo = ad.mediaType === 'video' || Boolean(ad.youtubeId);
|
const isVideo = ad.mediaType === 'video' || Boolean(ad.youtubeId);
|
||||||
const headlines = adHeadlines(ad);
|
const headlines = adHeadlines(ad);
|
||||||
const bodies = adBodies(ad);
|
const bodies = adBodies(ad);
|
||||||
const linkDescs = adLinkDescriptions(ad);
|
const linkDescs = adLinkDescriptions(ad);
|
||||||
const assets = adAssets(ad);
|
const assets = adAssets(ad);
|
||||||
const extraCount = Math.max(0, assets.length - 1);
|
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 (
|
return (
|
||||||
<article className="grid gap-4 rounded-2xl border border-gray-200 bg-white p-4 md:grid-cols-[minmax(140px,180px)_1fr]">
|
<article className="grid gap-4 rounded-2xl border border-gray-200 bg-white p-4 md:grid-cols-[minmax(140px,200px)_1fr]">
|
||||||
|
<div className="space-y-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onZoom}
|
onClick={onZoom}
|
||||||
className="relative mx-auto w-full max-w-[180px] overflow-hidden rounded-xl bg-gray-100 border border-gray-200 aspect-[9/16] flex items-center justify-center group"
|
className={`relative mx-auto w-full max-w-[180px] overflow-hidden rounded-xl bg-gray-100 border border-gray-200 flex items-center justify-center group ${aspectClass(frameAspect)}`}
|
||||||
>
|
>
|
||||||
{preview ? (
|
{preview ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
@@ -307,11 +385,7 @@ function CreativeCard({
|
|||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
) : ad.platform === 'google' &&
|
) : isSearch && headlines.length ? (
|
||||||
(ad.mediaType === 'rsa' ||
|
|
||||||
ad.channel === 'search_brand' ||
|
|
||||||
ad.channel === 'search_generic') &&
|
|
||||||
headlines.length ? (
|
|
||||||
<div className="w-full h-full p-3.5 space-y-1.5 text-left bg-white flex flex-col justify-center border-t border-gray-100">
|
<div className="w-full h-full p-3.5 space-y-1.5 text-left bg-white flex flex-col justify-center border-t border-gray-100">
|
||||||
<p className="text-[9px] uppercase tracking-wider text-emerald-700 font-medium">
|
<p className="text-[9px] uppercase tracking-wider text-emerald-700 font-medium">
|
||||||
Anuncio ·{' '}
|
Anuncio ·{' '}
|
||||||
@@ -363,6 +437,11 @@ function CreativeCard({
|
|||||||
<Play className="w-3 h-3" /> Vídeo
|
<Play className="w-3 h-3" /> Vídeo
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
{ad.platform === 'meta' ? (
|
||||||
|
<span className="absolute top-2 left-2 rounded bg-black/70 px-1.5 py-0.5 text-[9px] text-white uppercase tracking-wide">
|
||||||
|
{frameAspect}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
{extraCount > 0 ? (
|
{extraCount > 0 ? (
|
||||||
<span className="absolute top-2 right-2 rounded-full bg-black/70 px-2 py-1 text-[10px] text-white">
|
<span className="absolute top-2 right-2 rounded-full bg-black/70 px-2 py-1 text-[10px] text-white">
|
||||||
+{extraCount}
|
+{extraCount}
|
||||||
@@ -372,6 +451,33 @@ function CreativeCard({
|
|||||||
<Maximize2 className="w-3.5 h-3.5" /> Zoom
|
<Maximize2 className="w-3.5 h-3.5" /> Zoom
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
{pmaxFormats.length > 1 ? (
|
||||||
|
<div className="flex flex-wrap gap-1.5 justify-center">
|
||||||
|
{pmaxFormats.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f.label}
|
||||||
|
type="button"
|
||||||
|
onClick={onZoom}
|
||||||
|
className="w-12 overflow-hidden rounded-md border border-gray-200 bg-gray-50"
|
||||||
|
title={`Formato ${f.label}`}
|
||||||
|
>
|
||||||
|
<div className={`${aspectClass(f.aspect)} w-full`}>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={f.url}
|
||||||
|
alt={f.label}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-[8px] text-center text-gray-500 py-0.5">
|
||||||
|
{f.label}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
<div className="min-w-0 space-y-3">
|
<div className="min-w-0 space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
@@ -478,10 +584,22 @@ export function CreativeZoomModal({
|
|||||||
const assets = adAssets(ad);
|
const assets = adAssets(ad);
|
||||||
const images = assets.filter((a) => a.kind === 'image' || a.kind === 'logo');
|
const images = assets.filter((a) => a.kind === 'image' || a.kind === 'logo');
|
||||||
const videos = assets.filter((a) => a.kind === 'youtube' || a.kind === 'video');
|
const videos = assets.filter((a) => a.kind === 'youtube' || a.kind === 'video');
|
||||||
const main = previewUrlFor(ad);
|
const { url: main, aspect: mainAspect } = previewFor(ad);
|
||||||
const previewAsset =
|
const previewAssetUrl = main.replace(/^\/api\/ad-tracker\/media\?url=/, '');
|
||||||
images.find((a) => resolveAspect(a) === '9:16') || images[0];
|
const otherImages = images.filter((a) => {
|
||||||
const otherImages = images.filter((a) => a.url !== previewAsset?.url);
|
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(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
@@ -534,7 +652,9 @@ export function CreativeZoomModal({
|
|||||||
<div className="flex-1 overflow-y-auto p-4 md:p-6 space-y-6">
|
<div className="flex-1 overflow-y-auto p-4 md:p-6 space-y-6">
|
||||||
<div className="grid gap-6 lg:grid-cols-[minmax(200px,280px)_1fr]">
|
<div className="grid gap-6 lg:grid-cols-[minmax(200px,280px)_1fr]">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="relative mx-auto w-full max-w-[280px] overflow-hidden rounded-2xl border border-gray-200 bg-black aspect-[9/16]">
|
<div
|
||||||
|
className={`relative mx-auto w-full max-w-[280px] overflow-hidden rounded-2xl border border-gray-200 bg-black ${aspectClass(mainAspect)}`}
|
||||||
|
>
|
||||||
{main ? (
|
{main ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img
|
<img
|
||||||
@@ -549,7 +669,7 @@ export function CreativeZoomModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-center text-[10px] uppercase tracking-wider text-gray-500">
|
<p className="text-center text-[10px] uppercase tracking-wider text-gray-500">
|
||||||
Preview 9:16
|
Preview {mainAspect}
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<MiniKpi label="Gasto" value={formatEur(ad.spend)} />
|
<MiniKpi label="Gasto" value={formatEur(ad.spend)} />
|
||||||
@@ -591,13 +711,13 @@ export function CreativeZoomModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{otherImages.length > 0 ? (
|
{zoomImages.length > 0 ? (
|
||||||
<section>
|
<section>
|
||||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">
|
<h3 className="text-sm font-semibold text-gray-900 mb-2">
|
||||||
Otras piezas ({otherImages.length})
|
Otras piezas ({zoomImages.length})
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
{otherImages.map((asset, i) => {
|
{zoomImages.map((asset, i) => {
|
||||||
const aspect = resolveAspect(asset);
|
const aspect = resolveAspect(asset);
|
||||||
return (
|
return (
|
||||||
<figure
|
<figure
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { googleAdsConfigured } from '@/lib/ads-discovery';
|
|||||||
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
||||||
import type {
|
import type {
|
||||||
AdCreativeCard,
|
AdCreativeCard,
|
||||||
|
CreativeAspect,
|
||||||
CreativeAsset,
|
CreativeAsset,
|
||||||
GoogleCreativeChannel,
|
GoogleCreativeChannel,
|
||||||
} from '@/lib/ad-creatives-meta';
|
} from '@/lib/ad-creatives-meta';
|
||||||
@@ -208,17 +209,80 @@ function ytThumb(youtubeId: string): string {
|
|||||||
return `https://i.ytimg.com/vi/${youtubeId}/hqdefault.jpg`;
|
return `https://i.ytimg.com/vi/${youtubeId}/hqdefault.jpg`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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') {
|
||||||
|
return '16:9';
|
||||||
|
}
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
function pickVerticalGoogle(assets: CreativeAsset[]): string {
|
function pickVerticalGoogle(assets: CreativeAsset[]): string {
|
||||||
const images = assets.filter((a) => a.kind === 'image' && a.url);
|
const images = assets.filter((a) => a.kind === 'image' && a.url);
|
||||||
const portrait = images.find((a) =>
|
const portrait = images.find(
|
||||||
|
(a) =>
|
||||||
|
a.aspect === '4:5' ||
|
||||||
|
a.aspect === '9:16' ||
|
||||||
/PORTRAIT|TALL|VERTICAL/i.test(a.label || '')
|
/PORTRAIT|TALL|VERTICAL/i.test(a.label || '')
|
||||||
);
|
);
|
||||||
if (portrait) return portrait.url;
|
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;
|
if (square) return square.url;
|
||||||
return images[0]?.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<string, AdCreativeCard[]> = {
|
||||||
|
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<string, number> = {
|
||||||
|
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<string>();
|
||||||
|
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 = {
|
type AgBucket = {
|
||||||
adId: string;
|
adId: string;
|
||||||
adName: string;
|
adName: string;
|
||||||
@@ -455,11 +519,16 @@ LIMIT 400
|
|||||||
const yt = str(row.asset?.youtubeVideoAsset?.youtubeVideoId);
|
const yt = str(row.asset?.youtubeVideoAsset?.youtubeVideoId);
|
||||||
const ytTitle = str(row.asset?.youtubeVideoAsset?.youtubeVideoTitle);
|
const ytTitle = str(row.asset?.youtubeVideoAsset?.youtubeVideoTitle);
|
||||||
if (img && !list.some((a) => a.url === img)) {
|
if (img && !list.some((a) => a.url === img)) {
|
||||||
list.push({
|
list.push(
|
||||||
|
withAspect(
|
||||||
|
{
|
||||||
kind: ft.includes('LOGO') ? 'logo' : 'image',
|
kind: ft.includes('LOGO') ? 'logo' : 'image',
|
||||||
url: img,
|
url: img,
|
||||||
label: ft || 'MARKETING_IMAGE',
|
label: ft || 'MARKETING_IMAGE',
|
||||||
});
|
},
|
||||||
|
ft || 'MARKETING_IMAGE'
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (yt && !list.some((a) => a.youtubeId === yt)) {
|
if (yt && !list.some((a) => a.youtubeId === yt)) {
|
||||||
list.push({
|
list.push({
|
||||||
@@ -468,6 +537,7 @@ LIMIT 400
|
|||||||
label: 'YOUTUBE_VIDEO',
|
label: 'YOUTUBE_VIDEO',
|
||||||
youtubeId: yt,
|
youtubeId: yt,
|
||||||
title: ytTitle || yt,
|
title: ytTitle || yt,
|
||||||
|
aspect: '16:9',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
assetsByAdId.set(adId, list);
|
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 === 'CALL_TO_ACTION_SELECTION' && cta) entry.ctas.push(cta);
|
||||||
else if (ft.includes('MARKETING_IMAGE') && img) {
|
else if (ft.includes('MARKETING_IMAGE') && img) {
|
||||||
if (!entry.assets.some((a) => a.url === 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) {
|
} else if ((ft === 'LOGO' || ft === 'LANDSCAPE_LOGO') && img) {
|
||||||
if (!entry.assets.some((a) => a.url === 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) {
|
} else if (ft === 'YOUTUBE_VIDEO' && yt) {
|
||||||
if (!entry.assets.some((a) => a.youtubeId === yt)) {
|
if (!entry.assets.some((a) => a.youtubeId === yt)) {
|
||||||
@@ -574,6 +648,7 @@ LIMIT 400
|
|||||||
label: 'YOUTUBE_VIDEO',
|
label: 'YOUTUBE_VIDEO',
|
||||||
youtubeId: yt,
|
youtubeId: yt,
|
||||||
title: ytTitle || yt,
|
title: ytTitle || yt,
|
||||||
|
aspect: '16:9',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -639,12 +714,6 @@ LIMIT 400
|
|||||||
const adId = str(ad?.id);
|
const adId = str(ad?.id);
|
||||||
if (!adId) continue;
|
if (!adId) continue;
|
||||||
const m = adMetricMap.get(adId);
|
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 rsa = ad?.responsiveSearchAd;
|
||||||
const rda = ad?.responsiveDisplayAd;
|
const rda = ad?.responsiveDisplayAd;
|
||||||
const headlines = uniq([
|
const headlines = uniq([
|
||||||
@@ -660,7 +729,12 @@ LIMIT 400
|
|||||||
const linkedAssets = assetsByAdId.get(adId) || [];
|
const linkedAssets = assetsByAdId.get(adId) || [];
|
||||||
const assets: CreativeAsset[] = [...linkedAssets];
|
const assets: CreativeAsset[] = [...linkedAssets];
|
||||||
if (imageUrl && !assets.some((a) => a.url === imageUrl)) {
|
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 adType = enumName(ad?.type).toLowerCase() || 'rsa';
|
||||||
const mediaType = rsa
|
const mediaType = rsa
|
||||||
@@ -674,6 +748,31 @@ LIMIT 400
|
|||||||
const channelType = enumName(row.campaign?.advertisingChannelType);
|
const channelType = enumName(row.campaign?.advertisingChannelType);
|
||||||
const campaignName = str(row.campaign?.name);
|
const campaignName = str(row.campaign?.name);
|
||||||
const adsetName = str(row.adGroup?.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 =
|
const preview =
|
||||||
pickVerticalGoogle(assets) ||
|
pickVerticalGoogle(assets) ||
|
||||||
assets.find((a) => a.kind === 'image')?.url ||
|
assets.find((a) => a.kind === 'image')?.url ||
|
||||||
@@ -722,13 +821,17 @@ LIMIT 400
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cards.sort((a, b) => b.spend - a.spend);
|
const active = cards.filter(
|
||||||
const sliced = cards
|
|
||||||
.filter(
|
|
||||||
(c) =>
|
(c) =>
|
||||||
c.spend > 0 || c.impressions > 0 || c.clicks > 0 || c.conversions > 0
|
c.spend > 0 ||
|
||||||
)
|
c.impressions > 0 ||
|
||||||
.slice(0, limit);
|
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) {
|
if (!sliced.length) {
|
||||||
return warehouseFallback(
|
return warehouseFallback(
|
||||||
|
|||||||
@@ -105,12 +105,7 @@ export function inferCreativeAspect(
|
|||||||
height?: number
|
height?: number
|
||||||
): CreativeAspect {
|
): CreativeAspect {
|
||||||
const L = (label || '').toLowerCase();
|
const L = (label || '').toLowerCase();
|
||||||
if (/9\s*:?\s*16|story|reels|vertical|tall|portrait_9/.test(L)) return '9:16';
|
// Dimensiones primero: labels tipo "video" no deben forzar landscape.
|
||||||
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';
|
|
||||||
if (width && height && width > 0 && height > 0) {
|
if (width && height && width > 0 && height > 0) {
|
||||||
const r = width / height;
|
const r = width / height;
|
||||||
if (r <= 0.62) return '9:16';
|
if (r <= 0.62) return '9:16';
|
||||||
@@ -118,6 +113,12 @@ export function inferCreativeAspect(
|
|||||||
if (r >= 1.5) return '16:9';
|
if (r >= 1.5) return '16:9';
|
||||||
if (Math.abs(r - 1) < 0.12) return '1:1';
|
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';
|
return 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,7 +202,8 @@ function extractCreativePack(creative: Record<string, unknown>): Extracted {
|
|||||||
|
|
||||||
if (Array.isArray(link?.child_attachments)) {
|
if (Array.isArray(link?.child_attachments)) {
|
||||||
(link!.child_attachments as Record<string, unknown>[]).forEach((c, i) => {
|
(link!.child_attachments as Record<string, unknown>[]).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);
|
pushHash(c.image_hash);
|
||||||
if (c.name) headlines.push(str(c.name));
|
if (c.name) headlines.push(str(c.name));
|
||||||
if (c.message) bodies.push(str(c.message));
|
if (c.message) bodies.push(str(c.message));
|
||||||
@@ -269,22 +271,56 @@ function extractCreativePack(creative: Record<string, unknown>): Extracted {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Prefer 9:16 story; then 4:5; else first image. */
|
/**
|
||||||
function pickVerticalPreview(assets: CreativeAsset[], fallback: string): string {
|
* Meta preview picker:
|
||||||
const images = assets.filter((a) => a.kind === 'image' && a.url);
|
* - Default: vertical 9:16 (vídeo/imagen), then 4:5
|
||||||
const nine = images.find((a) => a.aspect === '9:16');
|
* - Carousel: 1:1, then 4:5
|
||||||
if (nine) return nine.url;
|
*/
|
||||||
const portrait = images.find(
|
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) =>
|
||||||
a.aspect === '4:5' ||
|
(a.kind === 'video' || /video|reel|story/i.test(a.label || '')) &&
|
||||||
/portrait|vertical|9.?16|4.?5|tall|story/i.test(a.label || '')
|
aspectOf(a) === '9:16'
|
||||||
);
|
);
|
||||||
if (portrait) return portrait.url;
|
if (videoNine) return { url: videoNine.url, aspect: '9:16' };
|
||||||
const nonSquare = images.find(
|
|
||||||
(a) => a.label?.includes('thumbnail') || a.label?.includes('video_poster')
|
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;
|
if (thumb) return { url: thumb.url, aspect: '9:16' };
|
||||||
return images[0]?.url || fallback;
|
|
||||||
|
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) {
|
async function sleep(ms: number) {
|
||||||
@@ -657,7 +693,8 @@ function cardFromBundle(
|
|||||||
const creative = b?.creative || {};
|
const creative = b?.creative || {};
|
||||||
const fallback =
|
const fallback =
|
||||||
str(creative.image_url) || str(creative.thumbnail_url) || '';
|
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 headlines = pack.headlines;
|
||||||
const bodies = pack.bodies;
|
const bodies = pack.bodies;
|
||||||
const descriptions = pack.descriptions;
|
const descriptions = pack.descriptions;
|
||||||
@@ -672,9 +709,9 @@ function cardFromBundle(
|
|||||||
clicks: agg.clicks,
|
clicks: agg.clicks,
|
||||||
conversions: agg.conversions,
|
conversions: agg.conversions,
|
||||||
cpl: agg.conversions > 0 ? agg.spend / agg.conversions : null,
|
cpl: agg.conversions > 0 ? agg.spend / agg.conversions : null,
|
||||||
mediaType: detectMediaType(creative),
|
mediaType,
|
||||||
thumbnailUrl: preview,
|
thumbnailUrl: picked.url,
|
||||||
imageUrl: preview,
|
imageUrl: picked.url,
|
||||||
headline: headlines.join('\n'),
|
headline: headlines.join('\n'),
|
||||||
primaryText: bodies.join('\n') || descriptions.join('\n'),
|
primaryText: bodies.join('\n') || descriptions.join('\n'),
|
||||||
headlines,
|
headlines,
|
||||||
|
|||||||
Reference in New Issue
Block a user