feat: previews de creatividades en Ad Tracker (estilo Brava)

Meta Graph en vivo + ficha Google; galería por anuncio en resumen/Meta/Google.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-23 18:16:14 +02:00
parent be45df300b
commit 86225a701e
3 changed files with 663 additions and 3 deletions

View File

@@ -1,6 +1,6 @@
'use client';
import React, { useMemo, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import {
AD_TRACKER_PRESETS,
RangePreset,
@@ -8,6 +8,8 @@ import {
resolveAdRange,
} 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';
type PlatformKpis = {
spend: number;
@@ -302,6 +304,165 @@ function CampaignTable({
);
}
function CreativesGallery({
platform,
ads,
loading,
warning,
}: {
platform: 'meta' | 'google';
ads: AdCreativeCard[];
loading: boolean;
warning?: string | null;
}) {
const filtered = ads.filter((a) => a.platform === platform);
return (
<section className="space-y-3">
<div>
<p className="text-[10px] uppercase tracking-wider text-gray-500">
Creatividades · estilo Brava Ad Tracker
</p>
<h3 className="text-lg font-bold text-white">
{platform === 'meta' ? 'Meta Ads' : 'Google Ads'} · preview por anuncio
</h3>
<p className="text-xs text-gray-500 mt-1">
Preview serving / asset + KPIs del rango. Meta tira creatividades en
vivo; Google muestra ficha (assets RSA en siguiente iteración).
</p>
</div>
{warning ? (
<p className="text-xs text-amber-400/90 border border-amber-500/20 rounded-lg px-3 py-2">
{warning}
</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 preview = ad.imageUrl || ad.thumbnailUrl;
const isVideo = ad.mediaType === 'video';
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"
referrerPolicy="no-referrer"
/>
) : ad.platform === 'google' ? (
<div className="w-full p-4 space-y-2 text-left">
<p className="text-[10px] uppercase tracking-wider text-sky-400">
Google Ads · {ad.mediaType || 'rsa'}
</p>
<p className="text-sm font-semibold text-blue-300 leading-snug">
{(ad.headline || ad.adName).split('\n')[0]}
</p>
<p className="text-xs text-gray-400 line-clamp-3">
{(ad.primaryText || ad.campaignName || '').split('\n')[0]}
</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}
</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>
{(ad.headline || ad.primaryText) && (
<div className="space-y-1 text-xs">
{ad.headline ? (
<p className="text-gray-200 font-medium line-clamp-2">
{ad.headline.split('\n')[0]}
</p>
) : null}
{ad.primaryText ? (
<p className="text-gray-500 line-clamp-3">
{ad.primaryText.split('\n')[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,
@@ -314,9 +475,53 @@ export default function AdTrackerClient({
const [preset, setPreset] = useState<RangePreset>('last_7d');
const [tab, setTab] = useState<Tab>('resumen');
const [chartMode, setChartMode] = useState<ChartMode>('combined');
const [creatives, setCreatives] = useState<AdCreativeCard[]>([]);
const [creativesLoading, setCreativesLoading] = useState(false);
const [creativesWarning, setCreativesWarning] = useState<string | null>(null);
const range = useMemo(() => resolveAdRange(preset), [preset]);
useEffect(() => {
let cancelled = false;
async function load() {
setCreativesLoading(true);
setCreativesWarning(null);
try {
const platform =
tab === 'meta' ? 'meta' : tab === 'google' ? 'google' : 'all';
const qs = new URLSearchParams({
since: range.since,
until: range.until,
platform,
});
const r = await fetch(`/api/ad-tracker/creatives?${qs}`, {
cache: 'no-store',
});
const data = await r.json().catch(() => ({}));
if (cancelled) return;
if (!r.ok) {
setCreatives([]);
setCreativesWarning(data.hint || data.error || 'No se pudieron cargar creatividades');
return;
}
setCreatives((data.ads as AdCreativeCard[]) || []);
const w = Array.isArray(data.warnings) ? data.warnings.filter(Boolean) : [];
setCreativesWarning(w.length ? w.join(' · ') : null);
} catch {
if (!cancelled) {
setCreatives([]);
setCreativesWarning('Error al cargar creatividades');
}
} finally {
if (!cancelled) setCreativesLoading(false);
}
}
void load();
return () => {
cancelled = true;
};
}, [range.since, range.until, tab]);
const filtered = useMemo(
() => rows.filter((r) => inRange(r.date, range.since, range.until)),
[rows, range.since, range.until]
@@ -366,8 +571,18 @@ export default function AdTrackerClient({
{tagline || 'Meta + Google Ads'}
</h2>
<p className="text-sm text-gray-400 mt-2 max-w-2xl">
Resumen agregado de Meta y Google Ads: gasto, clics, conversiones y
campañas activas en el periodo.
Resumen Meta + Google: gasto, clics, conversiones y{' '}
<span className="text-gray-300">preview de creatividades</span> por
anuncio (como en{' '}
<a
href="https://4ventures.es/adtracker/brava-dermoestetica"
target="_blank"
rel="noreferrer"
className="text-sky-400 hover:underline"
>
Ad Tracker Brava
</a>
). Ventana: {range.since} → {range.until}.
</p>
</div>
</div>
@@ -594,6 +809,19 @@ export default function AdTrackerClient({
empty="Sin campañas Google activas en este periodo"
/>
</div>
<CreativesGallery
platform="meta"
ads={creatives}
loading={creativesLoading}
warning={creativesWarning}
/>
<CreativesGallery
platform="google"
ads={creatives}
loading={false}
warning={null}
/>
</div>
)}
@@ -625,6 +853,12 @@ export default function AdTrackerClient({
rows={metaCampaigns}
empty="Sin campañas Meta activas en este periodo"
/>
<CreativesGallery
platform="meta"
ads={creatives}
loading={creativesLoading}
warning={creativesWarning}
/>
</div>
)}
@@ -656,6 +890,12 @@ export default function AdTrackerClient({
rows={googleCampaigns}
empty="Sin campañas Google activas en este periodo"
/>
<CreativesGallery
platform="google"
ads={creatives}
loading={creativesLoading}
warning={creativesWarning}
/>
</div>
)}
</div>