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:
4Ventures
2026-09-23 18:38:02 +02:00
parent d05df8c371
commit 8091498508
4 changed files with 951 additions and 308 deletions

View File

@@ -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>
);
}