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,
|
||||
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<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 }) {
|
||||
@@ -225,8 +296,8 @@ export function CreativesGallery({
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{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.'}
|
||||
</p>
|
||||
</div>
|
||||
{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 (
|
||||
<article className="grid gap-4 rounded-2xl border border-gray-200 bg-white p-4 md:grid-cols-[minmax(140px,180px)_1fr]">
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
{preview ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={preview}
|
||||
alt={ad.adName}
|
||||
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 ? (
|
||||
<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">
|
||||
Anuncio ·{' '}
|
||||
{ad.channel === 'search_brand'
|
||||
? 'Search Brand'
|
||||
: ad.channel === 'search_generic'
|
||||
? 'Search Generic'
|
||||
: 'Search'}
|
||||
</p>
|
||||
<p className="text-[10px] text-emerald-800 truncate">
|
||||
{(ad.linkUrl || 'https://ejemplo.com').replace(/^https?:\/\//, '')}
|
||||
</p>
|
||||
{headlines.slice(0, 2).map((h) => (
|
||||
<p
|
||||
key={h}
|
||||
className="text-[13px] font-medium text-[#1a0dab] leading-snug line-clamp-2"
|
||||
<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
|
||||
type="button"
|
||||
onClick={onZoom}
|
||||
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 ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={preview}
|
||||
alt={ad.adName}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : isSearch && 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">
|
||||
<p className="text-[9px] uppercase tracking-wider text-emerald-700 font-medium">
|
||||
Anuncio ·{' '}
|
||||
{ad.channel === 'search_brand'
|
||||
? 'Search Brand'
|
||||
: ad.channel === 'search_generic'
|
||||
? 'Search Generic'
|
||||
: 'Search'}
|
||||
</p>
|
||||
<p className="text-[10px] text-emerald-800 truncate">
|
||||
{(ad.linkUrl || 'https://ejemplo.com').replace(/^https?:\/\//, '')}
|
||||
</p>
|
||||
{headlines.slice(0, 2).map((h) => (
|
||||
<p
|
||||
key={h}
|
||||
className="text-[13px] font-medium text-[#1a0dab] leading-snug line-clamp-2"
|
||||
>
|
||||
{h}
|
||||
</p>
|
||||
))}
|
||||
{bodies[0] ? (
|
||||
<p className="text-[11px] text-gray-600 leading-snug line-clamp-3">
|
||||
{bodies[0]}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : ad.platform === 'google' && headlines.length ? (
|
||||
<div className="w-full h-full p-4 space-y-2 text-left bg-gradient-to-b from-[#1a2332] to-[#0d121f] flex flex-col justify-center">
|
||||
<p className="text-[10px] uppercase tracking-wider text-sky-400">
|
||||
Google · {ad.mediaType || 'ad'}
|
||||
</p>
|
||||
{headlines.slice(0, 3).map((h) => (
|
||||
<p
|
||||
key={h}
|
||||
className="text-sm font-semibold text-blue-300 leading-snug line-clamp-2"
|
||||
>
|
||||
{h}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-gray-500 text-xs px-4">
|
||||
<ImageIcon className="w-8 h-8 mx-auto mb-2 opacity-40" />
|
||||
Sin preview
|
||||
</div>
|
||||
)}
|
||||
{isVideo && preview ? (
|
||||
<span className="absolute bottom-2 left-2 inline-flex items-center gap-1 rounded-full bg-black/70 px-2 py-1 text-[10px] text-white">
|
||||
<Play className="w-3 h-3" /> Vídeo
|
||||
</span>
|
||||
) : 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 ? (
|
||||
<span className="absolute top-2 right-2 rounded-full bg-black/70 px-2 py-1 text-[10px] text-white">
|
||||
+{extraCount}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="absolute inset-x-0 bottom-0 translate-y-full group-hover:translate-y-0 transition-transform bg-black/75 text-white text-[11px] py-2 flex items-center justify-center gap-1">
|
||||
<Maximize2 className="w-3.5 h-3.5" /> Zoom
|
||||
</span>
|
||||
</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}`}
|
||||
>
|
||||
{h}
|
||||
</p>
|
||||
))}
|
||||
{bodies[0] ? (
|
||||
<p className="text-[11px] text-gray-600 leading-snug line-clamp-3">
|
||||
{bodies[0]}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : ad.platform === 'google' && headlines.length ? (
|
||||
<div className="w-full h-full p-4 space-y-2 text-left bg-gradient-to-b from-[#1a2332] to-[#0d121f] flex flex-col justify-center">
|
||||
<p className="text-[10px] uppercase tracking-wider text-sky-400">
|
||||
Google · {ad.mediaType || 'ad'}
|
||||
</p>
|
||||
{headlines.slice(0, 3).map((h) => (
|
||||
<p
|
||||
key={h}
|
||||
className="text-sm font-semibold text-blue-300 leading-snug line-clamp-2"
|
||||
>
|
||||
{h}
|
||||
</p>
|
||||
<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>
|
||||
) : (
|
||||
<div className="text-center text-gray-500 text-xs px-4">
|
||||
<ImageIcon className="w-8 h-8 mx-auto mb-2 opacity-40" />
|
||||
Sin preview
|
||||
</div>
|
||||
)}
|
||||
{isVideo && preview ? (
|
||||
<span className="absolute bottom-2 left-2 inline-flex items-center gap-1 rounded-full bg-black/70 px-2 py-1 text-[10px] text-white">
|
||||
<Play className="w-3 h-3" /> Vídeo
|
||||
</span>
|
||||
) : null}
|
||||
{extraCount > 0 ? (
|
||||
<span className="absolute top-2 right-2 rounded-full bg-black/70 px-2 py-1 text-[10px] text-white">
|
||||
+{extraCount}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="absolute inset-x-0 bottom-0 translate-y-full group-hover:translate-y-0 transition-transform bg-black/75 text-white text-[11px] py-2 flex items-center justify-center gap-1">
|
||||
<Maximize2 className="w-3.5 h-3.5" /> Zoom
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-w-0 space-y-3">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
@@ -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({
|
||||
<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="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 ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
@@ -549,7 +669,7 @@ export function CreativeZoomModal({
|
||||
)}
|
||||
</div>
|
||||
<p className="text-center text-[10px] uppercase tracking-wider text-gray-500">
|
||||
Preview 9:16
|
||||
Preview {mainAspect}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<MiniKpi label="Gasto" value={formatEur(ad.spend)} />
|
||||
@@ -591,13 +711,13 @@ export function CreativeZoomModal({
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
{otherImages.length > 0 ? (
|
||||
{zoomImages.length > 0 ? (
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">
|
||||
Otras piezas ({otherImages.length})
|
||||
Otras piezas ({zoomImages.length})
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{otherImages.map((asset, i) => {
|
||||
{zoomImages.map((asset, i) => {
|
||||
const aspect = resolveAspect(asset);
|
||||
return (
|
||||
<figure
|
||||
|
||||
Reference in New Issue
Block a user