1030 lines
36 KiB
TypeScript
1030 lines
36 KiB
TypeScript
'use client';
|
|
|
|
import React, { useEffect } from 'react';
|
|
import type {
|
|
AdCreativeCard,
|
|
CreativeAspect,
|
|
CreativeAsset,
|
|
} from '@/lib/ad-creatives-meta';
|
|
import { pickMetaPreviewAsset } 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[] {
|
|
const all = ad.headlines?.length
|
|
? ad.headlines
|
|
: (ad.headline || '')
|
|
.split('\n')
|
|
.map((s) => s.trim())
|
|
.filter(Boolean);
|
|
if (ad.platform === 'google') return all;
|
|
return all.slice(0, 5);
|
|
}
|
|
|
|
function adBodies(ad: AdCreativeCard): string[] {
|
|
const all = ad.bodies?.length
|
|
? ad.bodies
|
|
: (ad.primaryText || '')
|
|
.split('\n')
|
|
.map((s) => s.trim())
|
|
.filter(Boolean);
|
|
if (ad.platform === 'google') return all;
|
|
return all.slice(0, 5);
|
|
}
|
|
|
|
function adLinkDescriptions(ad: AdCreativeCard): string[] {
|
|
if (ad.descriptions?.length) return ad.descriptions;
|
|
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/.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]';
|
|
}
|
|
}
|
|
|
|
/** Meta: frame 9:16 by default; carousel 1:1 then 4:5. Google PMax stays portrait-first. */
|
|
function previewMeta(ad: AdCreativeCard): { url: string; aspect: CreativeAspect } {
|
|
const assets = adAssets(ad);
|
|
const picked = pickMetaPreviewAsset(
|
|
assets,
|
|
ad.mediaType || 'image',
|
|
ad.imageUrl || ad.thumbnailUrl || ''
|
|
);
|
|
// Frame rules (user): vertical default; carousel square / 4:5
|
|
const aspect: CreativeAspect =
|
|
ad.mediaType === 'carousel'
|
|
? picked.aspect === '4:5'
|
|
? '4:5'
|
|
: '1:1'
|
|
: '9:16';
|
|
const raw =
|
|
picked.url ||
|
|
ad.imageUrl ||
|
|
ad.thumbnailUrl ||
|
|
assets.find((a) => a.url)?.url ||
|
|
'';
|
|
return { url: raw ? proxiedMediaUrl(raw) : '', aspect };
|
|
}
|
|
|
|
function previewGoogle(ad: AdCreativeCard): { url: string; aspect: CreativeAspect } {
|
|
const assets = adAssets(ad);
|
|
const isSearch =
|
|
ad.mediaType === 'rsa' ||
|
|
ad.channel === 'search_brand' ||
|
|
ad.channel === 'search_generic';
|
|
if (isSearch) {
|
|
return { url: '', aspect: '9:16' };
|
|
}
|
|
const portrait = assets.find(
|
|
(a) =>
|
|
(a.kind === 'image' || a.kind === 'logo') &&
|
|
(resolveAspect(a) === '4:5' || resolveAspect(a) === '9:16')
|
|
);
|
|
const square = assets.find(
|
|
(a) =>
|
|
(a.kind === 'image' || a.kind === 'logo') && resolveAspect(a) === '1:1'
|
|
);
|
|
const landscape = assets.find(
|
|
(a) => a.kind === 'image' && resolveAspect(a) === '16:9'
|
|
);
|
|
const raw =
|
|
portrait?.url ||
|
|
square?.url ||
|
|
landscape?.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 ||
|
|
'';
|
|
const aspect: CreativeAspect = portrait
|
|
? resolveAspect(portrait)
|
|
: square
|
|
? '1:1'
|
|
: landscape
|
|
? '16:9'
|
|
: '4:5';
|
|
return { url: raw ? proxiedMediaUrl(raw) : '', aspect };
|
|
}
|
|
|
|
function previewFor(ad: AdCreativeCard): { url: string; aspect: CreativeAspect } {
|
|
return ad.platform === 'meta' ? previewMeta(ad) : previewGoogle(ad);
|
|
}
|
|
|
|
function formatThumbs(ad: AdCreativeCard): { url: string; aspect: CreativeAspect; label: string }[] {
|
|
if (ad.mediaType !== 'pmax' && ad.channel !== 'pmax') return [];
|
|
const assets = adAssets(ad).filter(
|
|
(a) =>
|
|
(a.kind === 'image' || a.kind === 'logo' || a.kind === 'youtube') && a.url
|
|
);
|
|
const seen = new Set<string>();
|
|
const out: { url: string; aspect: CreativeAspect; label: string }[] = [];
|
|
for (const a of assets) {
|
|
if (seen.has(a.url)) continue;
|
|
seen.add(a.url);
|
|
const asp = resolveAspect(a);
|
|
out.push({
|
|
url: proxiedMediaUrl(a.url),
|
|
aspect: asp === 'unknown' ? '1:1' : asp,
|
|
label: a.label || asp,
|
|
});
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function CopyList({
|
|
label,
|
|
items,
|
|
strong,
|
|
muted,
|
|
}: {
|
|
label: string;
|
|
items: string[];
|
|
strong?: boolean;
|
|
muted?: boolean;
|
|
}) {
|
|
return (
|
|
<div>
|
|
<p className="text-[9px] uppercase tracking-wider text-gray-500 mb-1">
|
|
{label}
|
|
</p>
|
|
<ul className="space-y-1">
|
|
{items.map((h) => (
|
|
<li
|
|
key={h}
|
|
className={`leading-snug ${
|
|
strong
|
|
? 'text-gray-900 font-medium'
|
|
: muted
|
|
? 'text-gray-500'
|
|
: 'text-gray-600'
|
|
}`}
|
|
>
|
|
{h}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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);
|
|
|
|
const googleGroups: {
|
|
key: string;
|
|
title: string;
|
|
hint: string;
|
|
items: AdCreativeCard[];
|
|
}[] =
|
|
platform === 'google'
|
|
? [
|
|
{
|
|
key: 'search_brand',
|
|
title: 'Search · Brand',
|
|
hint: 'RSA de marca (titulares + URL)',
|
|
items: filtered.filter((a) => a.channel === 'search_brand'),
|
|
},
|
|
{
|
|
key: 'search_generic',
|
|
title: 'Search · Generic',
|
|
hint: 'RSA genéricos / captación',
|
|
items: filtered.filter(
|
|
(a) =>
|
|
a.channel === 'search_generic' ||
|
|
(!a.channel && a.mediaType === 'rsa')
|
|
),
|
|
},
|
|
{
|
|
key: 'pmax',
|
|
title: 'Performance Max',
|
|
hint: 'Asset groups · imágenes, logos y vídeo',
|
|
items: filtered.filter(
|
|
(a) => a.channel === 'pmax' || a.mediaType === 'pmax'
|
|
),
|
|
},
|
|
{
|
|
key: 'display',
|
|
title: 'Display · Remarketing',
|
|
hint: 'RDA / image ads · creatividades visuales',
|
|
items: filtered.filter(
|
|
(a) =>
|
|
a.channel === 'display' ||
|
|
a.mediaType === 'rda' ||
|
|
a.mediaType === 'image'
|
|
),
|
|
},
|
|
{
|
|
key: 'other',
|
|
title: 'Otros Google',
|
|
hint: 'Campañas sin clasificar',
|
|
items: filtered.filter((a) => {
|
|
const ch = a.channel;
|
|
if (
|
|
ch === 'search_brand' ||
|
|
ch === 'search_generic' ||
|
|
ch === 'pmax' ||
|
|
ch === 'display'
|
|
) {
|
|
return false;
|
|
}
|
|
if (!ch && (a.mediaType === 'rsa' || a.mediaType === 'pmax' || a.mediaType === 'rda' || a.mediaType === 'image')) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}),
|
|
},
|
|
].filter((g) => g.items.length > 0)
|
|
: [];
|
|
|
|
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">
|
|
{platform === 'google'
|
|
? 'Agrupado por Search Brand / Generic, Performance Max y Display. Search muestra preview tipo SERP; PMax muestra todos los formatos (1:1, 4:5, 16:9).'
|
|
: 'Meta: preview vertical 9:16 por defecto; carrusel en 1:1 (o 4:5). Pulsa Zoom para el resto de piezas.'}
|
|
</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>
|
|
) : platform === 'google' && googleGroups.length > 0 ? (
|
|
<div className="space-y-8">
|
|
{googleGroups.map((g) => (
|
|
<div key={g.key} className="space-y-3">
|
|
<div>
|
|
<h4 className="text-sm font-semibold text-gray-900">{g.title}</h4>
|
|
<p className="text-[11px] text-gray-500">{g.hint} · {g.items.length}</p>
|
|
</div>
|
|
<div className="space-y-3">
|
|
{g.items.map((ad) => (
|
|
<CreativeCard
|
|
key={`${ad.platform}-${ad.adId}`}
|
|
ad={ad}
|
|
onZoom={() => onZoom(ad)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<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 { url: preview, aspect: frameAspect } = previewFor(ad);
|
|
const isVideo = ad.mediaType === 'video' || Boolean(ad.youtubeId);
|
|
const headlines = adHeadlines(ad);
|
|
const bodies = adBodies(ad);
|
|
const linkDescs = adLinkDescriptions(ad);
|
|
const longTitles = ad.longHeadlines || [];
|
|
const googleDescs =
|
|
ad.platform === 'google'
|
|
? ad.descriptions?.length
|
|
? ad.descriptions
|
|
: bodies
|
|
: [];
|
|
const assets = adAssets(ad);
|
|
const extraCount = Math.max(0, assets.length - 1);
|
|
const pmaxFormats = formatThumbs(ad);
|
|
const isSearch =
|
|
ad.platform === 'google' &&
|
|
(ad.mediaType === 'rsa' ||
|
|
ad.channel === 'search_brand' ||
|
|
ad.channel === 'search_generic');
|
|
|
|
return (
|
|
<article className="grid gap-4 rounded-2xl border border-gray-200 bg-white p-4 md:grid-cols-[minmax(140px,200px)_1fr]">
|
|
<div className="space-y-2">
|
|
<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 flex items-center justify-center group ${aspectClass(frameAspect)}`}
|
|
>
|
|
{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"
|
|
/>
|
|
) : isSearch && headlines.length ? (
|
|
<div className="w-full h-full p-3.5 space-y-1.5 text-left bg-white flex flex-col justify-center border-t border-gray-100">
|
|
<p className="text-[9px] uppercase tracking-wider text-emerald-700 font-medium">
|
|
Anuncio ·{' '}
|
|
{ad.channel === 'search_brand'
|
|
? 'Search Brand'
|
|
: ad.channel === 'search_generic'
|
|
? 'Search Generic'
|
|
: 'Search'}
|
|
</p>
|
|
<p className="text-[10px] text-emerald-800 truncate">
|
|
{(ad.linkUrl || 'https://ejemplo.com').replace(/^https?:\/\//, '')}
|
|
</p>
|
|
{headlines.slice(0, 2).map((h) => (
|
|
<p
|
|
key={h}
|
|
className="text-[13px] font-medium text-[#1a0dab] leading-snug line-clamp-2"
|
|
>
|
|
{h}
|
|
</p>
|
|
))}
|
|
{googleDescs[0] ? (
|
|
<p className="text-[11px] text-gray-600 leading-snug line-clamp-3">
|
|
{googleDescs[0]}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
) : 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 || 'ad'}
|
|
</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}
|
|
{ad.platform === 'meta' ? (
|
|
<span className="absolute top-2 left-2 rounded bg-black/70 px-1.5 py-0.5 text-[9px] text-white uppercase tracking-wide">
|
|
{frameAspect}
|
|
</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>
|
|
{pmaxFormats.length > 0 ? (
|
|
<div className="flex flex-wrap gap-1.5 justify-center">
|
|
{pmaxFormats.slice(0, 12).map((f, i) => (
|
|
<button
|
|
key={`${f.url}-${i}`}
|
|
type="button"
|
|
onClick={onZoom}
|
|
className="w-12 overflow-hidden rounded-md border border-gray-200 bg-gray-50"
|
|
title={f.label}
|
|
>
|
|
<div className={`${aspectClass(f.aspect)} w-full`}>
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
src={f.url}
|
|
alt={f.label}
|
|
className="w-full h-full object-cover"
|
|
loading="lazy"
|
|
/>
|
|
</div>
|
|
<p className="text-[8px] text-center text-gray-500 py-0.5">
|
|
{f.label}
|
|
</p>
|
|
</button>
|
|
))}
|
|
{pmaxFormats.length > 12 ? (
|
|
<p className="w-full text-center text-[10px] text-gray-500">
|
|
+{pmaxFormats.length - 12} en Zoom
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
) : 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-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 ||
|
|
longTitles.length > 0 ||
|
|
bodies.length > 0 ||
|
|
linkDescs.length > 0 ||
|
|
googleDescs.length > 0) && (
|
|
<div className="space-y-2.5 text-xs border border-gray-200 rounded-xl p-3 bg-gray-50">
|
|
{ad.platform === 'google' ? (
|
|
<>
|
|
{headlines.length > 0 ? (
|
|
<CopyList
|
|
label={`Títulos (${headlines.length})`}
|
|
items={headlines}
|
|
strong
|
|
/>
|
|
) : null}
|
|
{longTitles.length > 0 ? (
|
|
<CopyList
|
|
label={`Títulos largos (${longTitles.length})`}
|
|
items={longTitles}
|
|
strong
|
|
/>
|
|
) : null}
|
|
{googleDescs.length > 0 ? (
|
|
<CopyList
|
|
label={`Descripciones (${googleDescs.length})`}
|
|
items={googleDescs}
|
|
/>
|
|
) : null}
|
|
</>
|
|
) : (
|
|
<>
|
|
{headlines.length > 0 ? (
|
|
<CopyList
|
|
label={`Titulares (${headlines.length})`}
|
|
items={headlines}
|
|
strong
|
|
/>
|
|
) : null}
|
|
{bodies.length > 0 ? (
|
|
<CopyList label={`Textos (${bodies.length})`} items={bodies} />
|
|
) : null}
|
|
{linkDescs.length > 0 ? (
|
|
<CopyList label="Descripción" items={linkDescs} muted />
|
|
) : 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 googleDescs =
|
|
ad.platform === 'google'
|
|
? ad.descriptions?.length
|
|
? ad.descriptions
|
|
: bodies
|
|
: [];
|
|
const isGoogle = ad.platform === 'google';
|
|
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 { url: main, aspect: mainAspect } = previewFor(ad);
|
|
const zoomImages =
|
|
ad.platform === 'google'
|
|
? images.filter((a) => a.url)
|
|
: images.filter((a) => {
|
|
if (!a.url) return false;
|
|
const previewAssetUrl = main.replace(
|
|
/^\/api\/ad-tracker\/media\?url=/,
|
|
''
|
|
);
|
|
if (!previewAssetUrl) return true;
|
|
try {
|
|
return (
|
|
decodeURIComponent(previewAssetUrl) !== a.url && a.url !== main
|
|
);
|
|
} catch {
|
|
return a.url !== main;
|
|
}
|
|
});
|
|
|
|
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 ${aspectClass(mainAspect)}`}
|
|
>
|
|
{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 {mainAspect}
|
|
</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">
|
|
{zoomImages.length > 0 ? (
|
|
<section>
|
|
<h3 className="text-sm font-semibold text-gray-900 mb-2">
|
|
{ad.platform === 'google'
|
|
? `Imágenes y logos (${zoomImages.length})`
|
|
: `Otras piezas (${zoomImages.length})`}
|
|
</h3>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
{zoomImages.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">
|
|
{isGoogle ? 'Títulos' : '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">
|
|
Títulos largos ({longH.length})
|
|
</h3>
|
|
<ul className="space-y-1.5 text-sm text-gray-800">
|
|
{longH.map((h) => (
|
|
<li
|
|
key={h}
|
|
className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2"
|
|
>
|
|
{h}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
) : null}
|
|
|
|
{isGoogle && googleDescs.length > 0 ? (
|
|
<section>
|
|
<h3 className="text-sm font-semibold text-gray-900 mb-2">
|
|
Descripciones ({googleDescs.length})
|
|
</h3>
|
|
<ul className="space-y-2 text-sm text-gray-600">
|
|
{googleDescs.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}
|
|
|
|
{!isGoogle && 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}
|
|
|
|
{!isGoogle && 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}
|
|
|
|
{ad.businessName ? (
|
|
<section>
|
|
<h3 className="text-sm font-semibold text-gray-900 mb-2">
|
|
Nombre de negocio
|
|
</h3>
|
|
<p className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-800">
|
|
{ad.businessName}
|
|
</p>
|
|
</section>
|
|
) : null}
|
|
|
|
{(ad.ctas || []).length > 0 ? (
|
|
<section>
|
|
<h3 className="text-sm font-semibold text-gray-900 mb-2">
|
|
CTA ({ad.ctas!.length})
|
|
</h3>
|
|
<ul className="flex flex-wrap gap-1.5">
|
|
{ad.ctas!.map((c) => (
|
|
<li
|
|
key={c}
|
|
className="rounded-full border border-gray-200 bg-white px-3 py-1 text-xs text-gray-800"
|
|
>
|
|
{c}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
) : null}
|
|
|
|
{(ad.sitelinks || []).length > 0 ? (
|
|
<section>
|
|
<h3 className="text-sm font-semibold text-gray-900 mb-2">
|
|
Sitelinks ({ad.sitelinks!.length})
|
|
</h3>
|
|
<ul className="space-y-1.5 text-sm text-gray-800">
|
|
{ad.sitelinks!.map((s) => (
|
|
<li
|
|
key={s}
|
|
className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2"
|
|
>
|
|
{s}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
) : null}
|
|
|
|
{(ad.callouts || []).length > 0 ? (
|
|
<section>
|
|
<h3 className="text-sm font-semibold text-gray-900 mb-2">
|
|
Callouts ({ad.callouts!.length})
|
|
</h3>
|
|
<ul className="space-y-1.5 text-sm text-gray-800">
|
|
{ad.callouts!.map((s) => (
|
|
<li
|
|
key={s}
|
|
className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2"
|
|
>
|
|
{s}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
) : null}
|
|
|
|
{(ad.snippets || []).length > 0 ? (
|
|
<section>
|
|
<h3 className="text-sm font-semibold text-gray-900 mb-2">
|
|
Structured snippets ({ad.snippets!.length})
|
|
</h3>
|
|
<ul className="space-y-1.5 text-sm text-gray-800">
|
|
{ad.snippets!.map((s) => (
|
|
<li
|
|
key={s}
|
|
className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2"
|
|
>
|
|
{s}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|