Files
4ventures-client-portal/src/components/AdCreativesGallery.tsx

628 lines
23 KiB
TypeScript

'use client';
import React, { useEffect } from 'react';
import type {
AdCreativeCard,
CreativeAspect,
CreativeAsset,
} from '@/lib/ad-creatives-meta';
import { formatEur, formatEurCents, 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.slice(0, 5);
return (ad.headline || '')
.split('\n')
.map((s) => s.trim())
.filter(Boolean)
.slice(0, 5);
}
function adBodies(ad: AdCreativeCard): string[] {
if (ad.bodies?.length) return ad.bodies.slice(0, 5);
// Legacy: primaryText used to hold bodies
return (ad.primaryText || '')
.split('\n')
.map((s) => s.trim())
.filter(Boolean)
.slice(0, 5);
}
function adLinkDescriptions(ad: AdCreativeCard): string[] {
if (ad.descriptions?.length) return ad.descriptions.slice(0, 5);
return [];
}
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', aspect: '9:16' });
if (ad.youtubeId) {
out.push({
kind: 'youtube',
url: `https://i.ytimg.com/vi/${ad.youtubeId}/hqdefault.jpg`,
youtubeId: ad.youtubeId,
label: 'YOUTUBE',
aspect: '16:9',
});
}
return out;
}
function resolveAspect(asset: CreativeAsset): CreativeAspect {
if (asset.aspect && asset.aspect !== 'unknown') return asset.aspect;
const L = (asset.label || '').toLowerCase();
if (/9\s*:?\s*16|story|reels|vertical|tall/.test(L)) return '9:16';
if (/4\s*:?\s*5|portrait/.test(L)) return '4:5';
if (/16\s*:?\s*9|1\.91|landscape|horizontal|youtube|video/.test(L)) {
return '16:9';
}
if (/1\s*:?\s*1|square|logo/.test(L)) return '1:1';
return 'unknown';
}
function aspectClass(aspect: CreativeAspect): string {
switch (aspect) {
case '9:16':
return 'aspect-[9/16]';
case '4:5':
return 'aspect-[4/5]';
case '16:9':
return 'aspect-video';
case '1:1':
return 'aspect-square';
default:
return 'aspect-[4/5]';
}
}
function previewUrlFor(ad: AdCreativeCard): string {
const assets = adAssets(ad);
const nine = assets.find(
(a) => a.kind === 'image' && resolveAspect(a) === '9:16'
);
const fourFive = assets.find(
(a) => a.kind === 'image' && resolveAspect(a) === '4:5'
);
const raw =
nine?.url ||
fourFive?.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-200 bg-gray-50 px-2.5 py-2">
<p className="text-[9px] uppercase tracking-wider text-gray-500">{label}</p>
<p className="text-sm font-semibold text-gray-900 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) => {
// Never surface soft Graph/insights failures when we still render cards
if (/insights ad-level|fetch failed|métricas repartidas|sin imagen/i.test(w)) {
return false;
}
return platform === 'meta'
? /meta|token meta|sin ads meta|sin actividad meta|sin cuenta meta/i.test(w)
: /google|pmax|rsa|sin ads google|sin assets|oauth|warehouse|reconecta/i.test(
w
);
}) ||
(platform === 'google' && warning && /google|oauth|token/i.test(warning)
? warning
: 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-gray-900">
{platform === 'meta' ? 'Meta Ads' : 'Google Ads'} · creatividades
</h3>
<p className="text-xs text-gray-500 mt-1">
Preview 9:16. Pulsa Zoom para ver el resto de piezas en su formato.
</p>
</div>
{platformWarning ? (
<p className="text-xs text-amber-800 border border-amber-200 bg-amber-50 rounded-lg px-3 py-2">
{platformWarning}
</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-200 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 = adBodies(ad);
const linkDescs = adLinkDescriptions(ad);
const assets = adAssets(ad);
const extraCount = Math.max(0, assets.length - 1);
return (
<article className="grid gap-4 rounded-2xl border border-gray-200 bg-white p-4 md:grid-cols-[minmax(140px,180px)_1fr]">
<button
type="button"
onClick={onZoom}
className="relative mx-auto w-full max-w-[180px] overflow-hidden rounded-xl bg-gray-100 border border-gray-200 aspect-[9/16] 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 line-clamp-2"
>
{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}
</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-gray-900 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-200 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-700 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>
{(headlines.length > 0 || bodies.length > 0 || linkDescs.length > 0) && (
<div className="space-y-2.5 text-xs border border-gray-200 rounded-xl p-3 bg-gray-50">
{headlines.length > 0 ? (
<div>
<p className="text-[9px] uppercase tracking-wider text-gray-500 mb-1">
Titulares ({headlines.length})
</p>
<ul className="space-y-1">
{headlines.map((h) => (
<li
key={h}
className="text-gray-900 font-medium leading-snug"
>
{h}
</li>
))}
</ul>
</div>
) : null}
{bodies.length > 0 ? (
<div>
<p className="text-[9px] uppercase tracking-wider text-gray-500 mb-1">
Textos ({bodies.length})
</p>
<ul className="space-y-1.5">
{bodies.map((b) => (
<li key={b} className="text-gray-600 leading-relaxed">
{b}
</li>
))}
</ul>
</div>
) : null}
{linkDescs.length > 0 ? (
<div>
<p className="text-[9px] uppercase tracking-wider text-gray-500 mb-1">
Descripción
</p>
<ul className="space-y-1">
{linkDescs.map((d) => (
<li key={d} className="text-gray-500 leading-relaxed">
{d}
</li>
))}
</ul>
</div>
) : null}
{ad.cta ? (
<p className="text-[10px] text-brand-700 uppercase tracking-wide pt-0.5">
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 ? formatEurCents(ad.cpl) : '—'}
/>
</div>
</div>
</article>
);
}
export function CreativeZoomModal({
ad,
onClose,
}: {
ad: AdCreativeCard;
onClose: () => void;
}) {
const headlines = adHeadlines(ad);
const bodies = adBodies(ad);
const linkDescs = adLinkDescriptions(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);
const previewAsset =
images.find((a) => resolveAspect(a) === '9:16') || images[0];
const otherImages = images.filter((a) => a.url !== previewAsset?.url);
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-200 bg-white shadow-2xl max-h-[95vh]"
onClick={(e) => e.stopPropagation()}
>
<header className="flex items-start justify-between gap-3 border-b border-gray-200 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-gray-900 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-200 p-2 text-gray-600 hover:bg-gray-50"
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(200px,280px)_1fr]">
<div className="space-y-3">
<div className="relative mx-auto w-full max-w-[280px] overflow-hidden rounded-2xl border border-gray-200 bg-black aspect-[9/16]">
{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>
<p className="text-center text-[10px] uppercase tracking-wider text-gray-500">
Preview 9:16
</p>
<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 ? formatEurCents(ad.cpl) : '—'}
/>
</div>
<div className="text-xs text-gray-500 space-y-1">
<p>
Impresiones:{' '}
<span className="text-gray-600">
{formatNum(ad.impressions)}
</span>
</p>
<p>
Formato:{' '}
<span className="text-gray-600">{ad.mediaType || '—'}</span>
</p>
{ad.cta ? (
<p>
CTA: <span className="text-brand-700">{ad.cta}</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">
{otherImages.length > 0 ? (
<section>
<h3 className="text-sm font-semibold text-gray-900 mb-2">
Otras piezas ({otherImages.length})
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{otherImages.map((asset, i) => {
const aspect = resolveAspect(asset);
return (
<figure
key={`${asset.url}-${i}`}
className="overflow-hidden rounded-xl border border-gray-200 bg-gray-100"
>
<div
className={`${aspectClass(aspect)} max-h-[420px] mx-auto w-full bg-black/5`}
>
{/* 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-contain"
loading="lazy"
/>
</div>
<figcaption className="px-2 py-1.5 text-[10px] uppercase tracking-wider text-gray-500 truncate">
{aspect !== 'unknown' ? aspect : asset.label || 'imagen'}
{asset.kind === 'logo' ? ' · logo' : ''}
</figcaption>
</figure>
);
})}
</div>
</section>
) : null}
{videos.length > 0 ? (
<section>
<h3 className="text-sm font-semibold text-gray-900 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-200 overflow-hidden bg-gray-100"
>
{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-[9/16] relative max-h-[420px] mx-auto">
{/* 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}
</div>
))}
</div>
</section>
) : null}
{headlines.length > 0 ? (
<section>
<h3 className="text-sm font-semibold text-gray-900 mb-2">
Titulares ({headlines.length})
</h3>
<ul className="space-y-1.5 text-sm text-gray-800">
{headlines.map((h) => (
<li
key={h}
className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2"
>
{h}
</li>
))}
</ul>
</section>
) : null}
{longH.length > 0 ? (
<section>
<h3 className="text-sm font-semibold text-gray-900 mb-2">
Titulares largos ({longH.length})
</h3>
<ul className="space-y-1.5 text-sm text-gray-800">
{longH.slice(0, 5).map((h) => (
<li
key={h}
className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2"
>
{h}
</li>
))}
</ul>
</section>
) : null}
{bodies.length > 0 ? (
<section>
<h3 className="text-sm font-semibold text-gray-900 mb-2">
Textos ({bodies.length})
</h3>
<ul className="space-y-2 text-sm text-gray-600">
{bodies.map((b) => (
<li
key={b}
className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 leading-relaxed whitespace-pre-wrap"
>
{b}
</li>
))}
</ul>
</section>
) : null}
{linkDescs.length > 0 ? (
<section>
<h3 className="text-sm font-semibold text-gray-900 mb-2">
Descripción
</h3>
<ul className="space-y-2 text-sm text-gray-600">
{linkDescs.map((d) => (
<li
key={d}
className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 leading-relaxed whitespace-pre-wrap"
>
{d}
</li>
))}
</ul>
</section>
) : null}
</div>
</div>
</div>
</div>
</div>
);
}