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';
|
||||
import { AdDailyRow, formatEur, formatNum } from '@/lib/metrics';
|
||||
import type { AdCreativeCard } from '@/lib/ad-creatives-meta';
|
||||
import { ImageIcon, Play } from 'lucide-react';
|
||||
import {
|
||||
CreativesGallery,
|
||||
CreativeZoomModal,
|
||||
} from '@/components/AdCreativesGallery';
|
||||
|
||||
type PlatformKpis = {
|
||||
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({
|
||||
label,
|
||||
tagline,
|
||||
@@ -518,6 +322,7 @@ export default function AdTrackerClient({
|
||||
const [creatives, setCreatives] = useState<AdCreativeCard[]>([]);
|
||||
const [creativesLoading, setCreativesLoading] = useState(false);
|
||||
const [creativesWarning, setCreativesWarning] = useState<string | null>(null);
|
||||
const [zoomAd, setZoomAd] = useState<AdCreativeCard | null>(null);
|
||||
|
||||
const range = useMemo(() => resolveAdRange(preset), [preset]);
|
||||
|
||||
@@ -776,12 +581,14 @@ export default function AdTrackerClient({
|
||||
ads={creatives}
|
||||
loading={creativesLoading}
|
||||
warning={creativesWarning}
|
||||
onZoom={setZoomAd}
|
||||
/>
|
||||
<CreativesGallery
|
||||
platform="google"
|
||||
ads={creatives}
|
||||
loading={creativesLoading}
|
||||
warning={creativesWarning}
|
||||
onZoom={setZoomAd}
|
||||
/>
|
||||
|
||||
<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}
|
||||
loading={creativesLoading}
|
||||
warning={creativesWarning}
|
||||
onZoom={setZoomAd}
|
||||
/>
|
||||
<section className="rounded-2xl border border-gray-800 bg-[#111827] p-4">
|
||||
<h3 className="text-lg font-bold text-white mb-3">
|
||||
@@ -918,6 +726,7 @@ export default function AdTrackerClient({
|
||||
ads={creatives}
|
||||
loading={creativesLoading}
|
||||
warning={creativesWarning}
|
||||
onZoom={setZoomAd}
|
||||
/>
|
||||
<section className="rounded-2xl border border-gray-800 bg-[#111827] p-4">
|
||||
<h3 className="text-lg font-bold text-white mb-3">
|
||||
@@ -938,6 +747,9 @@ export default function AdTrackerClient({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{zoomAd ? (
|
||||
<CreativeZoomModal ad={zoomAd} onClose={() => setZoomAd(null)} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user