996 lines
32 KiB
TypeScript
996 lines
32 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 } from '@/components/ui';
|
|
import { useDateRange } from '@/components/DateRangeContext';
|
|
|
|
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 {
|
|
return Number(r.conversions ?? r.platform_leads ?? 0) || 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 KpiTile({
|
|
label,
|
|
value,
|
|
}: {
|
|
label: string;
|
|
value: string;
|
|
}) {
|
|
return (
|
|
<div className="rounded-xl border border-gray-200 bg-white px-3 py-3">
|
|
<p className="text-[10px] uppercase tracking-wider text-gray-500">{label}</p>
|
|
<p className="mt-1 text-xl font-bold text-gray-900 tabular-nums">{value}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CampaignTable({
|
|
title,
|
|
rows,
|
|
empty,
|
|
}: {
|
|
title: string;
|
|
rows: CampaignRow[];
|
|
empty: string;
|
|
}) {
|
|
return (
|
|
<section className="rounded-2xl border border-gray-200 bg-white overflow-hidden">
|
|
<div className="px-4 py-3 border-b border-gray-200">
|
|
<p className="text-[10px] uppercase tracking-wider text-gray-500">
|
|
Campañas activas
|
|
</p>
|
|
<h3 className="text-lg font-bold text-gray-900">{title}</h3>
|
|
</div>
|
|
{rows.length === 0 ? (
|
|
<p className="px-4 py-8 text-sm text-gray-500">{empty}</p>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="min-w-full text-left text-sm">
|
|
<thead className="text-[10px] uppercase tracking-wider text-gray-500 bg-gray-50">
|
|
<tr>
|
|
<th className="px-4 py-2 font-medium">Campaña</th>
|
|
<th className="px-3 py-2 font-medium">Gasto</th>
|
|
<th className="px-3 py-2 font-medium">Clics</th>
|
|
<th className="px-3 py-2 font-medium">Conv.</th>
|
|
<th className="px-3 py-2 font-medium">CPL</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.slice(0, 25).map((r) => (
|
|
<tr key={r.name} className="border-t border-gray-200">
|
|
<td className="px-4 py-2.5 text-gray-800 max-w-[220px] truncate">
|
|
{r.name}
|
|
</td>
|
|
<td className="px-3 py-2.5 text-gray-900 tabular-nums">
|
|
{formatEur(r.spend)}
|
|
</td>
|
|
<td className="px-3 py-2.5 text-gray-600 tabular-nums">
|
|
{formatNum(r.clicks)}
|
|
</td>
|
|
<td className="px-3 py-2.5 text-gray-600 tabular-nums">
|
|
{formatNum(r.conversions)}
|
|
</td>
|
|
<td className="px-3 py-2.5 text-gray-600 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 range = useMemo(() => ({ since, until, label: rangeLabel }), [
|
|
since,
|
|
until,
|
|
rangeLabel,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (tab === 'resumen') {
|
|
setCreatives([]);
|
|
setCreativesWarning(null);
|
|
setCreativesLoading(false);
|
|
setMetaClickSplit(null);
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
async function load() {
|
|
setCreativesLoading(true);
|
|
setCreativesWarning(null);
|
|
try {
|
|
const platform = tab === 'meta' ? 'meta' : 'google';
|
|
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([]);
|
|
setMetaClickSplit(null);
|
|
setCreativesWarning(data.hint || data.error || 'No se pudieron cargar creatividades');
|
|
return;
|
|
}
|
|
setCreatives((data.ads as AdCreativeCard[]) || []);
|
|
if (
|
|
data.metaClickSplit &&
|
|
typeof data.metaClickSplit.clicks === 'number'
|
|
) {
|
|
setMetaClickSplit(data.metaClickSplit);
|
|
} else if (platform === 'google') {
|
|
setMetaClickSplit(null);
|
|
}
|
|
const w = Array.isArray(data.warnings) ? data.warnings.filter(Boolean) : [];
|
|
setCreativesWarning(w.length ? w.join(' · ') : null);
|
|
} catch {
|
|
if (!cancelled) {
|
|
setCreatives([]);
|
|
setMetaClickSplit(null);
|
|
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]
|
|
);
|
|
|
|
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-6">
|
|
<PageHeader
|
|
eyebrow={`Ads · ${label}`}
|
|
title={tagline || 'Meta + Google Ads'}
|
|
description={`Gasto, clics, conversiones y creatividades. Periodo del header: ${rangeLabel}.`}
|
|
/>
|
|
|
|
{/* Tabs */}
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{(
|
|
[
|
|
{ id: 'resumen', label: 'Resumen' },
|
|
{ id: 'meta', label: 'Meta Ads' },
|
|
{ id: 'google', label: 'Google Ads' },
|
|
] as const
|
|
).map((t) => (
|
|
<button
|
|
key={t.id}
|
|
type="button"
|
|
onClick={() => setTab(t.id)}
|
|
className={`px-3 py-1.5 rounded-lg text-xs font-semibold ${
|
|
tab === t.id
|
|
? 'bg-gray-900 text-white'
|
|
: 'bg-gray-100 text-gray-600'
|
|
}`}
|
|
>
|
|
{t.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{tab === 'resumen' && (
|
|
<div className="space-y-6">
|
|
<section className="space-y-4">
|
|
<div>
|
|
<p className="text-[10px] uppercase tracking-wider text-gray-500">
|
|
Resumen de cuenta
|
|
</p>
|
|
<h3 className="text-xl font-bold text-gray-900 mt-1">
|
|
Meta + Google, juntos
|
|
</h3>
|
|
<p className="text-xs text-gray-500 mt-1 max-w-3xl">
|
|
Gasto, impresiones, clics y conversiones sumados. CTR, CPC y CPL
|
|
se recalculan sobre el total (no se suman). Ventana:{' '}
|
|
{range.since} → {range.until}.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
|
<KpiTile label="Gasto" value={formatEur(combined.spend)} />
|
|
<KpiTile
|
|
label="Impresiones"
|
|
value={formatNum(combined.impressions)}
|
|
/>
|
|
<KpiTile label="Clics" value={formatNum(combined.clicks)} />
|
|
<KpiTile
|
|
label="Conversiones"
|
|
value={formatNum(combined.conversions)}
|
|
/>
|
|
<KpiTile
|
|
label="CTR"
|
|
value={
|
|
combined.ctr != null ? `${combined.ctr.toFixed(2)}%` : '—'
|
|
}
|
|
/>
|
|
<KpiTile
|
|
label="CPC"
|
|
value={combined.cpc != null ? formatEurCents(combined.cpc) : '—'}
|
|
/>
|
|
<KpiTile
|
|
label="CPL"
|
|
value={combined.cpl != null ? formatEurCents(combined.cpl) : '—'}
|
|
/>
|
|
<KpiTile label="Ads" value={formatNum(combined.ads)} />
|
|
</div>
|
|
|
|
<div className="h-2.5 rounded-full overflow-hidden border border-gray-200 bg-gray-900 flex">
|
|
<div
|
|
className="bg-[#7C66B8]"
|
|
style={{ width: `${metaShare}%` }}
|
|
title={`Meta ${metaShare.toFixed(0)}%`}
|
|
/>
|
|
<div
|
|
className="bg-sky-500"
|
|
style={{ width: `${googleShare}%` }}
|
|
title={`Google ${googleShare.toFixed(0)}%`}
|
|
/>
|
|
</div>
|
|
|
|
<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-[#7C66B8]/50"
|
|
>
|
|
<p className="text-[11px] uppercase tracking-wider text-[#a78bfa]">
|
|
Meta Ads
|
|
</p>
|
|
<p className="text-xl font-bold text-gray-900 mt-1">
|
|
{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] uppercase tracking-wider text-sky-400">
|
|
Google Ads
|
|
</p>
|
|
<p className="text-xl font-bold text-gray-900 mt-1">
|
|
{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">
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
|
<KpiTile label="Gasto" value={formatEur(meta.spend)} />
|
|
<KpiTile
|
|
label="Impresiones"
|
|
value={formatNum(meta.impressions)}
|
|
/>
|
|
<KpiTile label="Clics (todos)" value={formatNum(metaClicksAll)} />
|
|
<KpiTile
|
|
label="Clics (enlace)"
|
|
value={formatNum(metaClicksLink)}
|
|
/>
|
|
<KpiTile
|
|
label="Conversiones"
|
|
value={formatNum(meta.conversions)}
|
|
/>
|
|
<KpiTile
|
|
label="CTR"
|
|
value={
|
|
meta.impressions > 0
|
|
? `${((100 * metaClicksAll) / meta.impressions).toFixed(2)}%`
|
|
: '—'
|
|
}
|
|
/>
|
|
<KpiTile
|
|
label="CPC (todos)"
|
|
value={metaCpcAll != null ? formatEurCents(metaCpcAll) : '—'}
|
|
/>
|
|
<KpiTile
|
|
label="CPC (enlace)"
|
|
value={metaCpcLink != null ? formatEurCents(metaCpcLink) : '—'}
|
|
/>
|
|
<KpiTile
|
|
label="CPL"
|
|
value={meta.cpl != null ? formatEurCents(meta.cpl) : '—'}
|
|
/>
|
|
<KpiTile 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">
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
|
<KpiTile label="Gasto" value={formatEur(google.spend)} />
|
|
<KpiTile
|
|
label="Impresiones"
|
|
value={formatNum(google.impressions)}
|
|
/>
|
|
<KpiTile label="Clics" value={formatNum(google.clicks)} />
|
|
<KpiTile
|
|
label="Conversiones"
|
|
value={formatNum(google.conversions)}
|
|
/>
|
|
<KpiTile
|
|
label="CTR"
|
|
value={google.ctr != null ? `${google.ctr.toFixed(2)}%` : '—'}
|
|
/>
|
|
<KpiTile
|
|
label="CPC"
|
|
value={google.cpc != null ? formatEurCents(google.cpc) : '—'}
|
|
/>
|
|
<KpiTile
|
|
label="CPL"
|
|
value={google.cpl != null ? formatEurCents(google.cpl) : '—'}
|
|
/>
|
|
<KpiTile 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>
|
|
);
|
|
}
|