Files
4ventures-client-portal/src/components/AdTrackerClient.tsx
4Ventures a5bfec698b Align Ads and Métricas on the same paginated ad_daily warehouse.
Ad Tracker only loaded 2000 newest rows, so Brava showed 0 € while Métricas summed the full month. Both now page the same table for the selected range.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 15:33:06 +02:00

1077 lines
34 KiB
TypeScript

'use client';
import React, { useEffect, useMemo, useState } from 'react';
import { normalizePlatform } from '@/lib/ad-tracker-range';
import { AdDailyRow, formatEur, formatEurCents, formatNum, linkClicksOf } from '@/lib/metrics';
import type { AdCreativeCard } from '@/lib/ad-creatives-meta';
import {
CreativesGallery,
CreativeZoomModal,
} from '@/components/AdCreativesGallery';
import { PageHeader, SegmentedControl } from '@/components/ui';
import SyncNowButton from '@/components/SyncNowButton';
import { useDateRange } from '@/components/DateRangeContext';
import {
HelpDetails,
HeroBand,
HeroStat,
ShareBar,
StatMini,
tableHeadClass,
} from '@/components/dash-ui';
type PlatformKpis = {
spend: number;
impressions: number;
clicks: number;
conversions: number;
ads: number;
ctr: number | null;
cpc: number | null;
cpl: number | null;
};
type DailyPoint = {
date: string;
metaSpend: number;
googleSpend: number;
metaClicks: number;
googleClicks: number;
metaConv: number;
googleConv: number;
};
type CampaignRow = {
name: string;
spend: number;
impressions: number;
clicks: number;
conversions: number;
cpl: number | null;
};
type Tab = 'resumen' | 'meta' | 'google';
type ChartMode = 'combined' | 'meta' | 'google';
function rates(input: Omit<PlatformKpis, 'ctr' | 'cpc' | 'cpl'>): PlatformKpis {
const { spend, impressions, clicks, conversions, ads } = input;
return {
spend,
impressions,
clicks,
conversions,
ads,
ctr: impressions > 0 ? (100 * clicks) / impressions : null,
cpc: clicks > 0 ? spend / clicks : null,
cpl: conversions > 0 ? spend / conversions : null,
};
}
function convOf(r: AdDailyRow): number {
const x = r.platform_leads ?? r.conversions;
const n = Number(x);
return Number.isFinite(n) ? n : 0;
}
function campaignName(r: AdDailyRow): string {
return r.campaign_name || r.campaign || r.ad_name || 'Sin nombre';
}
function inRange(date: string | undefined, since: string, until: string) {
if (!date) return false;
const d = date.slice(0, 10);
return d >= since && d <= until;
}
function buildSeries(rows: AdDailyRow[]): DailyPoint[] {
const map = new Map<string, DailyPoint>();
for (const r of rows) {
const d = (r.date || '').slice(0, 10);
if (!d) continue;
const cur =
map.get(d) ||
({
date: d,
metaSpend: 0,
googleSpend: 0,
metaClicks: 0,
googleClicks: 0,
metaConv: 0,
googleConv: 0,
} satisfies DailyPoint);
const plat = normalizePlatform(r.platform);
const spend = Number(r.spend || 0);
const clicks = Number(r.clicks || 0);
const conv = convOf(r);
if (plat === 'meta') {
cur.metaSpend += spend;
cur.metaClicks += clicks;
cur.metaConv += conv;
} else if (plat === 'google') {
cur.googleSpend += spend;
cur.googleClicks += clicks;
cur.googleConv += conv;
} else {
// unknown → split weight to meta bucket for visibility
cur.metaSpend += spend;
cur.metaClicks += clicks;
cur.metaConv += conv;
}
map.set(d, cur);
}
return [...map.values()].sort((a, b) => a.date.localeCompare(b.date));
}
function aggregate(rows: AdDailyRow[]): PlatformKpis {
const active = rows.filter(
(r) =>
Number(r.spend || 0) > 0 ||
Number(r.impressions || 0) > 0 ||
Number(r.clicks || 0) > 0 ||
convOf(r) > 0
);
const spend = active.reduce((a, r) => a + Number(r.spend || 0), 0);
const impressions = active.reduce((a, r) => a + Number(r.impressions || 0), 0);
const clicks = active.reduce((a, r) => a + Number(r.clicks || 0), 0);
const conversions = active.reduce((a, r) => a + convOf(r), 0);
// Round once after sum (Ads Manager style; Google stores fractional daily).
const conversionsRounded = Math.round(conversions * 100) / 100;
const ads = new Set(
active
.map((r) => String(r.ad_id || campaignName(r) || '').trim())
.filter((n) => n && n !== 'Sin nombre')
).size;
return rates({
spend,
impressions,
clicks,
conversions: Math.round(conversionsRounded),
ads,
});
}
function byCampaign(rows: AdDailyRow[]): CampaignRow[] {
const map = new Map<string, CampaignRow>();
for (const r of rows) {
const spend = Number(r.spend || 0);
const impressions = Number(r.impressions || 0);
const clicks = Number(r.clicks || 0);
const conversions = convOf(r);
if (spend <= 0 && impressions <= 0 && clicks <= 0 && conversions <= 0) {
continue;
}
const name = campaignName(r);
const cur =
map.get(name) ||
({
name,
spend: 0,
impressions: 0,
clicks: 0,
conversions: 0,
cpl: null,
} satisfies CampaignRow);
cur.spend += spend;
cur.impressions += impressions;
cur.clicks += clicks;
cur.conversions += conversions;
map.set(name, cur);
}
return [...map.values()]
.map((c) => ({
...c,
conversions: Math.round(c.conversions * 100) / 100,
cpl: c.conversions > 0 ? c.spend / c.conversions : null,
}))
.sort((a, b) => b.spend - a.spend);
}
function SparkBars({
data,
mode,
metric,
}: {
data: DailyPoint[];
mode: ChartMode;
metric: 'cpl' | 'spend';
}) {
if (!data.length) {
return (
<p className="text-sm text-gray-500 py-10 text-center">
Sin serie diaria para este rango
</p>
);
}
const values = data.map((d) => {
const spend =
mode === 'meta'
? d.metaSpend
: mode === 'google'
? d.googleSpend
: d.metaSpend + d.googleSpend;
const conv =
mode === 'meta'
? d.metaConv
: mode === 'google'
? d.googleConv
: d.metaConv + d.googleConv;
const clicks =
mode === 'meta'
? d.metaClicks
: mode === 'google'
? d.googleClicks
: d.metaClicks + d.googleClicks;
if (metric === 'cpl') {
return {
bar: conv > 0 ? spend / conv : 0,
line: conv,
date: d.date,
};
}
return { bar: spend, line: clicks, date: d.date };
});
const maxBar = Math.max(...values.map((v) => v.bar), 0.0001);
const maxLine = Math.max(...values.map((v) => v.line), 0.0001);
const W = 640;
const H = 220;
const pad = { top: 16, right: 48, bottom: 36, left: 52 };
const innerW = W - pad.left - pad.right;
const innerH = H - pad.top - pad.bottom;
const n = values.length;
const gap = n > 1 ? innerW / n : innerW;
const barW = Math.max(4, Math.min(28, gap * 0.55));
const barColor = metric === 'cpl' ? '#0ea5e9' : '#6366f1';
const lineColor = metric === 'cpl' ? '#d97706' : '#059669';
const barLabel = metric === 'cpl' ? 'CPL (€)' : 'Gasto (€)';
const lineLabel = metric === 'cpl' ? 'Conversiones' : 'Clics';
const yBar = (v: number) => pad.top + innerH - (v / maxBar) * innerH;
const yLine = (v: number) => pad.top + innerH - (v / maxLine) * innerH;
const xAt = (i: number) => pad.left + gap * i + gap / 2;
const linePoints = values
.map((v, i) => `${xAt(i).toFixed(1)},${yLine(v.line).toFixed(1)}`)
.join(' ');
const tickCount = Math.min(6, n);
const xTicks =
n <= 1
? [0]
: Array.from({ length: tickCount }, (_, i) =>
Math.round((i * (n - 1)) / (tickCount - 1))
);
const yBarTicks = [0, 0.25, 0.5, 0.75, 1].map((t) => maxBar * t);
const yLineTicks = [0, 0.5, 1].map((t) => maxLine * t);
const fmtBar = (v: number) =>
metric === 'cpl' || metric === 'spend'
? v >= 100
? v.toFixed(0)
: v.toFixed(1)
: formatNum(v);
const fmtLine = (v: number) => formatNum(v);
const fmtDate = (iso: string) => {
const p = iso.slice(5).split('-');
return p.length === 2 ? `${p[1]}/${p[0]}` : iso.slice(5);
};
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-4 text-[11px] text-gray-600 px-1">
<span className="inline-flex items-center gap-1.5">
<span
className="inline-block w-3 h-3 rounded-sm"
style={{ background: barColor }}
/>
Barras · {barLabel}
</span>
<span className="inline-flex items-center gap-1.5">
<span
className="inline-block w-4 h-0.5 rounded"
style={{ background: lineColor }}
/>
<span
className="inline-block w-2 h-2 rounded-full"
style={{ background: lineColor }}
/>
Línea · {lineLabel}
</span>
</div>
<div className="w-full overflow-x-auto">
<svg
viewBox={`0 0 ${W} ${H}`}
className="w-full min-w-[480px] h-[220px]"
role="img"
aria-label={`Gráfico ${barLabel} y ${lineLabel}`}
>
{/* Grid */}
{yBarTicks.map((t, i) => (
<line
key={`g-${i}`}
x1={pad.left}
x2={W - pad.right}
y1={yBar(t)}
y2={yBar(t)}
stroke="#e5e7eb"
strokeWidth={1}
/>
))}
{/* Bars */}
{values.map((v, i) => {
const x = xAt(i) - barW / 2;
const y = yBar(v.bar);
const h = Math.max(1, pad.top + innerH - y);
return (
<rect
key={`b-${v.date}`}
x={x}
y={y}
width={barW}
height={h}
rx={2}
fill={barColor}
opacity={0.85}
>
<title>
{v.date}: {barLabel} {fmtBar(v.bar)} · {lineLabel}{' '}
{fmtLine(v.line)}
</title>
</rect>
);
})}
{/* Line */}
{n > 1 ? (
<polyline
fill="none"
stroke={lineColor}
strokeWidth={2.5}
strokeLinejoin="round"
strokeLinecap="round"
points={linePoints}
/>
) : null}
{values.map((v, i) => (
<circle
key={`d-${v.date}`}
cx={xAt(i)}
cy={yLine(v.line)}
r={3.5}
fill={lineColor}
stroke="#fff"
strokeWidth={1.5}
>
<title>
{v.date}: {lineLabel} {fmtLine(v.line)}
</title>
</circle>
))}
{/* Y left (bars) */}
{yBarTicks.map((t, i) => (
<text
key={`yl-${i}`}
x={pad.left - 8}
y={yBar(t) + 3}
textAnchor="end"
className="fill-gray-500"
fontSize={9}
>
{fmtBar(t)}
</text>
))}
<text
x={14}
y={pad.top + innerH / 2}
textAnchor="middle"
transform={`rotate(-90 14 ${pad.top + innerH / 2})`}
className="fill-gray-500"
fontSize={9}
>
{barLabel}
</text>
{/* Y right (line) */}
{yLineTicks.map((t, i) => (
<text
key={`yr-${i}`}
x={W - pad.right + 8}
y={yLine(t) + 3}
textAnchor="start"
className="fill-gray-500"
fontSize={9}
>
{fmtLine(t)}
</text>
))}
<text
x={W - 12}
y={pad.top + innerH / 2}
textAnchor="middle"
transform={`rotate(90 ${W - 12} ${pad.top + innerH / 2})`}
className="fill-gray-500"
fontSize={9}
>
{lineLabel}
</text>
{/* X axis */}
<line
x1={pad.left}
x2={W - pad.right}
y1={pad.top + innerH}
y2={pad.top + innerH}
stroke="#d1d5db"
strokeWidth={1}
/>
{xTicks.map((idx) => (
<text
key={`x-${idx}`}
x={xAt(idx)}
y={H - 10}
textAnchor="middle"
className="fill-gray-500"
fontSize={9}
>
{fmtDate(values[idx].date)}
</text>
))}
</svg>
</div>
</div>
);
}
function CampaignTable({
title,
rows,
empty,
}: {
title: string;
rows: CampaignRow[];
empty: string;
}) {
const spendMax = Math.max(1, ...rows.map((r) => r.spend));
return (
<section className="rounded-2xl border border-gray-200 bg-white overflow-hidden shadow-sm">
<div className="px-4 py-3 border-b border-gray-100">
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-gray-500">
Campañas
</p>
<h3 className="text-base font-semibold text-gray-900 tracking-tight">
{title}
</h3>
</div>
{rows.length === 0 ? (
<p className="px-4 py-10 text-sm text-gray-500 text-center">{empty}</p>
) : (
<div className="overflow-x-auto">
<table className="min-w-full text-left text-sm">
<thead className={tableHeadClass()}>
<tr>
<th className="px-4 py-3 font-semibold">Campaña</th>
<th className="px-3 py-3 font-semibold min-w-[150px]">Gasto</th>
<th className="px-3 py-3 font-semibold">Clics</th>
<th className="px-3 py-3 font-semibold">Conv.</th>
<th className="px-3 py-3 font-semibold">CPL</th>
</tr>
</thead>
<tbody>
{rows.slice(0, 25).map((r) => (
<tr
key={r.name}
className="border-t border-gray-100 hover:bg-brand-50/40"
>
<td
className="px-4 py-2.5 text-gray-800 max-w-[240px] truncate"
title={r.name}
>
{r.name}
</td>
<td className="px-3 py-2.5">
<ShareBar
value={r.spend}
max={spendMax}
label={formatEur(r.spend)}
/>
</td>
<td className="px-3 py-2.5 text-gray-700 tabular-nums">
{formatNum(r.clicks)}
</td>
<td className="px-3 py-2.5 text-gray-700 tabular-nums">
{formatNum(r.conversions)}
</td>
<td className="px-3 py-2.5 text-gray-700 tabular-nums">
{r.cpl != null ? formatEurCents(r.cpl) : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
}
export default function AdTrackerClient({
label,
tagline,
rows,
}: {
label: string;
tagline?: string;
rows: AdDailyRow[];
}) {
const { since, until, label: rangeLabel } = useDateRange();
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 [metaClickSplit, setMetaClickSplit] = useState<{
clicks: number;
linkClicks: number;
spend: number;
} | null>(null);
const [zoomAd, setZoomAd] = useState<AdCreativeCard | null>(null);
const [rowsLive, setRowsLive] = useState<AdDailyRow[]>(rows);
const [rowsLoading, setRowsLoading] = useState(false);
const range = useMemo(() => ({ since, until, label: rangeLabel }), [
since,
until,
rangeLabel,
]);
useEffect(() => {
setRowsLive(rows);
}, [rows]);
useEffect(() => {
let cancelled = false;
async function loadRange() {
setRowsLoading(true);
try {
const qs = new URLSearchParams({
since: range.since,
until: range.until,
});
const r = await fetch(`/api/ads/daily?${qs}`, { cache: 'no-store' });
const data = await r.json().catch(() => ({}));
if (cancelled) return;
if (r.ok && Array.isArray(data.rows)) {
setRowsLive(data.rows as AdDailyRow[]);
}
} finally {
if (!cancelled) setRowsLoading(false);
}
}
void loadRange();
return () => {
cancelled = true;
};
}, [range.since, range.until]);
useEffect(() => {
if (tab === 'resumen') {
setCreatives([]);
setCreativesWarning(null);
setCreativesLoading(false);
setMetaClickSplit(null);
return;
}
let cancelled = false;
async function load() {
setCreativesLoading(true);
setCreativesWarning(null);
const platform = tab === 'meta' ? 'meta' : 'google';
const baseQs = {
since: range.since,
until: range.until,
platform,
};
let hasFast = false;
// 1) Warehouse first — instant metrics (source of truth from sync)
try {
const qs = new URLSearchParams({ ...baseQs, mode: 'fast' });
const r = await fetch(`/api/ad-tracker/creatives?${qs}`, {
cache: 'no-store',
});
const data = await r.json().catch(() => ({}));
if (cancelled) return;
if (r.ok) {
hasFast = true;
setCreatives((data.ads as AdCreativeCard[]) || []);
if (
data.metaClickSplit &&
typeof data.metaClickSplit.clicks === 'number'
) {
setMetaClickSplit(data.metaClickSplit);
} else if (platform === 'google') {
setMetaClickSplit(null);
}
setCreativesLoading(false);
}
} catch {
/* continue to live */
}
// 2) Live enrich (previews) — never blocks empty UI
try {
const qs = new URLSearchParams({ ...baseQs, mode: 'live' });
const r = await fetch(`/api/ad-tracker/creatives?${qs}`, {
cache: 'no-store',
});
const data = await r.json().catch(() => ({}));
if (cancelled) return;
if (!r.ok) {
if (!hasFast) {
setCreativesWarning(
data.hint || data.error || 'No se pudieron cargar creatividades'
);
}
return;
}
const liveAds = (data.ads as AdCreativeCard[]) || [];
if (liveAds.length) setCreatives(liveAds);
if (
data.metaClickSplit &&
typeof data.metaClickSplit.clicks === 'number'
) {
setMetaClickSplit(data.metaClickSplit);
}
const w = Array.isArray(data.warnings)
? data.warnings.filter(Boolean)
: [];
setCreativesWarning(w.length ? w.join(' · ') : null);
} catch {
if (!cancelled && !hasFast) {
setCreativesWarning('Error al cargar creatividades');
}
} finally {
if (!cancelled) setCreativesLoading(false);
}
}
void load();
return () => {
cancelled = true;
};
}, [range.since, range.until, tab]);
const filtered = useMemo(
() => rowsLive.filter((r) => inRange(r.date, range.since, range.until)),
[rowsLive, range.since, range.until]
);
const metaRows = filtered.filter((r) => normalizePlatform(r.platform) === 'meta');
const googleRows = filtered.filter(
(r) => normalizePlatform(r.platform) === 'google'
);
// rows without platform count toward combined via meta bucket already in series
const otherRows = filtered.filter(
(r) => normalizePlatform(r.platform) === 'other'
);
const meta = aggregate([...metaRows, ...otherRows]);
const google = aggregate(googleRows);
const combined = rates({
spend: meta.spend + google.spend,
impressions: meta.impressions + google.impressions,
clicks: meta.clicks + google.clicks,
conversions: meta.conversions + google.conversions,
ads: meta.ads + google.ads,
});
// Meta: clics todos (warehouse) + clics enlace (live Ads Manager o warehouse).
const metaClicksAll = metaClickSplit?.clicks ?? meta.clicks;
const metaLinkFromWarehouse = [...metaRows, ...otherRows].reduce(
(a, r) => a + linkClicksOf(r),
0
);
const metaClicksLink =
metaClickSplit?.linkClicks ??
(metaLinkFromWarehouse > 0 ? metaLinkFromWarehouse : meta.clicks);
const metaCpcAll =
metaClicksAll > 0 ? (metaClickSplit?.spend ?? meta.spend) / metaClicksAll : null;
const metaCpcLink =
metaClicksLink > 0
? (metaClickSplit?.spend ?? meta.spend) / metaClicksLink
: null;
const daily = buildSeries(filtered);
const metaCampaigns = byCampaign([...metaRows, ...otherRows]);
const googleCampaigns = byCampaign(googleRows);
const totalSpend = combined.spend || 1;
const metaShare = Math.min(100, (100 * meta.spend) / totalSpend);
const googleShare = Math.min(100, (100 * google.spend) / totalSpend);
const chips: { id: ChartMode; label: string }[] = [
{ id: 'combined', label: 'Conjunto' },
{ id: 'meta', label: 'Meta' },
{ id: 'google', label: 'Google' },
];
return (
<div className="space-y-8">
<PageHeader
eyebrow="Publicidad"
title={tagline || 'Ads'}
description={`${label} · Meta + Google · ${rangeLabel}${
rowsLoading ? ' · actualizando…' : ''
}`}
actions={<SyncNowButton forceModule="ads" />}
/>
<SegmentedControl
value={tab}
onChange={setTab}
size="sm"
options={[
{ id: 'resumen', label: 'Resumen' },
{ id: 'meta', label: 'Meta Ads' },
{ id: 'google', label: 'Google Ads' },
]}
/>
{tab === 'resumen' && (
<div className="space-y-6">
<section className="space-y-4">
<HeroBand>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6">
<HeroStat label="Inversión" value={formatEur(combined.spend)} />
<HeroStat
label="Conversiones"
value={formatNum(combined.conversions)}
/>
<HeroStat
label="CPL"
value={
combined.cpl != null ? formatEurCents(combined.cpl) : '—'
}
/>
<HeroStat
label="CTR"
value={
combined.ctr != null
? `${combined.ctr.toFixed(2)}%`
: '—'
}
/>
</div>
</HeroBand>
<HelpDetails title="Cómo leer Ads">
<p>
Gasto, impresiones, clics y conversiones se suman entre Meta y
Google. CTR, CPC y CPL se recalculan sobre el total (no se
suman porcentajes).
</p>
</HelpDetails>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<StatMini
label="Impresiones"
value={formatNum(combined.impressions)}
/>
<StatMini label="Clics" value={formatNum(combined.clicks)} />
<StatMini
label="CPC"
value={
combined.cpc != null ? formatEurCents(combined.cpc) : '—'
}
/>
<StatMini label="Ads activos" value={formatNum(combined.ads)} />
</div>
<div className="h-2.5 rounded-full overflow-hidden border border-gray-200 bg-gray-100 flex">
<div
className="bg-brand-500"
style={{ width: `${metaShare}%` }}
title={`Meta ${metaShare.toFixed(0)}%`}
/>
<div
className="bg-sky-500"
style={{ width: `${googleShare}%` }}
title={`Google ${googleShare.toFixed(0)}%`}
/>
</div>
<p className="text-[11px] text-gray-500 -mt-2">
Reparto de inversión · Meta {metaShare.toFixed(0)}% · Google{' '}
{googleShare.toFixed(0)}%
</p>
<div className="grid gap-3 md:grid-cols-2">
<button
type="button"
onClick={() => setTab('meta')}
className="rounded-xl border border-gray-200 bg-white p-4 text-left hover:border-brand-400 hover:bg-brand-50/40 transition-colors"
>
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-brand-700">
Meta Ads
</p>
<p className="text-xl font-semibold text-gray-900 mt-1 tabular-nums tracking-tight">
{formatEur(meta.spend)}
</p>
<p className="text-xs text-gray-500 mt-1">
{formatNum(meta.ads)} ads · {formatNum(meta.clicks)} clics ·{' '}
{formatNum(meta.conversions)} conv. · Ver Meta →
</p>
</button>
<button
type="button"
onClick={() => setTab('google')}
className="rounded-xl border border-gray-200 bg-white p-4 text-left hover:border-sky-500/50"
>
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-sky-700">
Google Ads
</p>
<p className="text-xl font-semibold text-gray-900 mt-1 tabular-nums tracking-tight">
{formatEur(google.spend)}
</p>
<p className="text-xs text-gray-500 mt-1">
{formatNum(google.ads)} ads · {formatNum(google.clicks)} clics
· {formatNum(google.conversions)} conv. · Ver Google →
</p>
</button>
</div>
</section>
<section className="rounded-2xl border border-gray-200 bg-white p-4 md:p-5">
<div className="flex flex-wrap items-start justify-between gap-3 mb-3">
<div>
<p className="text-[10px] uppercase tracking-wider text-gray-500">
Rendimiento diario
</p>
<h3 className="text-lg font-bold text-gray-900">
Conversiones y CPL
</h3>
<p className="text-xs text-gray-500 mt-1">
Barras = {`CPL (€)`} (eje izq.). Línea = conversiones (eje
der.). Fechas en eje X. CPL periodo:{' '}
{combined.cpl != null ? formatEurCents(combined.cpl) : '—'}
</p>
</div>
<div className="flex flex-wrap gap-1.5">
{chips.map((c) => (
<button
key={c.id}
type="button"
onClick={() => setChartMode(c.id)}
className={`px-3 py-1 rounded-full text-[11px] font-medium ${
chartMode === c.id
? 'bg-brand-500 text-white'
: 'border border-gray-200 text-gray-600'
}`}
>
{c.label}
</button>
))}
</div>
</div>
<SparkBars data={daily} mode={chartMode} metric="cpl" />
</section>
<section className="rounded-2xl border border-gray-200 bg-white p-4 md:p-5">
<div className="flex flex-wrap items-start justify-between gap-3 mb-3">
<div>
<p className="text-[10px] uppercase tracking-wider text-gray-500">
Rendimiento diario
</p>
<h3 className="text-lg font-bold text-gray-900">Coste y clics</h3>
<p className="text-xs text-gray-500 mt-1">
Barras = gasto (€) (eje izq.). Línea = clics (eje der.). Fechas
en eje X. {formatEur(combined.spend)} ·{' '}
{formatNum(combined.clicks)} clics
</p>
</div>
<div className="flex flex-wrap gap-1.5">
{chips.map((c) => (
<button
key={`spend-${c.id}`}
type="button"
onClick={() => setChartMode(c.id)}
className={`px-3 py-1 rounded-full text-[11px] font-medium ${
chartMode === c.id
? 'bg-brand-500 text-white'
: 'border border-gray-200 text-gray-600'
}`}
>
{c.label}
</button>
))}
</div>
</div>
<SparkBars data={daily} mode={chartMode} metric="spend" />
</section>
<div className="grid gap-4 lg:grid-cols-2">
<CampaignTable
title="Meta Ads"
rows={metaCampaigns}
empty="Sin campañas Meta activas en este periodo"
/>
<CampaignTable
title="Google Ads"
rows={googleCampaigns}
empty="Sin campañas Google activas en este periodo"
/>
</div>
</div>
)}
{tab === 'meta' && (
<div className="space-y-6">
<HeroBand eyebrow="Meta Ads">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6">
<HeroStat label="Inversión" value={formatEur(meta.spend)} />
<HeroStat
label="Conversiones"
value={formatNum(meta.conversions)}
/>
<HeroStat
label="CPL"
value={meta.cpl != null ? formatEurCents(meta.cpl) : '—'}
/>
<HeroStat
label="CTR"
value={
meta.impressions > 0
? `${((100 * metaClicksAll) / meta.impressions).toFixed(2)}%`
: '—'
}
/>
</div>
</HeroBand>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
<StatMini
label="Impresiones"
value={formatNum(meta.impressions)}
/>
<StatMini label="Clics (todos)" value={formatNum(metaClicksAll)} />
<StatMini
label="Clics (enlace)"
value={formatNum(metaClicksLink)}
/>
<StatMini
label="CPC (todos)"
value={metaCpcAll != null ? formatEurCents(metaCpcAll) : '—'}
/>
<StatMini
label="CPC (enlace)"
value={metaCpcLink != null ? formatEurCents(metaCpcLink) : '—'}
/>
<StatMini label="Ads activos" value={formatNum(meta.ads)} />
</div>
<CreativesGallery
platform="meta"
ads={creatives.filter(
(c) =>
c.platform === 'meta' &&
(c.spend > 0 || c.impressions > 0 || c.clicks > 0 || c.conversions > 0)
)}
loading={creativesLoading}
warning={creativesWarning}
onZoom={setZoomAd}
/>
<section className="rounded-2xl border border-gray-200 bg-white p-4">
<h3 className="text-lg font-bold text-gray-900 mb-3">
Conversiones y CPL · Meta
</h3>
<SparkBars data={daily} mode="meta" metric="cpl" />
</section>
<section className="rounded-2xl border border-gray-200 bg-white p-4">
<h3 className="text-lg font-bold text-gray-900 mb-3">
Coste y clics · Meta
</h3>
<SparkBars data={daily} mode="meta" metric="spend" />
</section>
<CampaignTable
title="Campañas Meta con rendimiento"
rows={metaCampaigns}
empty="Sin campañas Meta con actividad en este periodo"
/>
</div>
)}
{tab === 'google' && (
<div className="space-y-6">
<HeroBand eyebrow="Google Ads">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6">
<HeroStat label="Inversión" value={formatEur(google.spend)} />
<HeroStat
label="Conversiones"
value={formatNum(google.conversions)}
/>
<HeroStat
label="CPL"
value={google.cpl != null ? formatEurCents(google.cpl) : '—'}
/>
<HeroStat
label="CTR"
value={
google.ctr != null ? `${google.ctr.toFixed(2)}%` : '—'
}
/>
</div>
</HeroBand>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<StatMini
label="Impresiones"
value={formatNum(google.impressions)}
/>
<StatMini label="Clics" value={formatNum(google.clicks)} />
<StatMini
label="CPC"
value={google.cpc != null ? formatEurCents(google.cpc) : '—'}
/>
<StatMini label="Ads activos" value={formatNum(google.ads)} />
</div>
<CreativesGallery
platform="google"
ads={creatives.filter(
(c) =>
c.platform === 'google' &&
(c.spend > 0 || c.impressions > 0 || c.clicks > 0 || c.conversions > 0)
)}
loading={creativesLoading}
warning={creativesWarning}
onZoom={setZoomAd}
/>
<section className="rounded-2xl border border-gray-200 bg-white p-4">
<h3 className="text-lg font-bold text-gray-900 mb-3">
Conversiones y CPL · Google
</h3>
<SparkBars data={daily} mode="google" metric="cpl" />
</section>
<section className="rounded-2xl border border-gray-200 bg-white p-4">
<h3 className="text-lg font-bold text-gray-900 mb-3">
Coste y clics · Google
</h3>
<SparkBars data={daily} mode="google" metric="spend" />
</section>
<CampaignTable
title="Campañas Google con rendimiento"
rows={googleCampaigns}
empty="Sin campañas Google con actividad en este periodo"
/>
</div>
)}
{zoomAd ? (
<CreativeZoomModal ad={zoomAd} onClose={() => setZoomAd(null)} />
) : null}
</div>
);
}