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:
@@ -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