feat: zoom de creatividades con assets verticales Meta y PMax completo
Preview 4:5 + textos en card; modal con todas las imágenes, vídeos y copy. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
594
src/components/AdCreativesGallery.tsx
Normal file
594
src/components/AdCreativesGallery.tsx
Normal file
@@ -0,0 +1,594 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import type { AdCreativeCard, CreativeAsset } from '@/lib/ad-creatives-meta';
|
||||||
|
import { formatEur, formatNum } from '@/lib/metrics';
|
||||||
|
import { ImageIcon, Play, Maximize2, X, ExternalLink } from 'lucide-react';
|
||||||
|
|
||||||
|
export function proxiedMediaUrl(raw: string): string {
|
||||||
|
if (!raw) return '';
|
||||||
|
if (raw.startsWith('/api/')) return raw;
|
||||||
|
return `/api/ad-tracker/media?url=${encodeURIComponent(raw)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function adHeadlines(ad: AdCreativeCard): string[] {
|
||||||
|
if (ad.headlines?.length) return ad.headlines;
|
||||||
|
return (ad.headline || '')
|
||||||
|
.split('\n')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function adDescriptions(ad: AdCreativeCard): string[] {
|
||||||
|
if (ad.descriptions?.length) return ad.descriptions;
|
||||||
|
return (ad.primaryText || '')
|
||||||
|
.split('\n')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function adAssets(ad: AdCreativeCard): CreativeAsset[] {
|
||||||
|
if (ad.assets?.length) return ad.assets;
|
||||||
|
const out: CreativeAsset[] = [];
|
||||||
|
const u = ad.imageUrl || ad.thumbnailUrl;
|
||||||
|
if (u) out.push({ kind: 'image', url: u, label: 'preview' });
|
||||||
|
if (ad.youtubeId) {
|
||||||
|
out.push({
|
||||||
|
kind: 'youtube',
|
||||||
|
url: `https://i.ytimg.com/vi/${ad.youtubeId}/hqdefault.jpg`,
|
||||||
|
youtubeId: ad.youtubeId,
|
||||||
|
label: 'YOUTUBE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewUrlFor(ad: AdCreativeCard): string {
|
||||||
|
const assets = adAssets(ad);
|
||||||
|
const portrait = assets.find(
|
||||||
|
(a) =>
|
||||||
|
a.kind === 'image' &&
|
||||||
|
/portrait|vertical|tall|TALL|PORTRAIT|story|4.?5|9.?16/i.test(
|
||||||
|
a.label || ''
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const raw =
|
||||||
|
portrait?.url ||
|
||||||
|
ad.imageUrl ||
|
||||||
|
ad.thumbnailUrl ||
|
||||||
|
assets.find((a) => a.kind === 'image')?.url ||
|
||||||
|
(ad.youtubeId
|
||||||
|
? `https://i.ytimg.com/vi/${ad.youtubeId}/hqdefault.jpg`
|
||||||
|
: '') ||
|
||||||
|
assets.find((a) => a.youtubeId)?.url ||
|
||||||
|
'';
|
||||||
|
return raw ? proxiedMediaUrl(raw) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function MiniKpi({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-gray-800 bg-black/25 px-2.5 py-2">
|
||||||
|
<p className="text-[9px] uppercase tracking-wider text-gray-500">{label}</p>
|
||||||
|
<p className="text-sm font-semibold text-white mt-0.5 tabular-nums">
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreativesGallery({
|
||||||
|
platform,
|
||||||
|
ads,
|
||||||
|
loading,
|
||||||
|
warning,
|
||||||
|
onZoom,
|
||||||
|
}: {
|
||||||
|
platform: 'meta' | 'google';
|
||||||
|
ads: AdCreativeCard[];
|
||||||
|
loading: boolean;
|
||||||
|
warning?: string | null;
|
||||||
|
onZoom: (ad: AdCreativeCard) => void;
|
||||||
|
}) {
|
||||||
|
const filtered = ads.filter((a) => a.platform === platform);
|
||||||
|
const platformWarning =
|
||||||
|
warning
|
||||||
|
?.split(' · ')
|
||||||
|
.find((w) =>
|
||||||
|
platform === 'meta'
|
||||||
|
? /meta|token meta|sin ads meta/i.test(w)
|
||||||
|
: /google|pmax|rsa|sin ads google|sin assets/i.test(w)
|
||||||
|
) || null;
|
||||||
|
const shownWarning =
|
||||||
|
filtered.length === 0 && warning
|
||||||
|
? platformWarning || (ads.length === 0 ? warning : null)
|
||||||
|
: platformWarning && filtered.length === 0
|
||||||
|
? platformWarning
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] uppercase tracking-wider text-gray-500">
|
||||||
|
Creatividades · preview por anuncio
|
||||||
|
</p>
|
||||||
|
<h3 className="text-lg font-bold text-white">
|
||||||
|
{platform === 'meta' ? 'Meta Ads' : 'Google Ads'} · creatividades
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Preview vertical + textos. Pulsa Zoom para ver todas las piezas.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{shownWarning ? (
|
||||||
|
<p className="text-xs text-amber-400/90 border border-amber-500/20 rounded-lg px-3 py-2">
|
||||||
|
{shownWarning}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-gray-500 py-8 text-center">
|
||||||
|
Cargando creatividades…
|
||||||
|
</p>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-500 py-8 text-center border border-dashed border-gray-800 rounded-xl">
|
||||||
|
Sin creatividades para este periodo.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filtered.map((ad) => (
|
||||||
|
<CreativeCard
|
||||||
|
key={`${ad.platform}-${ad.adId}`}
|
||||||
|
ad={ad}
|
||||||
|
onZoom={() => onZoom(ad)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreativeCard({
|
||||||
|
ad,
|
||||||
|
onZoom,
|
||||||
|
}: {
|
||||||
|
ad: AdCreativeCard;
|
||||||
|
onZoom: () => void;
|
||||||
|
}) {
|
||||||
|
const preview = previewUrlFor(ad);
|
||||||
|
const isVideo = ad.mediaType === 'video' || Boolean(ad.youtubeId);
|
||||||
|
const headlines = adHeadlines(ad);
|
||||||
|
const bodies = adDescriptions(ad);
|
||||||
|
const assets = adAssets(ad);
|
||||||
|
const extraCount = Math.max(0, assets.length - 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="grid gap-4 rounded-2xl border border-gray-800 bg-[#111827] p-4 md:grid-cols-[minmax(160px,220px)_1fr]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onZoom}
|
||||||
|
className="relative mx-auto w-full max-w-[220px] overflow-hidden rounded-xl bg-black/40 border border-gray-800 aspect-[4/5] 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' && 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 || 'rsa'}
|
||||||
|
</p>
|
||||||
|
{headlines.slice(0, 3).map((h) => (
|
||||||
|
<p
|
||||||
|
key={h}
|
||||||
|
className="text-sm font-semibold text-blue-300 leading-snug"
|
||||||
|
>
|
||||||
|
{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}
|
||||||
|
{extraCount > 0 ? (
|
||||||
|
<span className="absolute top-2 right-2 rounded-full bg-black/70 px-2 py-1 text-[10px] text-white">
|
||||||
|
+{extraCount} assets
|
||||||
|
</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 className="min-w-0 space-y-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
|
<h4 className="text-base font-semibold text-white leading-snug">
|
||||||
|
{ad.adName || 'Sin nombre'}
|
||||||
|
</h4>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[10px] uppercase tracking-wider text-gray-500 border border-gray-700 px-2 py-0.5 rounded">
|
||||||
|
{ad.status || ad.platform}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onZoom}
|
||||||
|
className="inline-flex items-center gap-1 rounded-lg border border-brand-500/40 bg-brand-500/10 px-2.5 py-1 text-[11px] font-medium text-brand-300 hover:bg-brand-500/20"
|
||||||
|
>
|
||||||
|
<Maximize2 className="w-3.5 h-3.5" /> Zoom
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">{ad.campaignName || '—'}</p>
|
||||||
|
{ad.adsetName ? (
|
||||||
|
<p className="text-xs text-gray-400">Conjunto: {ad.adsetName}</p>
|
||||||
|
) : null}
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-2 py-0.5 text-[10px] uppercase tracking-wide ${
|
||||||
|
ad.platform === 'meta'
|
||||||
|
? 'border border-violet-500/40 text-violet-300'
|
||||||
|
: 'border border-sky-500/40 text-sky-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{ad.platform === 'meta' ? 'Meta' : 'Google'}
|
||||||
|
</span>
|
||||||
|
{ad.mediaType && ad.mediaType !== 'unknown' ? (
|
||||||
|
<span className="rounded-full border border-gray-700 px-2 py-0.5 text-[10px] uppercase text-gray-400">
|
||||||
|
{ad.mediaType}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{assets.length > 0 ? (
|
||||||
|
<span className="rounded-full border border-gray-700 px-2 py-0.5 text-[10px] text-gray-400">
|
||||||
|
{assets.length} piezas
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{(headlines.length > 0 || bodies.length > 0) && (
|
||||||
|
<div className="space-y-2 text-xs border border-gray-800/80 rounded-xl p-3 bg-black/20">
|
||||||
|
{headlines.length > 0 ? (
|
||||||
|
<div>
|
||||||
|
<p className="text-[9px] uppercase tracking-wider text-gray-500 mb-1">
|
||||||
|
Titulares
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{headlines.slice(0, 4).map((h) => (
|
||||||
|
<li
|
||||||
|
key={h}
|
||||||
|
className="text-gray-200 font-medium leading-snug"
|
||||||
|
>
|
||||||
|
{h}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{headlines.length > 4 ? (
|
||||||
|
<li className="text-gray-500">+{headlines.length - 4} más</li>
|
||||||
|
) : null}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{bodies.length > 0 ? (
|
||||||
|
<div>
|
||||||
|
<p className="text-[9px] uppercase tracking-wider text-gray-500 mb-1">
|
||||||
|
Textos / descripciones
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{bodies.slice(0, 3).map((b) => (
|
||||||
|
<li
|
||||||
|
key={b}
|
||||||
|
className="text-gray-400 leading-relaxed line-clamp-3"
|
||||||
|
>
|
||||||
|
{b}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{bodies.length > 3 ? (
|
||||||
|
<li className="text-gray-500">+{bodies.length - 3} más</li>
|
||||||
|
) : null}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{ad.cta ? (
|
||||||
|
<p className="text-[10px] text-brand-300 uppercase tracking-wide">
|
||||||
|
CTA · {ad.cta}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||||
|
<MiniKpi label="Gasto" value={formatEur(ad.spend)} />
|
||||||
|
<MiniKpi label="Clics" value={formatNum(ad.clicks)} />
|
||||||
|
<MiniKpi label="Conv." value={formatNum(ad.conversions)} />
|
||||||
|
<MiniKpi
|
||||||
|
label="CPL"
|
||||||
|
value={ad.cpl != null ? formatEur(ad.cpl) : '—'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreativeZoomModal({
|
||||||
|
ad,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
ad: AdCreativeCard;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const headlines = adHeadlines(ad);
|
||||||
|
const bodies = adDescriptions(ad);
|
||||||
|
const longH = ad.longHeadlines || [];
|
||||||
|
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);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
const prev = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onKey);
|
||||||
|
document.body.style.overflow = prev;
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[80] flex items-stretch justify-center bg-black/80 p-3 md:p-6"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative flex w-full max-w-6xl flex-col overflow-hidden rounded-2xl border border-gray-700 bg-[#0D121F] shadow-2xl max-h-[95vh]"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<header className="flex items-start justify-between gap-3 border-b border-gray-800 px-4 py-3 md:px-6 shrink-0">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-[10px] uppercase tracking-wider text-gray-500">
|
||||||
|
Zoom creatividades ·{' '}
|
||||||
|
{ad.platform === 'meta' ? 'Meta Ads' : 'Google Ads'}
|
||||||
|
</p>
|
||||||
|
<h2 className="text-lg font-bold text-white truncate">
|
||||||
|
{ad.adName || 'Anuncio'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
|
{ad.campaignName}
|
||||||
|
{ad.adsetName ? ` · ${ad.adsetName}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-lg border border-gray-700 p-2 text-gray-300 hover:bg-gray-800"
|
||||||
|
aria-label="Cerrar"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 md:p-6 space-y-6">
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[minmax(220px,320px)_1fr]">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="relative mx-auto w-full max-w-[320px] overflow-hidden rounded-2xl border border-gray-800 bg-black aspect-[4/5]">
|
||||||
|
{main ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={main}
|
||||||
|
alt={ad.adName}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center text-gray-500 text-sm">
|
||||||
|
Sin preview
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<MiniKpi label="Gasto" value={formatEur(ad.spend)} />
|
||||||
|
<MiniKpi label="Clics" value={formatNum(ad.clicks)} />
|
||||||
|
<MiniKpi label="Conv." value={formatNum(ad.conversions)} />
|
||||||
|
<MiniKpi
|
||||||
|
label="CPL"
|
||||||
|
value={ad.cpl != null ? formatEur(ad.cpl) : '—'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 space-y-1">
|
||||||
|
<p>
|
||||||
|
Impresiones:{' '}
|
||||||
|
<span className="text-gray-300">
|
||||||
|
{formatNum(ad.impressions)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Formato:{' '}
|
||||||
|
<span className="text-gray-300">{ad.mediaType || '—'}</span>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Estado:{' '}
|
||||||
|
<span className="text-gray-300">{ad.status || '—'}</span>
|
||||||
|
</p>
|
||||||
|
{ad.cta ? (
|
||||||
|
<p>
|
||||||
|
CTA: <span className="text-brand-300">{ad.cta}</span>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{ad.businessName ? (
|
||||||
|
<p>
|
||||||
|
Negocio:{' '}
|
||||||
|
<span className="text-gray-300">{ad.businessName}</span>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{ad.linkUrl ? (
|
||||||
|
<a
|
||||||
|
href={ad.linkUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-sky-400 hover:underline break-all"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3 h-3 shrink-0" />
|
||||||
|
{ad.linkUrl}
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-5">
|
||||||
|
{images.length > 0 ? (
|
||||||
|
<section>
|
||||||
|
<h3 className="text-sm font-semibold text-white mb-2">
|
||||||
|
Imágenes y logos ({images.length})
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||||
|
{images.map((asset, i) => (
|
||||||
|
<figure
|
||||||
|
key={`${asset.url}-${i}`}
|
||||||
|
className="overflow-hidden rounded-xl border border-gray-800 bg-black/40"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
/PORTRAIT|TALL|vertical|story/i.test(
|
||||||
|
asset.label || ''
|
||||||
|
)
|
||||||
|
? 'aspect-[4/5]'
|
||||||
|
: /SQUARE/i.test(asset.label || '')
|
||||||
|
? 'aspect-square'
|
||||||
|
: 'aspect-video'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={proxiedMediaUrl(asset.url)}
|
||||||
|
alt={asset.label || `asset-${i}`}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{asset.label ? (
|
||||||
|
<figcaption className="px-2 py-1 text-[9px] uppercase tracking-wider text-gray-500 truncate">
|
||||||
|
{asset.label}
|
||||||
|
{asset.kind === 'logo' ? ' · logo' : ''}
|
||||||
|
</figcaption>
|
||||||
|
) : null}
|
||||||
|
</figure>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{videos.length > 0 ? (
|
||||||
|
<section>
|
||||||
|
<h3 className="text-sm font-semibold text-white mb-2">
|
||||||
|
Vídeos ({videos.length})
|
||||||
|
</h3>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{videos.map((v, i) => (
|
||||||
|
<div
|
||||||
|
key={`${v.youtubeId || v.url}-${i}`}
|
||||||
|
className="rounded-xl border border-gray-800 overflow-hidden bg-black/40"
|
||||||
|
>
|
||||||
|
{v.youtubeId ? (
|
||||||
|
<div className="aspect-video">
|
||||||
|
<iframe
|
||||||
|
title={v.title || v.youtubeId}
|
||||||
|
src={`https://www.youtube.com/embed/${v.youtubeId}`}
|
||||||
|
className="w-full h-full"
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
|
allowFullScreen
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : v.url ? (
|
||||||
|
<div className="aspect-video relative">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={proxiedMediaUrl(v.url)}
|
||||||
|
alt={v.title || 'video'}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<p className="px-2 py-1.5 text-[10px] text-gray-400 truncate">
|
||||||
|
{v.title || v.youtubeId || v.label || 'Vídeo'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{headlines.length > 0 ? (
|
||||||
|
<section>
|
||||||
|
<h3 className="text-sm font-semibold text-white mb-2">
|
||||||
|
Titulares ({headlines.length})
|
||||||
|
</h3>
|
||||||
|
<ul className="space-y-1.5 text-sm text-gray-200">
|
||||||
|
{headlines.map((h) => (
|
||||||
|
<li
|
||||||
|
key={h}
|
||||||
|
className="rounded-lg border border-gray-800 bg-black/25 px-3 py-2"
|
||||||
|
>
|
||||||
|
{h}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{longH.length > 0 ? (
|
||||||
|
<section>
|
||||||
|
<h3 className="text-sm font-semibold text-white mb-2">
|
||||||
|
Titulares largos ({longH.length})
|
||||||
|
</h3>
|
||||||
|
<ul className="space-y-1.5 text-sm text-gray-200">
|
||||||
|
{longH.map((h) => (
|
||||||
|
<li
|
||||||
|
key={h}
|
||||||
|
className="rounded-lg border border-gray-800 bg-black/25 px-3 py-2"
|
||||||
|
>
|
||||||
|
{h}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{bodies.length > 0 ? (
|
||||||
|
<section>
|
||||||
|
<h3 className="text-sm font-semibold text-white mb-2">
|
||||||
|
Textos / descripciones ({bodies.length})
|
||||||
|
</h3>
|
||||||
|
<ul className="space-y-2 text-sm text-gray-300">
|
||||||
|
{bodies.map((b) => (
|
||||||
|
<li
|
||||||
|
key={b}
|
||||||
|
className="rounded-lg border border-gray-800 bg-black/25 px-3 py-2 leading-relaxed whitespace-pre-wrap"
|
||||||
|
>
|
||||||
|
{b}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,7 +9,10 @@ import {
|
|||||||
} from '@/lib/ad-tracker-range';
|
} from '@/lib/ad-tracker-range';
|
||||||
import { AdDailyRow, formatEur, formatNum } from '@/lib/metrics';
|
import { AdDailyRow, formatEur, formatNum } from '@/lib/metrics';
|
||||||
import type { AdCreativeCard } from '@/lib/ad-creatives-meta';
|
import type { AdCreativeCard } from '@/lib/ad-creatives-meta';
|
||||||
import { ImageIcon, Play } from 'lucide-react';
|
import {
|
||||||
|
CreativesGallery,
|
||||||
|
CreativeZoomModal,
|
||||||
|
} from '@/components/AdCreativesGallery';
|
||||||
|
|
||||||
type PlatformKpis = {
|
type PlatformKpis = {
|
||||||
spend: number;
|
spend: number;
|
||||||
@@ -304,205 +307,6 @@ function CampaignTable({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function proxiedMediaUrl(raw: string): string {
|
|
||||||
if (!raw) return '';
|
|
||||||
if (raw.startsWith('/api/')) return raw;
|
|
||||||
return `/api/ad-tracker/media?url=${encodeURIComponent(raw)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function CreativesGallery({
|
|
||||||
platform,
|
|
||||||
ads,
|
|
||||||
loading,
|
|
||||||
warning,
|
|
||||||
}: {
|
|
||||||
platform: 'meta' | 'google';
|
|
||||||
ads: AdCreativeCard[];
|
|
||||||
loading: boolean;
|
|
||||||
warning?: string | null;
|
|
||||||
}) {
|
|
||||||
const filtered = ads.filter((a) => a.platform === platform);
|
|
||||||
const platformWarning =
|
|
||||||
warning
|
|
||||||
?.split(' · ')
|
|
||||||
.find((w) =>
|
|
||||||
platform === 'meta'
|
|
||||||
? /meta|token meta|sin ads meta/i.test(w)
|
|
||||||
: /google|pmax|rsa|sin ads google|sin assets/i.test(w)
|
|
||||||
) || (platform === 'meta' || platform === 'google' ? null : null);
|
|
||||||
|
|
||||||
// Prefer full warning on matching platform; otherwise only platform-relevant part
|
|
||||||
const shownWarning =
|
|
||||||
filtered.length === 0 && warning
|
|
||||||
? platformWarning ||
|
|
||||||
(ads.length === 0 ? warning : null)
|
|
||||||
: platformWarning && filtered.length === 0
|
|
||||||
? platformWarning
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<p className="text-[10px] uppercase tracking-wider text-gray-500">
|
|
||||||
Creatividades · preview por anuncio
|
|
||||||
</p>
|
|
||||||
<h3 className="text-lg font-bold text-white">
|
|
||||||
{platform === 'meta' ? 'Meta Ads' : 'Google Ads'} · creatividades
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
{platform === 'meta'
|
|
||||||
? 'Imagen / vídeo desde Meta Graph (proxy same-origin).'
|
|
||||||
: 'Assets PMax e RSA en vivo desde Google Ads API.'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{shownWarning ? (
|
|
||||||
<p className="text-xs text-amber-400/90 border border-amber-500/20 rounded-lg px-3 py-2">
|
|
||||||
{shownWarning}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{loading ? (
|
|
||||||
<p className="text-sm text-gray-500 py-8 text-center">
|
|
||||||
Cargando creatividades…
|
|
||||||
</p>
|
|
||||||
) : filtered.length === 0 ? (
|
|
||||||
<p className="text-sm text-gray-500 py-8 text-center border border-dashed border-gray-800 rounded-xl">
|
|
||||||
Sin creatividades para este periodo.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{filtered.map((ad) => (
|
|
||||||
<CreativeCard key={`${ad.platform}-${ad.adId}`} ad={ad} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CreativeCard({ ad }: { ad: AdCreativeCard }) {
|
|
||||||
const raw = ad.imageUrl || ad.thumbnailUrl;
|
|
||||||
const ytPoster = ad.youtubeId
|
|
||||||
? proxiedMediaUrl(`https://i.ytimg.com/vi/${ad.youtubeId}/hqdefault.jpg`)
|
|
||||||
: '';
|
|
||||||
const preview = raw ? proxiedMediaUrl(raw) : ytPoster;
|
|
||||||
const isVideo = ad.mediaType === 'video' || Boolean(ad.youtubeId);
|
|
||||||
const headlines = (ad.headline || '')
|
|
||||||
.split('\n')
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
const bodies = (ad.primaryText || '')
|
|
||||||
.split('\n')
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<article className="grid gap-4 rounded-2xl border border-gray-800 bg-[#111827] p-4 md:grid-cols-[minmax(220px,320px)_1fr]">
|
|
||||||
<div className="relative overflow-hidden rounded-xl bg-black/40 border border-gray-800 min-h-[220px] flex items-center justify-center">
|
|
||||||
{preview ? (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
|
||||||
src={preview}
|
|
||||||
alt={ad.adName}
|
|
||||||
className="w-full h-full object-cover max-h-[420px]"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
) : ad.platform === 'google' && headlines.length ? (
|
|
||||||
<div className="w-full p-4 space-y-2 text-left bg-gradient-to-b from-[#1a2332] to-[#0d121f]">
|
|
||||||
<p className="text-[10px] uppercase tracking-wider text-sky-400">
|
|
||||||
Google Ads · {ad.mediaType || 'rsa'}
|
|
||||||
</p>
|
|
||||||
{headlines.slice(0, 3).map((h) => (
|
|
||||||
<p
|
|
||||||
key={h}
|
|
||||||
className="text-sm font-semibold text-blue-300 leading-snug"
|
|
||||||
>
|
|
||||||
{h}
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
{bodies[0] ? (
|
|
||||||
<p className="text-xs text-gray-400 line-clamp-3">{bodies[0]}</p>
|
|
||||||
) : null}
|
|
||||||
</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}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 space-y-3">
|
|
||||||
<div>
|
|
||||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
|
||||||
<h4 className="text-base font-semibold text-white leading-snug">
|
|
||||||
{ad.adName || 'Sin nombre'}
|
|
||||||
</h4>
|
|
||||||
<span className="text-[10px] uppercase tracking-wider text-gray-500 border border-gray-700 px-2 py-0.5 rounded">
|
|
||||||
{ad.status || ad.platform}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">{ad.campaignName || '—'}</p>
|
|
||||||
{ad.adsetName ? (
|
|
||||||
<p className="text-xs text-gray-400">Conjunto: {ad.adsetName}</p>
|
|
||||||
) : null}
|
|
||||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
|
||||||
<span
|
|
||||||
className={`rounded-full px-2 py-0.5 text-[10px] uppercase tracking-wide ${
|
|
||||||
ad.platform === 'meta'
|
|
||||||
? 'border border-violet-500/40 text-violet-300'
|
|
||||||
: 'border border-sky-500/40 text-sky-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{ad.platform === 'meta' ? 'Meta' : 'Google'}
|
|
||||||
</span>
|
|
||||||
{ad.mediaType && ad.mediaType !== 'unknown' ? (
|
|
||||||
<span className="rounded-full border border-gray-700 px-2 py-0.5 text-[10px] uppercase text-gray-400">
|
|
||||||
{ad.mediaType}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{(headlines.length > 0 || bodies.length > 0) && (
|
|
||||||
<div className="space-y-1 text-xs">
|
|
||||||
{headlines[0] ? (
|
|
||||||
<p className="text-gray-200 font-medium line-clamp-2">
|
|
||||||
{headlines[0]}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{bodies[0] ? (
|
|
||||||
<p className="text-gray-500 line-clamp-3">{bodies[0]}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
|
||||||
<MiniKpi label="Gasto" value={formatEur(ad.spend)} />
|
|
||||||
<MiniKpi label="Clics" value={formatNum(ad.clicks)} />
|
|
||||||
<MiniKpi label="Conv." value={formatNum(ad.conversions)} />
|
|
||||||
<MiniKpi
|
|
||||||
label="CPL"
|
|
||||||
value={ad.cpl != null ? formatEur(ad.cpl) : '—'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MiniKpi({ label, value }: { label: string; value: string }) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-gray-800 bg-black/25 px-2.5 py-2">
|
|
||||||
<p className="text-[9px] uppercase tracking-wider text-gray-500">{label}</p>
|
|
||||||
<p className="text-sm font-semibold text-white mt-0.5 tabular-nums">
|
|
||||||
{value}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AdTrackerClient({
|
export default function AdTrackerClient({
|
||||||
label,
|
label,
|
||||||
tagline,
|
tagline,
|
||||||
@@ -518,6 +322,7 @@ export default function AdTrackerClient({
|
|||||||
const [creatives, setCreatives] = useState<AdCreativeCard[]>([]);
|
const [creatives, setCreatives] = useState<AdCreativeCard[]>([]);
|
||||||
const [creativesLoading, setCreativesLoading] = useState(false);
|
const [creativesLoading, setCreativesLoading] = useState(false);
|
||||||
const [creativesWarning, setCreativesWarning] = useState<string | null>(null);
|
const [creativesWarning, setCreativesWarning] = useState<string | null>(null);
|
||||||
|
const [zoomAd, setZoomAd] = useState<AdCreativeCard | null>(null);
|
||||||
|
|
||||||
const range = useMemo(() => resolveAdRange(preset), [preset]);
|
const range = useMemo(() => resolveAdRange(preset), [preset]);
|
||||||
|
|
||||||
@@ -776,12 +581,14 @@ export default function AdTrackerClient({
|
|||||||
ads={creatives}
|
ads={creatives}
|
||||||
loading={creativesLoading}
|
loading={creativesLoading}
|
||||||
warning={creativesWarning}
|
warning={creativesWarning}
|
||||||
|
onZoom={setZoomAd}
|
||||||
/>
|
/>
|
||||||
<CreativesGallery
|
<CreativesGallery
|
||||||
platform="google"
|
platform="google"
|
||||||
ads={creatives}
|
ads={creatives}
|
||||||
loading={creativesLoading}
|
loading={creativesLoading}
|
||||||
warning={creativesWarning}
|
warning={creativesWarning}
|
||||||
|
onZoom={setZoomAd}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section className="rounded-2xl border border-gray-800 bg-[#111827] p-4 md:p-5">
|
<section className="rounded-2xl border border-gray-800 bg-[#111827] p-4 md:p-5">
|
||||||
@@ -881,6 +688,7 @@ export default function AdTrackerClient({
|
|||||||
ads={creatives}
|
ads={creatives}
|
||||||
loading={creativesLoading}
|
loading={creativesLoading}
|
||||||
warning={creativesWarning}
|
warning={creativesWarning}
|
||||||
|
onZoom={setZoomAd}
|
||||||
/>
|
/>
|
||||||
<section className="rounded-2xl border border-gray-800 bg-[#111827] p-4">
|
<section className="rounded-2xl border border-gray-800 bg-[#111827] p-4">
|
||||||
<h3 className="text-lg font-bold text-white mb-3">
|
<h3 className="text-lg font-bold text-white mb-3">
|
||||||
@@ -918,6 +726,7 @@ export default function AdTrackerClient({
|
|||||||
ads={creatives}
|
ads={creatives}
|
||||||
loading={creativesLoading}
|
loading={creativesLoading}
|
||||||
warning={creativesWarning}
|
warning={creativesWarning}
|
||||||
|
onZoom={setZoomAd}
|
||||||
/>
|
/>
|
||||||
<section className="rounded-2xl border border-gray-800 bg-[#111827] p-4">
|
<section className="rounded-2xl border border-gray-800 bg-[#111827] p-4">
|
||||||
<h3 className="text-lg font-bold text-white mb-3">
|
<h3 className="text-lg font-bold text-white mb-3">
|
||||||
@@ -938,6 +747,9 @@ export default function AdTrackerClient({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{zoomAd ? (
|
||||||
|
<CreativeZoomModal ad={zoomAd} onClose={() => setZoomAd(null)} />
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/** Google Ads creatives for Ad Tracker (PMax asset groups + RSA/RDA). */
|
/** Google Ads creatives for Ad Tracker (PMax asset groups + RSA/RDA, full assets). */
|
||||||
|
|
||||||
import { googleAdsConfigured } from '@/lib/ads-discovery';
|
import { googleAdsConfigured } from '@/lib/ads-discovery';
|
||||||
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
||||||
import type { AdCreativeCard } from '@/lib/ad-creatives-meta';
|
import type { AdCreativeCard, CreativeAsset } from '@/lib/ad-creatives-meta';
|
||||||
import { aggregateDailyByAd } from '@/lib/ad-creatives-meta';
|
import { aggregateDailyByAd } from '@/lib/ad-creatives-meta';
|
||||||
|
|
||||||
const GADS_API = 'https://googleads.googleapis.com/v22';
|
const GADS_API = 'https://googleads.googleapis.com/v22';
|
||||||
@@ -20,14 +20,15 @@ type GaqlRow = {
|
|||||||
responsiveSearchAd?: {
|
responsiveSearchAd?: {
|
||||||
headlines?: { text?: string }[];
|
headlines?: { text?: string }[];
|
||||||
descriptions?: { text?: string }[];
|
descriptions?: { text?: string }[];
|
||||||
|
path1?: string;
|
||||||
|
path2?: string;
|
||||||
};
|
};
|
||||||
responsiveDisplayAd?: {
|
responsiveDisplayAd?: {
|
||||||
headlines?: { text?: string }[];
|
headlines?: { text?: string }[];
|
||||||
longHeadline?: { text?: string };
|
longHeadline?: { text?: string };
|
||||||
descriptions?: { text?: string }[];
|
descriptions?: { text?: string }[];
|
||||||
businessName?: string;
|
businessName?: string;
|
||||||
marketingImages?: { asset?: string }[];
|
callToActionText?: string;
|
||||||
squareMarketingImages?: { asset?: string }[];
|
|
||||||
};
|
};
|
||||||
imageAd?: { imageUrl?: string; name?: string };
|
imageAd?: { imageUrl?: string; name?: string };
|
||||||
};
|
};
|
||||||
@@ -48,6 +49,7 @@ type GaqlRow = {
|
|||||||
youtubeVideoId?: string;
|
youtubeVideoId?: string;
|
||||||
youtubeVideoTitle?: string;
|
youtubeVideoTitle?: string;
|
||||||
};
|
};
|
||||||
|
callToActionAsset?: { callToAction?: string };
|
||||||
};
|
};
|
||||||
metrics?: {
|
metrics?: {
|
||||||
costMicros?: string;
|
costMicros?: string;
|
||||||
@@ -70,6 +72,10 @@ function enumName(v?: string): string {
|
|||||||
return (v || '').replace(/^[A-Z0-9]+_/, '') || str(v);
|
return (v || '').replace(/^[A-Z0-9]+_/, '') || str(v);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function uniq(arr: string[]): string[] {
|
||||||
|
return [...new Set(arr.filter(Boolean))];
|
||||||
|
}
|
||||||
|
|
||||||
async function googleAccessToken(): Promise<string> {
|
async function googleAccessToken(): Promise<string> {
|
||||||
const refresh =
|
const refresh =
|
||||||
process.env.GOOGLE_ADS_REFRESH_TOKEN?.trim() ||
|
process.env.GOOGLE_ADS_REFRESH_TOKEN?.trim() ||
|
||||||
@@ -159,21 +165,34 @@ function ytThumb(youtubeId: string): string {
|
|||||||
return `https://i.ytimg.com/vi/${youtubeId}/hqdefault.jpg`;
|
return `https://i.ytimg.com/vi/${youtubeId}/hqdefault.jpg`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pickVerticalGoogle(assets: CreativeAsset[]): string {
|
||||||
|
const images = assets.filter((a) => a.kind === 'image' && a.url);
|
||||||
|
const portrait = images.find((a) =>
|
||||||
|
/PORTRAIT|TALL|VERTICAL/i.test(a.label || '')
|
||||||
|
);
|
||||||
|
if (portrait) return portrait.url;
|
||||||
|
const square = images.find((a) => /SQUARE/i.test(a.label || ''));
|
||||||
|
if (square) return square.url;
|
||||||
|
return images[0]?.url || '';
|
||||||
|
}
|
||||||
|
|
||||||
type AgBucket = {
|
type AgBucket = {
|
||||||
adId: string;
|
adId: string;
|
||||||
adName: string;
|
adName: string;
|
||||||
campaignId: string;
|
campaignId: string;
|
||||||
campaignName: string;
|
campaignName: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
finalUrls: string[];
|
||||||
headlines: string[];
|
headlines: string[];
|
||||||
|
longHeadlines: string[];
|
||||||
descriptions: string[];
|
descriptions: string[];
|
||||||
images: string[];
|
ctas: string[];
|
||||||
youtubeIds: string[];
|
assets: CreativeAsset[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Live Google creatives: Performance Max asset groups + RSA/RDA ads.
|
* Live Google creatives: Performance Max asset groups + RSA/RDA ads.
|
||||||
* Metrics: prefer asset_group / ad metrics in range; else fall back to campaign ad_daily.
|
* Returns full asset packs (images, videos, logos, texts) for zoom UI.
|
||||||
*/
|
*/
|
||||||
export async function fetchGoogleCreativeCards(opts: {
|
export async function fetchGoogleCreativeCards(opts: {
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
@@ -226,7 +245,8 @@ SELECT
|
|||||||
asset.text_asset.text,
|
asset.text_asset.text,
|
||||||
asset.image_asset.full_size.url,
|
asset.image_asset.full_size.url,
|
||||||
asset.youtube_video_asset.youtube_video_id,
|
asset.youtube_video_asset.youtube_video_id,
|
||||||
asset.youtube_video_asset.youtube_video_title
|
asset.youtube_video_asset.youtube_video_title,
|
||||||
|
asset.call_to_action_asset.call_to_action
|
||||||
FROM asset_group_asset
|
FROM asset_group_asset
|
||||||
WHERE asset_group.status != 'REMOVED'
|
WHERE asset_group.status != 'REMOVED'
|
||||||
AND asset_group_asset.status != 'REMOVED'
|
AND asset_group_asset.status != 'REMOVED'
|
||||||
@@ -262,10 +282,13 @@ SELECT
|
|||||||
ad_group_ad.ad.final_urls,
|
ad_group_ad.ad.final_urls,
|
||||||
ad_group_ad.ad.responsive_search_ad.headlines,
|
ad_group_ad.ad.responsive_search_ad.headlines,
|
||||||
ad_group_ad.ad.responsive_search_ad.descriptions,
|
ad_group_ad.ad.responsive_search_ad.descriptions,
|
||||||
|
ad_group_ad.ad.responsive_search_ad.path1,
|
||||||
|
ad_group_ad.ad.responsive_search_ad.path2,
|
||||||
ad_group_ad.ad.responsive_display_ad.headlines,
|
ad_group_ad.ad.responsive_display_ad.headlines,
|
||||||
ad_group_ad.ad.responsive_display_ad.long_headline,
|
ad_group_ad.ad.responsive_display_ad.long_headline,
|
||||||
ad_group_ad.ad.responsive_display_ad.descriptions,
|
ad_group_ad.ad.responsive_display_ad.descriptions,
|
||||||
ad_group_ad.ad.responsive_display_ad.business_name,
|
ad_group_ad.ad.responsive_display_ad.business_name,
|
||||||
|
ad_group_ad.ad.responsive_display_ad.call_to_action_text,
|
||||||
ad_group_ad.ad.image_ad.image_url,
|
ad_group_ad.ad.image_ad.image_url,
|
||||||
ad_group_ad.ad.image_ad.name
|
ad_group_ad.ad.image_ad.name
|
||||||
FROM ad_group_ad
|
FROM ad_group_ad
|
||||||
@@ -330,7 +353,6 @@ WHERE ${dateWhere}
|
|||||||
adMetricMap.set(id, cur);
|
adMetricMap.set(id, cur);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Campaign-level spend from ad_daily as fallback for PMax
|
|
||||||
const dailyAggs = aggregateDailyByAd(
|
const dailyAggs = aggregateDailyByAd(
|
||||||
opts.dailyRows,
|
opts.dailyRows,
|
||||||
'google',
|
'google',
|
||||||
@@ -339,7 +361,7 @@ WHERE ${dateWhere}
|
|||||||
);
|
);
|
||||||
const campaignSpend = new Map<string, (typeof dailyAggs)[number]>();
|
const campaignSpend = new Map<string, (typeof dailyAggs)[number]>();
|
||||||
for (const a of dailyAggs) {
|
for (const a of dailyAggs) {
|
||||||
campaignSpend.set(a.adId, a); // portal stores campaign id as ad_id
|
campaignSpend.set(a.adId, a);
|
||||||
if (a.campaignName) campaignSpend.set(a.campaignName.toLowerCase(), a);
|
if (a.campaignName) campaignSpend.set(a.campaignName.toLowerCase(), a);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,10 +377,12 @@ WHERE ${dateWhere}
|
|||||||
campaignId: str(row.campaign?.id),
|
campaignId: str(row.campaign?.id),
|
||||||
campaignName: str(row.campaign?.name),
|
campaignName: str(row.campaign?.name),
|
||||||
status: enumName(row.assetGroup?.status) || 'ENABLED',
|
status: enumName(row.assetGroup?.status) || 'ENABLED',
|
||||||
|
finalUrls: (row.assetGroup?.finalUrls || []).map(str).filter(Boolean),
|
||||||
headlines: [],
|
headlines: [],
|
||||||
|
longHeadlines: [],
|
||||||
descriptions: [],
|
descriptions: [],
|
||||||
images: [],
|
ctas: [],
|
||||||
youtubeIds: [],
|
assets: [],
|
||||||
};
|
};
|
||||||
agMap.set(agid, entry);
|
agMap.set(agid, entry);
|
||||||
}
|
}
|
||||||
@@ -366,16 +390,31 @@ WHERE ${dateWhere}
|
|||||||
const text = str(row.asset?.textAsset?.text);
|
const text = str(row.asset?.textAsset?.text);
|
||||||
const img = str(row.asset?.imageAsset?.fullSize?.url);
|
const img = str(row.asset?.imageAsset?.fullSize?.url);
|
||||||
const yt = str(row.asset?.youtubeVideoAsset?.youtubeVideoId);
|
const yt = str(row.asset?.youtubeVideoAsset?.youtubeVideoId);
|
||||||
|
const ytTitle = str(row.asset?.youtubeVideoAsset?.youtubeVideoTitle);
|
||||||
|
const cta = enumName(row.asset?.callToActionAsset?.callToAction);
|
||||||
|
|
||||||
if (ft === 'HEADLINE' && text) entry.headlines.push(text);
|
if (ft === 'HEADLINE' && text) entry.headlines.push(text);
|
||||||
else if (ft === 'LONG_HEADLINE' && text) entry.headlines.push(text);
|
else if (ft === 'LONG_HEADLINE' && text) entry.longHeadlines.push(text);
|
||||||
else if (ft === 'DESCRIPTION' && text) entry.descriptions.push(text);
|
else if (ft === 'DESCRIPTION' && text) entry.descriptions.push(text);
|
||||||
else if (
|
else if (ft === 'CALL_TO_ACTION_SELECTION' && cta) entry.ctas.push(cta);
|
||||||
(ft.includes('MARKETING_IMAGE') || ft === 'LOGO' || ft === 'LANDSCAPE_LOGO') &&
|
else if (ft.includes('MARKETING_IMAGE') && img) {
|
||||||
img
|
if (!entry.assets.some((a) => a.url === img)) {
|
||||||
) {
|
entry.assets.push({ kind: 'image', url: img, label: ft });
|
||||||
if (!entry.images.includes(img)) entry.images.push(img);
|
}
|
||||||
|
} 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 });
|
||||||
|
}
|
||||||
} else if (ft === 'YOUTUBE_VIDEO' && yt) {
|
} else if (ft === 'YOUTUBE_VIDEO' && yt) {
|
||||||
if (!entry.youtubeIds.includes(yt)) entry.youtubeIds.push(yt);
|
if (!entry.assets.some((a) => a.youtubeId === yt)) {
|
||||||
|
entry.assets.push({
|
||||||
|
kind: 'youtube',
|
||||||
|
url: ytThumb(yt),
|
||||||
|
label: 'YOUTUBE_VIDEO',
|
||||||
|
youtubeId: yt,
|
||||||
|
title: ytTitle || yt,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,11 +429,17 @@ WHERE ${dateWhere}
|
|||||||
const impressions = m?.impressions ?? camp?.impressions ?? 0;
|
const impressions = m?.impressions ?? camp?.impressions ?? 0;
|
||||||
const clicks = m?.clicks ?? camp?.clicks ?? 0;
|
const clicks = m?.clicks ?? camp?.clicks ?? 0;
|
||||||
const conversions = m?.conversions ?? camp?.conversions ?? 0;
|
const conversions = m?.conversions ?? camp?.conversions ?? 0;
|
||||||
// Skip zero-activity asset groups when we have spend elsewhere, keep if any spend
|
|
||||||
if (spend <= 0 && impressions <= 0 && clicks <= 0 && !m) continue;
|
if (spend <= 0 && impressions <= 0 && clicks <= 0 && !m) continue;
|
||||||
|
|
||||||
const yt = entry.youtubeIds[0] || '';
|
const headlines = uniq(entry.headlines);
|
||||||
const imageUrl = entry.images[0] || (yt ? ytThumb(yt) : '');
|
const longHeadlines = uniq(entry.longHeadlines);
|
||||||
|
const descriptions = uniq(entry.descriptions);
|
||||||
|
const preview =
|
||||||
|
pickVerticalGoogle(entry.assets) ||
|
||||||
|
entry.assets.find((a) => a.kind === 'youtube')?.url ||
|
||||||
|
'';
|
||||||
|
const firstYt = entry.assets.find((a) => a.youtubeId)?.youtubeId;
|
||||||
|
|
||||||
cards.push({
|
cards.push({
|
||||||
platform: 'google',
|
platform: 'google',
|
||||||
adId: entry.adId,
|
adId: entry.adId,
|
||||||
@@ -406,13 +451,19 @@ WHERE ${dateWhere}
|
|||||||
clicks,
|
clicks,
|
||||||
conversions: Math.round(conversions * 100) / 100,
|
conversions: Math.round(conversions * 100) / 100,
|
||||||
cpl: conversions > 0 ? spend / conversions : null,
|
cpl: conversions > 0 ? spend / conversions : null,
|
||||||
mediaType: yt && !entry.images[0] ? 'video' : entry.images[0] ? 'pmax' : 'pmax',
|
mediaType: 'pmax',
|
||||||
thumbnailUrl: imageUrl,
|
thumbnailUrl: preview,
|
||||||
imageUrl,
|
imageUrl: preview,
|
||||||
headline: [...new Set(entry.headlines)].slice(0, 5).join('\n'),
|
headline: [...headlines, ...longHeadlines].join('\n'),
|
||||||
primaryText: [...new Set(entry.descriptions)].slice(0, 3).join('\n'),
|
primaryText: descriptions.join('\n'),
|
||||||
|
headlines,
|
||||||
|
longHeadlines,
|
||||||
|
descriptions,
|
||||||
|
assets: entry.assets,
|
||||||
|
linkUrl: entry.finalUrls[0],
|
||||||
|
cta: uniq(entry.ctas)[0],
|
||||||
status: entry.status,
|
status: entry.status,
|
||||||
youtubeId: yt || undefined,
|
youtubeId: firstYt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,7 +472,6 @@ WHERE ${dateWhere}
|
|||||||
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);
|
||||||
// Prefer ads with activity in range; keep a few without if needed
|
|
||||||
const spend = m?.spend ?? 0;
|
const spend = m?.spend ?? 0;
|
||||||
const impressions = m?.impressions ?? 0;
|
const impressions = m?.impressions ?? 0;
|
||||||
const clicks = m?.clicks ?? 0;
|
const clicks = m?.clicks ?? 0;
|
||||||
@@ -430,24 +480,21 @@ WHERE ${dateWhere}
|
|||||||
|
|
||||||
const rsa = ad?.responsiveSearchAd;
|
const rsa = ad?.responsiveSearchAd;
|
||||||
const rda = ad?.responsiveDisplayAd;
|
const rda = ad?.responsiveDisplayAd;
|
||||||
const headlines = [
|
const headlines = uniq([
|
||||||
...(rsa?.headlines || []).map((h) => str(h.text)),
|
...(rsa?.headlines || []).map((h) => str(h.text)),
|
||||||
...(rda?.headlines || []).map((h) => str(h.text)),
|
...(rda?.headlines || []).map((h) => str(h.text)),
|
||||||
str(rda?.longHeadline?.text),
|
]);
|
||||||
].filter(Boolean);
|
const longHeadlines = uniq([str(rda?.longHeadline?.text)]);
|
||||||
const descriptions = [
|
const descriptions = uniq([
|
||||||
...(rsa?.descriptions || []).map((d) => str(d.text)),
|
...(rsa?.descriptions || []).map((d) => str(d.text)),
|
||||||
...(rda?.descriptions || []).map((d) => str(d.text)),
|
...(rda?.descriptions || []).map((d) => str(d.text)),
|
||||||
].filter(Boolean);
|
]);
|
||||||
const imageUrl = str(ad?.imageAd?.imageUrl);
|
const imageUrl = str(ad?.imageAd?.imageUrl);
|
||||||
|
const assets: CreativeAsset[] = [];
|
||||||
|
if (imageUrl) assets.push({ kind: 'image', url: imageUrl, label: 'IMAGE_AD' });
|
||||||
const adType = enumName(ad?.type).toLowerCase() || 'rsa';
|
const adType = enumName(ad?.type).toLowerCase() || 'rsa';
|
||||||
const mediaType = rsa
|
const mediaType = rsa ? 'rsa' : rda ? 'rda' : imageUrl ? 'image' : adType;
|
||||||
? 'rsa'
|
const paths = [str(rsa?.path1), str(rsa?.path2)].filter(Boolean);
|
||||||
: rda
|
|
||||||
? 'rda'
|
|
||||||
: imageUrl
|
|
||||||
? 'image'
|
|
||||||
: adType;
|
|
||||||
|
|
||||||
cards.push({
|
cards.push({
|
||||||
platform: 'google',
|
platform: 'google',
|
||||||
@@ -463,17 +510,30 @@ WHERE ${dateWhere}
|
|||||||
mediaType,
|
mediaType,
|
||||||
thumbnailUrl: imageUrl,
|
thumbnailUrl: imageUrl,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
headline: [...new Set(headlines)].slice(0, 5).join('\n'),
|
headline: [...headlines, ...longHeadlines].join('\n'),
|
||||||
primaryText: [...new Set(descriptions)].slice(0, 3).join('\n'),
|
primaryText: descriptions.join('\n'),
|
||||||
|
headlines,
|
||||||
|
longHeadlines,
|
||||||
|
descriptions,
|
||||||
|
assets,
|
||||||
|
linkUrl: (ad?.finalUrls || []).map(str).filter(Boolean)[0],
|
||||||
|
cta: str(rda?.callToActionText) || undefined,
|
||||||
|
businessName: str(rda?.businessName) || undefined,
|
||||||
status: enumName(row.adGroupAd?.status),
|
status: enumName(row.adGroupAd?.status),
|
||||||
});
|
});
|
||||||
|
if (paths.length) {
|
||||||
|
// encode paths into descriptions note for zoom
|
||||||
|
cards[cards.length - 1].descriptions = [
|
||||||
|
...descriptions,
|
||||||
|
`Path: /${paths.join('/')}`,
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cards.sort((a, b) => b.spend - a.spend);
|
cards.sort((a, b) => b.spend - a.spend);
|
||||||
const sliced = cards.slice(0, limit);
|
const sliced = cards.slice(0, limit);
|
||||||
|
|
||||||
if (!sliced.length) {
|
if (!sliced.length) {
|
||||||
// Fallback: campaign cards from ad_daily (text only)
|
|
||||||
const fallback = dailyAggs.slice(0, limit).map((agg) => ({
|
const fallback = dailyAggs.slice(0, limit).map((agg) => ({
|
||||||
platform: 'google' as const,
|
platform: 'google' as const,
|
||||||
adId: agg.adId,
|
adId: agg.adId,
|
||||||
@@ -489,6 +549,9 @@ WHERE ${dateWhere}
|
|||||||
imageUrl: '',
|
imageUrl: '',
|
||||||
headline: agg.campaignName || agg.adName,
|
headline: agg.campaignName || agg.adName,
|
||||||
primaryText: 'Sin assets creativos en la API',
|
primaryText: 'Sin assets creativos en la API',
|
||||||
|
headlines: [agg.campaignName || agg.adName].filter(Boolean),
|
||||||
|
descriptions: ['Sin assets creativos en la API'],
|
||||||
|
assets: [] as CreativeAsset[],
|
||||||
status: undefined,
|
status: undefined,
|
||||||
}));
|
}));
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/** Meta ad creatives for Ad Tracker (Brava-style previews). */
|
/** Meta ad creatives for Ad Tracker (vertical previews + full copy/assets). */
|
||||||
|
|
||||||
import { resolveMetaAccessToken } from '@/lib/ads-discovery';
|
import { resolveMetaAccessToken } from '@/lib/ads-discovery';
|
||||||
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
||||||
@@ -10,6 +10,8 @@ const CREATIVE_FIELDS = [
|
|||||||
'name',
|
'name',
|
||||||
'title',
|
'title',
|
||||||
'body',
|
'body',
|
||||||
|
'call_to_action_type',
|
||||||
|
'link_url',
|
||||||
'thumbnail_url',
|
'thumbnail_url',
|
||||||
'image_url',
|
'image_url',
|
||||||
'image_hash',
|
'image_hash',
|
||||||
@@ -19,6 +21,14 @@ const CREATIVE_FIELDS = [
|
|||||||
'effective_object_story_id',
|
'effective_object_story_id',
|
||||||
].join(',');
|
].join(',');
|
||||||
|
|
||||||
|
export type CreativeAsset = {
|
||||||
|
kind: 'image' | 'video' | 'logo' | 'youtube';
|
||||||
|
url: string;
|
||||||
|
label?: string;
|
||||||
|
youtubeId?: string;
|
||||||
|
title?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type AdCreativeCard = {
|
export type AdCreativeCard = {
|
||||||
platform: 'meta' | 'google';
|
platform: 'meta' | 'google';
|
||||||
adId: string;
|
adId: string;
|
||||||
@@ -31,10 +41,19 @@ export type AdCreativeCard = {
|
|||||||
conversions: number;
|
conversions: number;
|
||||||
cpl: number | null;
|
cpl: number | null;
|
||||||
mediaType: string;
|
mediaType: string;
|
||||||
|
/** Best preview URL (prefer vertical / portrait). */
|
||||||
thumbnailUrl: string;
|
thumbnailUrl: string;
|
||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
|
/** Joined for backwards compat; prefer headlines/descriptions arrays. */
|
||||||
headline: string;
|
headline: string;
|
||||||
primaryText: string;
|
primaryText: string;
|
||||||
|
headlines: string[];
|
||||||
|
descriptions: string[];
|
||||||
|
longHeadlines?: string[];
|
||||||
|
assets: CreativeAsset[];
|
||||||
|
linkUrl?: string;
|
||||||
|
cta?: string;
|
||||||
|
businessName?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
youtubeId?: string;
|
youtubeId?: string;
|
||||||
};
|
};
|
||||||
@@ -58,6 +77,10 @@ function str(v: unknown): string {
|
|||||||
return v == null ? '' : String(v).trim();
|
return v == null ? '' : String(v).trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function uniq(arr: string[]): string[] {
|
||||||
|
return [...new Set(arr.filter(Boolean))];
|
||||||
|
}
|
||||||
|
|
||||||
function detectMediaType(creative: Record<string, unknown>): string {
|
function detectMediaType(creative: Record<string, unknown>): string {
|
||||||
if (creative.video_id) return 'video';
|
if (creative.video_id) return 'video';
|
||||||
const feed = creative.asset_feed_spec as Record<string, unknown> | undefined;
|
const feed = creative.asset_feed_spec as Record<string, unknown> | undefined;
|
||||||
@@ -71,32 +94,125 @@ function detectMediaType(creative: Record<string, unknown>): string {
|
|||||||
return 'unknown';
|
return 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractTexts(creative: Record<string, unknown>): {
|
type Extracted = {
|
||||||
headline: string;
|
headlines: string[];
|
||||||
primaryText: string;
|
descriptions: string[];
|
||||||
} {
|
assets: CreativeAsset[];
|
||||||
const titles: string[] = [];
|
linkUrl: string;
|
||||||
const bodies: string[] = [];
|
cta: string;
|
||||||
if (creative.title) titles.push(str(creative.title));
|
hashes: string[];
|
||||||
if (creative.body) bodies.push(str(creative.body));
|
};
|
||||||
|
|
||||||
|
function extractCreativePack(creative: Record<string, unknown>): Extracted {
|
||||||
|
const headlines: string[] = [];
|
||||||
|
const descriptions: string[] = [];
|
||||||
|
const assets: CreativeAsset[] = [];
|
||||||
|
const hashes: string[] = [];
|
||||||
|
const pushHash = (h: unknown) => {
|
||||||
|
const s = str(h);
|
||||||
|
if (/^[a-f0-9]{16,64}$/i.test(s)) hashes.push(s);
|
||||||
|
};
|
||||||
|
const pushImg = (url: unknown, label?: string) => {
|
||||||
|
const u = str(url);
|
||||||
|
if (!/^https?:\/\//i.test(u)) return;
|
||||||
|
if (assets.some((a) => a.url === u)) return;
|
||||||
|
assets.push({ kind: 'image', url: u, label });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (creative.title) headlines.push(str(creative.title));
|
||||||
|
if (creative.body) descriptions.push(str(creative.body));
|
||||||
|
pushImg(creative.image_url, 'image');
|
||||||
|
pushImg(creative.thumbnail_url, 'thumbnail');
|
||||||
|
pushHash(creative.image_hash);
|
||||||
|
|
||||||
const oss = creative.object_story_spec as Record<string, unknown> | undefined;
|
const oss = creative.object_story_spec as Record<string, unknown> | undefined;
|
||||||
const link = oss?.link_data as Record<string, unknown> | undefined;
|
const link = oss?.link_data as Record<string, unknown> | undefined;
|
||||||
const video = oss?.video_data as Record<string, unknown> | undefined;
|
const video = oss?.video_data as Record<string, unknown> | undefined;
|
||||||
if (link?.name) titles.push(str(link.name));
|
const photo = oss?.photo_data as Record<string, unknown> | undefined;
|
||||||
if (link?.message) bodies.push(str(link.message));
|
if (link?.name) headlines.push(str(link.name));
|
||||||
if (video?.title) titles.push(str(video.title));
|
if (link?.message) descriptions.push(str(link.message));
|
||||||
if (video?.message) bodies.push(str(video.message));
|
if (link?.description) descriptions.push(str(link.description));
|
||||||
|
if (link?.caption) descriptions.push(str(link.caption));
|
||||||
|
pushImg(link?.picture, 'story');
|
||||||
|
pushImg(link?.image_url, 'story');
|
||||||
|
pushHash(link?.image_hash);
|
||||||
|
if (video?.title) headlines.push(str(video.title));
|
||||||
|
if (video?.message) descriptions.push(str(video.message));
|
||||||
|
if (video?.link_description) descriptions.push(str(video.link_description));
|
||||||
|
pushImg(video?.image_url, 'video_poster');
|
||||||
|
pushHash(video?.image_hash);
|
||||||
|
pushImg(photo?.url, 'photo');
|
||||||
|
pushHash(photo?.image_hash);
|
||||||
|
|
||||||
|
if (Array.isArray(link?.child_attachments)) {
|
||||||
|
(link!.child_attachments as Record<string, unknown>[]).forEach((c, i) => {
|
||||||
|
pushImg(c.picture || c.image_url, `carousel-${i + 1}`);
|
||||||
|
pushHash(c.image_hash);
|
||||||
|
if (c.name) headlines.push(str(c.name));
|
||||||
|
if (c.description) descriptions.push(str(c.description));
|
||||||
|
if (c.message) descriptions.push(str(c.message));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const feed = creative.asset_feed_spec as Record<string, unknown> | undefined;
|
const feed = creative.asset_feed_spec as Record<string, unknown> | undefined;
|
||||||
const feedTitles = feed?.titles as { text?: string }[] | undefined;
|
if (feed) {
|
||||||
const feedBodies = feed?.bodies as { text?: string }[] | undefined;
|
for (const t of (feed.titles as { text?: string }[]) || []) {
|
||||||
for (const t of feedTitles || []) if (t?.text) titles.push(str(t.text));
|
if (t?.text) headlines.push(str(t.text));
|
||||||
for (const b of feedBodies || []) if (b?.text) bodies.push(str(b.text));
|
}
|
||||||
|
for (const b of (feed.bodies as { text?: string }[]) || []) {
|
||||||
|
if (b?.text) descriptions.push(str(b.text));
|
||||||
|
}
|
||||||
|
for (const d of (feed.descriptions as { text?: string }[]) || []) {
|
||||||
|
if (d?.text) descriptions.push(str(d.text));
|
||||||
|
}
|
||||||
|
for (const img of (feed.images as Record<string, unknown>[]) || []) {
|
||||||
|
pushImg(img.url || img.permalink_url, 'feed');
|
||||||
|
pushHash(img.hash);
|
||||||
|
}
|
||||||
|
for (const v of (feed.videos as Record<string, unknown>[]) || []) {
|
||||||
|
pushImg(v.thumbnail_url || v.picture, 'video_feed');
|
||||||
|
if (v.video_id) {
|
||||||
|
assets.push({
|
||||||
|
kind: 'video',
|
||||||
|
url: str(v.thumbnail_url || v.picture),
|
||||||
|
label: 'video',
|
||||||
|
title: str(v.video_id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkUrl =
|
||||||
|
str(creative.link_url) ||
|
||||||
|
str(link?.link) ||
|
||||||
|
str(video?.link) ||
|
||||||
|
'';
|
||||||
|
const ctaRaw = (link?.call_to_action as { type?: string } | undefined)?.type;
|
||||||
|
const cta = str(creative.call_to_action_type) || str(ctaRaw);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
headline: [...new Set(titles.filter(Boolean))].slice(0, 5).join('\n'),
|
headlines: uniq(headlines),
|
||||||
primaryText: [...new Set(bodies.filter(Boolean))].slice(0, 3).join('\n'),
|
descriptions: uniq(descriptions),
|
||||||
|
assets,
|
||||||
|
linkUrl,
|
||||||
|
cta,
|
||||||
|
hashes: uniq(hashes),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Prefer portrait/vertical-looking assets; else first image. */
|
||||||
|
function pickVerticalPreview(assets: CreativeAsset[], fallback: string): string {
|
||||||
|
const images = assets.filter((a) => a.kind === 'image' && a.url);
|
||||||
|
const portrait = images.find((a) =>
|
||||||
|
/portrait|vertical|9.?16|4.?5|tall|story/i.test(a.label || '')
|
||||||
|
);
|
||||||
|
if (portrait) return portrait.url;
|
||||||
|
// Meta often serves taller thumbnails when requested 1080x1920 — thumbnail label last
|
||||||
|
const nonSquare = images.find((a) => a.label === 'thumbnail' || a.label === 'video_poster');
|
||||||
|
if (nonSquare) return nonSquare.url;
|
||||||
|
return images[0]?.url || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
async function graphGet<T>(
|
async function graphGet<T>(
|
||||||
path: string,
|
path: string,
|
||||||
params: Record<string, string>,
|
params: Record<string, string>,
|
||||||
@@ -124,6 +240,37 @@ async function resolveMetaAccountId(tenantId: string): Promise<string | null> {
|
|||||||
return id || null;
|
return id || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveImageHashes(
|
||||||
|
accountId: string,
|
||||||
|
hashes: string[],
|
||||||
|
token: string
|
||||||
|
): Promise<Map<string, string>> {
|
||||||
|
const out = new Map<string, string>();
|
||||||
|
const unique = uniq(hashes).slice(0, 40);
|
||||||
|
if (!unique.length) return out;
|
||||||
|
const act = accountId.startsWith('act_') ? accountId : `act_${accountId}`;
|
||||||
|
try {
|
||||||
|
const raw = await graphGet<{
|
||||||
|
data?: { hash?: string; url?: string; permalink_url?: string }[];
|
||||||
|
}>(
|
||||||
|
`${act}/adimages`,
|
||||||
|
{
|
||||||
|
hashes: JSON.stringify(unique),
|
||||||
|
fields: 'hash,url,permalink_url',
|
||||||
|
},
|
||||||
|
token
|
||||||
|
);
|
||||||
|
for (const row of raw.data || []) {
|
||||||
|
const h = str(row.hash);
|
||||||
|
const u = str(row.url) || str(row.permalink_url);
|
||||||
|
if (h && u) out.set(h, u);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/** Aggregate ad_daily rows by ad_id for a platform. */
|
/** Aggregate ad_daily rows by ad_id for a platform. */
|
||||||
export function aggregateDailyByAd(
|
export function aggregateDailyByAd(
|
||||||
rows: {
|
rows: {
|
||||||
@@ -187,29 +334,33 @@ async function fetchMetaCreativeBundle(
|
|||||||
campaign?: { name?: string };
|
campaign?: { name?: string };
|
||||||
adset?: { name?: string };
|
adset?: { name?: string };
|
||||||
creative?: Record<string, unknown>;
|
creative?: Record<string, unknown>;
|
||||||
}>(adId, {
|
}>(
|
||||||
fields: `id,name,effective_status,campaign{name},adset{name},creative{${CREATIVE_FIELDS}}`,
|
adId,
|
||||||
thumbnail_width: '1080',
|
{
|
||||||
thumbnail_height: '1080',
|
fields: `id,name,effective_status,campaign{name},adset{name},creative{${CREATIVE_FIELDS}}`,
|
||||||
}, token);
|
// Prefer vertical crop for Stories / Reels style
|
||||||
|
thumbnail_width: '1080',
|
||||||
|
thumbnail_height: '1920',
|
||||||
|
},
|
||||||
|
token
|
||||||
|
);
|
||||||
|
|
||||||
let creative = (raw.creative || {}) as Record<string, unknown>;
|
let creative = (raw.creative || {}) as Record<string, unknown>;
|
||||||
// Only hydrate creative detail when thumbnail/image missing
|
|
||||||
const cid = str(creative.id);
|
const cid = str(creative.id);
|
||||||
if (cid && !str(creative.thumbnail_url) && !str(creative.image_url)) {
|
if (cid) {
|
||||||
try {
|
try {
|
||||||
const detail = await graphGet<Record<string, unknown>>(
|
const detail = await graphGet<Record<string, unknown>>(
|
||||||
cid,
|
cid,
|
||||||
{
|
{
|
||||||
fields: CREATIVE_FIELDS,
|
fields: CREATIVE_FIELDS,
|
||||||
thumbnail_width: '1080',
|
thumbnail_width: '1080',
|
||||||
thumbnail_height: '1080',
|
thumbnail_height: '1920',
|
||||||
},
|
},
|
||||||
token
|
token
|
||||||
);
|
);
|
||||||
creative = { ...creative, ...detail };
|
creative = { ...creative, ...detail };
|
||||||
} catch {
|
} catch {
|
||||||
/* keep lightweight */
|
/* keep */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,48 +407,68 @@ export async function fetchMetaCreativeCards(opts: {
|
|||||||
return { ads: [], warning: 'Sin ads Meta en ad_daily para este periodo' };
|
return { ads: [], warning: 'Sin ads Meta en ad_daily para este periodo' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const out: AdCreativeCard[] = [];
|
const bundles: (Awaited<ReturnType<typeof fetchMetaCreativeBundle>>)[] = [];
|
||||||
// Parallel batches of 8 — single Graph call per ad (thumbnail already in creative{})
|
for (let i = 0; i < aggs.length; i += 6) {
|
||||||
for (let i = 0; i < aggs.length; i += 8) {
|
const chunk = aggs.slice(i, i + 6);
|
||||||
const chunk = aggs.slice(i, i + 8);
|
bundles.push(
|
||||||
const bundles = await Promise.all(
|
...(await Promise.all(chunk.map((a) => fetchMetaCreativeBundle(a.adId, token))))
|
||||||
chunk.map((a) => fetchMetaCreativeBundle(a.adId, token))
|
|
||||||
);
|
);
|
||||||
chunk.forEach((agg, idx) => {
|
|
||||||
const b = bundles[idx];
|
|
||||||
const creative = b?.creative || {};
|
|
||||||
const texts = extractTexts(creative);
|
|
||||||
const imageUrl =
|
|
||||||
str(creative.image_url) ||
|
|
||||||
str(creative.thumbnail_url) ||
|
|
||||||
'';
|
|
||||||
const thumbnailUrl =
|
|
||||||
str(creative.thumbnail_url) || imageUrl;
|
|
||||||
out.push({
|
|
||||||
platform: 'meta',
|
|
||||||
adId: agg.adId,
|
|
||||||
adName: b?.adName || agg.adName,
|
|
||||||
campaignName: b?.campaignName || agg.campaignName,
|
|
||||||
adsetName: b?.adsetName,
|
|
||||||
spend: Math.round(agg.spend * 100) / 100,
|
|
||||||
impressions: agg.impressions,
|
|
||||||
clicks: agg.clicks,
|
|
||||||
conversions: agg.conversions,
|
|
||||||
cpl: agg.conversions > 0 ? agg.spend / agg.conversions : null,
|
|
||||||
mediaType: detectMediaType(creative),
|
|
||||||
thumbnailUrl,
|
|
||||||
imageUrl: imageUrl || thumbnailUrl,
|
|
||||||
headline: texts.headline,
|
|
||||||
primaryText: texts.primaryText,
|
|
||||||
status: b?.status,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const allHashes: string[] = [];
|
||||||
|
const packs = bundles.map((b) => {
|
||||||
|
const pack = extractCreativePack(b?.creative || {});
|
||||||
|
allHashes.push(...pack.hashes);
|
||||||
|
return pack;
|
||||||
|
});
|
||||||
|
const hashMap = await resolveImageHashes(account, allHashes, token);
|
||||||
|
|
||||||
|
const out: AdCreativeCard[] = [];
|
||||||
|
aggs.forEach((agg, idx) => {
|
||||||
|
const b = bundles[idx];
|
||||||
|
const pack = packs[idx];
|
||||||
|
const assets = [...pack.assets];
|
||||||
|
for (const h of pack.hashes) {
|
||||||
|
const u = hashMap.get(h);
|
||||||
|
if (u && !assets.some((a) => a.url === u)) {
|
||||||
|
assets.push({ kind: 'image', url: u, label: 'hash' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const creative = b?.creative || {};
|
||||||
|
const fallback =
|
||||||
|
str(creative.image_url) || str(creative.thumbnail_url) || '';
|
||||||
|
const preview = pickVerticalPreview(assets, fallback);
|
||||||
|
const headlines = pack.headlines;
|
||||||
|
const descriptions = pack.descriptions;
|
||||||
|
out.push({
|
||||||
|
platform: 'meta',
|
||||||
|
adId: agg.adId,
|
||||||
|
adName: b?.adName || agg.adName,
|
||||||
|
campaignName: b?.campaignName || agg.campaignName,
|
||||||
|
adsetName: b?.adsetName,
|
||||||
|
spend: Math.round(agg.spend * 100) / 100,
|
||||||
|
impressions: agg.impressions,
|
||||||
|
clicks: agg.clicks,
|
||||||
|
conversions: agg.conversions,
|
||||||
|
cpl: agg.conversions > 0 ? agg.spend / agg.conversions : null,
|
||||||
|
mediaType: detectMediaType(creative),
|
||||||
|
thumbnailUrl: preview,
|
||||||
|
imageUrl: preview,
|
||||||
|
headline: headlines.join('\n'),
|
||||||
|
primaryText: descriptions.join('\n'),
|
||||||
|
headlines,
|
||||||
|
descriptions,
|
||||||
|
assets,
|
||||||
|
linkUrl: pack.linkUrl || undefined,
|
||||||
|
cta: pack.cta || undefined,
|
||||||
|
status: b?.status,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
return { ads: out };
|
return { ads: out };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Google cards from ad_daily only (no asset pull yet — text fallback). */
|
/** Google cards from ad_daily only (text fallback). */
|
||||||
export function buildGoogleCardsFromDaily(
|
export function buildGoogleCardsFromDaily(
|
||||||
dailyRows: Parameters<typeof aggregateDailyByAd>[0],
|
dailyRows: Parameters<typeof aggregateDailyByAd>[0],
|
||||||
since: string,
|
since: string,
|
||||||
@@ -321,6 +492,9 @@ export function buildGoogleCardsFromDaily(
|
|||||||
imageUrl: '',
|
imageUrl: '',
|
||||||
headline: agg.adName,
|
headline: agg.adName,
|
||||||
primaryText: agg.campaignName,
|
primaryText: agg.campaignName,
|
||||||
|
headlines: agg.adName ? [agg.adName] : [],
|
||||||
|
descriptions: agg.campaignName ? [agg.campaignName] : [],
|
||||||
|
assets: [],
|
||||||
status: undefined,
|
status: undefined,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user