Show every Google PMax text, image and YouTube asset in Ads zoom.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-06 13:38:24 +02:00
parent 8db642fa2a
commit b9ddc7b806
4 changed files with 350 additions and 134 deletions

View File

@@ -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<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);
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({
<Maximize2 className="w-3.5 h-3.5" /> Zoom
</span>
</button>
{pmaxFormats.length > 1 ? (
{pmaxFormats.length > 0 ? (
<div className="flex flex-wrap gap-1.5 justify-center">
{pmaxFormats.map((f) => (
{pmaxFormats.slice(0, 12).map((f, i) => (
<button
key={f.label}
key={`${f.url}-${i}`}
type="button"
onClick={onZoom}
className="w-12 overflow-hidden rounded-md border border-gray-200 bg-gray-50"
title={`Formato ${f.label}`}
title={f.label}
>
<div className={`${aspectClass(f.aspect)} w-full`}>
{/* eslint-disable-next-line @next/next/no-img-element */}
@@ -475,6 +478,11 @@ function CreativeCard({
</p>
</button>
))}
{pmaxFormats.length > 12 ? (
<p className="w-full text-center text-[10px] text-gray-500">
+{pmaxFormats.length - 12} en Zoom
</p>
) : null}
</div>
) : null}
</div>
@@ -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 ? (
<section>
<h3 className="text-sm font-semibold text-gray-900 mb-2">
Otras piezas ({zoomImages.length})
{ad.platform === 'google'
? `Imágenes y logos (${zoomImages.length})`
: `Otras piezas (${zoomImages.length})`}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{zoomImages.map((asset, i) => {
@@ -810,7 +823,7 @@ export function CreativeZoomModal({
Titulares largos ({longH.length})
</h3>
<ul className="space-y-1.5 text-sm text-gray-800">
{longH.slice(0, 5).map((h) => (
{longH.map((h) => (
<li
key={h}
className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2"
@@ -857,6 +870,89 @@ export function CreativeZoomModal({
</ul>
</section>
) : null}
{ad.businessName ? (
<section>
<h3 className="text-sm font-semibold text-gray-900 mb-2">
Nombre de negocio
</h3>
<p className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-800">
{ad.businessName}
</p>
</section>
) : null}
{(ad.ctas || []).length > 0 ? (
<section>
<h3 className="text-sm font-semibold text-gray-900 mb-2">
CTA ({ad.ctas!.length})
</h3>
<ul className="flex flex-wrap gap-1.5">
{ad.ctas!.map((c) => (
<li
key={c}
className="rounded-full border border-gray-200 bg-white px-3 py-1 text-xs text-gray-800"
>
{c}
</li>
))}
</ul>
</section>
) : null}
{(ad.sitelinks || []).length > 0 ? (
<section>
<h3 className="text-sm font-semibold text-gray-900 mb-2">
Sitelinks ({ad.sitelinks!.length})
</h3>
<ul className="space-y-1.5 text-sm text-gray-800">
{ad.sitelinks!.map((s) => (
<li
key={s}
className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2"
>
{s}
</li>
))}
</ul>
</section>
) : null}
{(ad.callouts || []).length > 0 ? (
<section>
<h3 className="text-sm font-semibold text-gray-900 mb-2">
Callouts ({ad.callouts!.length})
</h3>
<ul className="space-y-1.5 text-sm text-gray-800">
{ad.callouts!.map((s) => (
<li
key={s}
className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2"
>
{s}
</li>
))}
</ul>
</section>
) : null}
{(ad.snippets || []).length > 0 ? (
<section>
<h3 className="text-sm font-semibold text-gray-900 mb-2">
Structured snippets ({ad.snippets!.length})
</h3>
<ul className="space-y-1.5 text-sm text-gray-800">
{ad.snippets!.map((s) => (
<li
key={s}
className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2"
>
{s}
</li>
))}
</ul>
</section>
) : null}
</div>
</div>
</div>