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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user