feat: RRSS hub estilo Miia + Ad Tracker por cliente
Replica agua-miia/rrss (hub, archivo, octubre 2026 con 27 piezas) y añade Ad Tracker por tenant al estilo Brava (periodos, Meta+Google, CPL/clics, campañas). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
663
src/components/AdTrackerClient.tsx
Normal file
663
src/components/AdTrackerClient.tsx
Normal file
@@ -0,0 +1,663 @@
|
||||
'use client';
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
AD_TRACKER_PRESETS,
|
||||
RangePreset,
|
||||
normalizePlatform,
|
||||
resolveAdRange,
|
||||
} from '@/lib/ad-tracker-range';
|
||||
import { AdDailyRow, formatEur, formatNum } from '@/lib/metrics';
|
||||
|
||||
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 spend = rows.reduce((a, r) => a + Number(r.spend || 0), 0);
|
||||
const impressions = rows.reduce((a, r) => a + Number(r.impressions || 0), 0);
|
||||
const clicks = rows.reduce((a, r) => a + Number(r.clicks || 0), 0);
|
||||
const conversions = rows.reduce((a, r) => a + convOf(r), 0);
|
||||
const ads = new Set(
|
||||
rows.map((r) => campaignName(r)).filter((n) => n && n !== 'Sin nombre')
|
||||
).size;
|
||||
return rates({ spend, impressions, clicks, conversions, ads });
|
||||
}
|
||||
|
||||
function byCampaign(rows: AdDailyRow[]): CampaignRow[] {
|
||||
const map = new Map<string, CampaignRow>();
|
||||
for (const r of rows) {
|
||||
const name = campaignName(r);
|
||||
const cur =
|
||||
map.get(name) ||
|
||||
({
|
||||
name,
|
||||
spend: 0,
|
||||
impressions: 0,
|
||||
clicks: 0,
|
||||
conversions: 0,
|
||||
cpl: null,
|
||||
} satisfies CampaignRow);
|
||||
cur.spend += Number(r.spend || 0);
|
||||
cur.impressions += Number(r.impressions || 0);
|
||||
cur.clicks += Number(r.clicks || 0);
|
||||
cur.conversions += convOf(r);
|
||||
map.set(name, cur);
|
||||
}
|
||||
return [...map.values()]
|
||||
.map((c) => ({
|
||||
...c,
|
||||
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, spend, clicks };
|
||||
}
|
||||
return { bar: spend, line: clicks, spend, clicks };
|
||||
});
|
||||
|
||||
const maxBar = Math.max(...values.map((v) => v.bar), 1);
|
||||
const maxLine = Math.max(...values.map((v) => v.line), 1);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-end gap-1 h-40 px-1">
|
||||
{values.map((v, i) => (
|
||||
<div
|
||||
key={data[i].date}
|
||||
className="flex-1 flex flex-col items-center justify-end gap-1 min-w-0 h-full"
|
||||
title={`${data[i].date}: ${
|
||||
metric === 'cpl'
|
||||
? `CPL ${v.bar ? formatEur(v.bar) : '—'} · ${formatNum(v.line)} conv`
|
||||
: `${formatEur(v.bar)} · ${formatNum(v.line)} clics`
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="w-full rounded-t bg-sky-500/70 relative"
|
||||
style={{ height: `${Math.max(4, (100 * v.bar) / maxBar)}%` }}
|
||||
>
|
||||
<span
|
||||
className="absolute -top-1 left-1/2 -translate-x-1/2 w-1.5 h-1.5 rounded-full bg-amber-300"
|
||||
style={{
|
||||
bottom: `${Math.max(0, (100 * v.line) / maxLine)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] text-gray-500 px-1">
|
||||
<span>{data[0]?.date.slice(5)}</span>
|
||||
<span>{data[data.length - 1]?.date.slice(5)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KpiTile({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-800 bg-[#0D121F] 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-white tabular-nums">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CampaignTable({
|
||||
title,
|
||||
rows,
|
||||
empty,
|
||||
}: {
|
||||
title: string;
|
||||
rows: CampaignRow[];
|
||||
empty: string;
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-2xl border border-gray-800 bg-[#111827] overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-gray-800">
|
||||
<p className="text-[10px] uppercase tracking-wider text-gray-500">
|
||||
Campañas activas
|
||||
</p>
|
||||
<h3 className="text-lg font-bold text-white">{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-black/20">
|
||||
<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-800/80">
|
||||
<td className="px-4 py-2.5 text-gray-200 max-w-[220px] truncate">
|
||||
{r.name}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-white tabular-nums">
|
||||
{formatEur(r.spend)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-gray-300 tabular-nums">
|
||||
{formatNum(r.clicks)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-gray-300 tabular-nums">
|
||||
{formatNum(r.conversions)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-gray-300 tabular-nums">
|
||||
{r.cpl != null ? formatEur(r.cpl) : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdTrackerClient({
|
||||
label,
|
||||
tagline,
|
||||
rows,
|
||||
}: {
|
||||
label: string;
|
||||
tagline?: string;
|
||||
rows: AdDailyRow[];
|
||||
}) {
|
||||
const [preset, setPreset] = useState<RangePreset>('last_7d');
|
||||
const [tab, setTab] = useState<Tab>('resumen');
|
||||
const [chartMode, setChartMode] = useState<ChartMode>('combined');
|
||||
|
||||
const range = useMemo(() => resolveAdRange(preset), [preset]);
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
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">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-bold tracking-[0.16em] uppercase text-sky-400">
|
||||
Ad tracker · {label}
|
||||
</p>
|
||||
<h2 className="text-2xl md:text-3xl font-extrabold text-white mt-1">
|
||||
{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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Period presets */}
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-gray-500 mb-2">
|
||||
Periodo
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{AD_TRACKER_PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => setPreset(p.id)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-semibold ${
|
||||
preset === p.id
|
||||
? 'bg-brand-500 text-white'
|
||||
: 'bg-gray-800/70 text-gray-300 hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
<span className="px-3 py-1.5 rounded-lg text-xs font-medium border border-gray-700 text-gray-300">
|
||||
{range.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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-white text-gray-900'
|
||||
: 'bg-gray-800/70 text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{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-white 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 ? formatEur(combined.cpc) : '—'}
|
||||
/>
|
||||
<KpiTile
|
||||
label="CPL"
|
||||
value={combined.cpl != null ? formatEur(combined.cpl) : '—'}
|
||||
/>
|
||||
<KpiTile label="Ads" value={formatNum(combined.ads)} />
|
||||
</div>
|
||||
|
||||
<div className="h-2.5 rounded-full overflow-hidden border border-gray-800 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-800 bg-[#0D121F] 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-white 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-800 bg-[#0D121F] 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-white 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-800 bg-[#111827] 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-white">
|
||||
Conversiones y CPL
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Barras: coste por lead. Línea: conversiones. CPL{' '}
|
||||
{combined.cpl != null ? formatEur(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-700 text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<SparkBars data={daily} mode={chartMode} metric="cpl" />
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-gray-800 bg-[#111827] 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-white">Coste y clics</h3>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Barras: coste. Línea: clics. {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-700 text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{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="Clics" value={formatNum(meta.clicks)} />
|
||||
<KpiTile label="Leads" value={formatNum(meta.conversions)} />
|
||||
<KpiTile
|
||||
label="CPL"
|
||||
value={meta.cpl != null ? formatEur(meta.cpl) : '—'}
|
||||
/>
|
||||
</div>
|
||||
<section className="rounded-2xl border border-gray-800 bg-[#111827] p-4">
|
||||
<h3 className="text-lg font-bold text-white mb-3">
|
||||
Conversiones y CPL · Meta
|
||||
</h3>
|
||||
<SparkBars data={daily} mode="meta" metric="cpl" />
|
||||
</section>
|
||||
<section className="rounded-2xl border border-gray-800 bg-[#111827] p-4">
|
||||
<h3 className="text-lg font-bold text-white mb-3">
|
||||
Coste y clics · Meta
|
||||
</h3>
|
||||
<SparkBars data={daily} mode="meta" metric="spend" />
|
||||
</section>
|
||||
<CampaignTable
|
||||
title="Meta Ads"
|
||||
rows={metaCampaigns}
|
||||
empty="Sin campañas Meta activas 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="Clics" value={formatNum(google.clicks)} />
|
||||
<KpiTile label="Conv." value={formatNum(google.conversions)} />
|
||||
<KpiTile
|
||||
label="CPL"
|
||||
value={google.cpl != null ? formatEur(google.cpl) : '—'}
|
||||
/>
|
||||
</div>
|
||||
<section className="rounded-2xl border border-gray-800 bg-[#111827] p-4">
|
||||
<h3 className="text-lg font-bold text-white mb-3">
|
||||
Conversiones y CPL · Google
|
||||
</h3>
|
||||
<SparkBars data={daily} mode="google" metric="cpl" />
|
||||
</section>
|
||||
<section className="rounded-2xl border border-gray-800 bg-[#111827] p-4">
|
||||
<h3 className="text-lg font-bold text-white mb-3">
|
||||
Coste y clics · Google
|
||||
</h3>
|
||||
<SparkBars data={daily} mode="google" metric="spend" />
|
||||
</section>
|
||||
<CampaignTable
|
||||
title="Google Ads"
|
||||
rows={googleCampaigns}
|
||||
empty="Sin campañas Google activas en este periodo"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user