fix: Ads CPC con céntimos + clics enlace Meta; gráficas barras/línea con ejes

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-29 01:28:59 +02:00
parent 5e8fd66e35
commit c83efa492d
4 changed files with 254 additions and 47 deletions

View File

@@ -2,7 +2,7 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { normalizePlatform } from '@/lib/ad-tracker-range'; import { normalizePlatform } from '@/lib/ad-tracker-range';
import { AdDailyRow, formatEur, formatNum } from '@/lib/metrics'; import { AdDailyRow, formatEur, formatEurCents, formatNum } from '@/lib/metrics';
import type { AdCreativeCard } from '@/lib/ad-creatives-meta'; import type { AdCreativeCard } from '@/lib/ad-creatives-meta';
import { import {
CreativesGallery, CreativesGallery,
@@ -212,44 +212,227 @@ function SparkBars({
? d.googleClicks ? d.googleClicks
: d.metaClicks + d.googleClicks; : d.metaClicks + d.googleClicks;
if (metric === 'cpl') { if (metric === 'cpl') {
return { bar: conv > 0 ? spend / conv : 0, line: conv, spend, clicks }; return {
bar: conv > 0 ? spend / conv : 0,
line: conv,
date: d.date,
};
} }
return { bar: spend, line: clicks, spend, clicks }; return { bar: spend, line: clicks, date: d.date };
}); });
const maxBar = Math.max(...values.map((v) => v.bar), 1); const maxBar = Math.max(...values.map((v) => v.bar), 0.0001);
const maxLine = Math.max(...values.map((v) => v.line), 1); 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 ( return (
<div className="space-y-2"> <div className="space-y-3">
<div className="flex items-end gap-1 h-40 px-1"> <div className="flex flex-wrap items-center gap-4 text-[11px] text-gray-600 px-1">
{values.map((v, i) => ( <span className="inline-flex items-center gap-1.5">
<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 <span
className="absolute -top-1 left-1/2 -translate-x-1/2 w-1.5 h-1.5 rounded-full bg-amber-300" className="inline-block w-3 h-3 rounded-sm"
style={{ style={{ background: barColor }}
bottom: `${Math.max(0, (100 * v.line) / maxLine)}%`,
}}
/> />
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>
</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}
/>
))} ))}
</div>
<div className="flex justify-between text-[10px] text-gray-500 px-1"> {/* Bars */}
<span>{data[0]?.date.slice(5)}</span> {values.map((v, i) => {
<span>{data[data.length - 1]?.date.slice(5)}</span> 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>
</div> </div>
); );
@@ -317,7 +500,7 @@ function CampaignTable({
{formatNum(r.conversions)} {formatNum(r.conversions)}
</td> </td>
<td className="px-3 py-2.5 text-gray-600 tabular-nums"> <td className="px-3 py-2.5 text-gray-600 tabular-nums">
{r.cpl != null ? formatEur(r.cpl) : '—'} {r.cpl != null ? formatEurCents(r.cpl) : '—'}
</td> </td>
</tr> </tr>
))} ))}
@@ -491,7 +674,7 @@ export default function AdTrackerClient({
label="Impresiones" label="Impresiones"
value={formatNum(combined.impressions)} value={formatNum(combined.impressions)}
/> />
<KpiTile label="Clics" value={formatNum(combined.clicks)} /> <KpiTile label="Clics (enlace)" value={formatNum(combined.clicks)} />
<KpiTile <KpiTile
label="Conversiones" label="Conversiones"
value={formatNum(combined.conversions)} value={formatNum(combined.conversions)}
@@ -504,11 +687,11 @@ export default function AdTrackerClient({
/> />
<KpiTile <KpiTile
label="CPC" label="CPC"
value={combined.cpc != null ? formatEur(combined.cpc) : '—'} value={combined.cpc != null ? formatEurCents(combined.cpc) : '—'}
/> />
<KpiTile <KpiTile
label="CPL" label="CPL"
value={combined.cpl != null ? formatEur(combined.cpl) : '—'} value={combined.cpl != null ? formatEurCents(combined.cpl) : '—'}
/> />
<KpiTile label="Ads" value={formatNum(combined.ads)} /> <KpiTile label="Ads" value={formatNum(combined.ads)} />
</div> </div>
@@ -572,8 +755,9 @@ export default function AdTrackerClient({
Conversiones y CPL Conversiones y CPL
</h3> </h3>
<p className="text-xs text-gray-500 mt-1"> <p className="text-xs text-gray-500 mt-1">
Barras: coste por lead. Línea: conversiones. CPL{' '} Barras = {`CPL (€)`} (eje izq.). Línea = conversiones (eje
{combined.cpl != null ? formatEur(combined.cpl) : '—'} der.). Fechas en eje X. CPL periodo:{' '}
{combined.cpl != null ? formatEurCents(combined.cpl) : '—'}
</p> </p>
</div> </div>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
@@ -604,7 +788,8 @@ export default function AdTrackerClient({
</p> </p>
<h3 className="text-lg font-bold text-gray-900">Coste y clics</h3> <h3 className="text-lg font-bold text-gray-900">Coste y clics</h3>
<p className="text-xs text-gray-500 mt-1"> <p className="text-xs text-gray-500 mt-1">
Barras: coste. Línea: clics. {formatEur(combined.spend)} ·{' '} Barras = gasto (€) (eje izq.). Línea = clics (eje der.). Fechas
en eje X. {formatEur(combined.spend)} ·{' '}
{formatNum(combined.clicks)} clics {formatNum(combined.clicks)} clics
</p> </p>
</div> </div>
@@ -651,7 +836,7 @@ export default function AdTrackerClient({
label="Impresiones" label="Impresiones"
value={formatNum(meta.impressions)} value={formatNum(meta.impressions)}
/> />
<KpiTile label="Clics" value={formatNum(meta.clicks)} /> <KpiTile label="Clics (enlace)" value={formatNum(meta.clicks)} />
<KpiTile <KpiTile
label="Conversiones" label="Conversiones"
value={formatNum(meta.conversions)} value={formatNum(meta.conversions)}
@@ -662,11 +847,11 @@ export default function AdTrackerClient({
/> />
<KpiTile <KpiTile
label="CPC" label="CPC"
value={meta.cpc != null ? formatEur(meta.cpc) : '—'} value={meta.cpc != null ? formatEurCents(meta.cpc) : '—'}
/> />
<KpiTile <KpiTile
label="CPL" label="CPL"
value={meta.cpl != null ? formatEur(meta.cpl) : '—'} value={meta.cpl != null ? formatEurCents(meta.cpl) : '—'}
/> />
<KpiTile label="Ads activos" value={formatNum(meta.ads)} /> <KpiTile label="Ads activos" value={formatNum(meta.ads)} />
</div> </div>
@@ -720,11 +905,11 @@ export default function AdTrackerClient({
/> />
<KpiTile <KpiTile
label="CPC" label="CPC"
value={google.cpc != null ? formatEur(google.cpc) : '—'} value={google.cpc != null ? formatEurCents(google.cpc) : '—'}
/> />
<KpiTile <KpiTile
label="CPL" label="CPL"
value={google.cpl != null ? formatEur(google.cpl) : '—'} value={google.cpl != null ? formatEurCents(google.cpl) : '—'}
/> />
<KpiTile label="Ads activos" value={formatNum(google.ads)} /> <KpiTile label="Ads activos" value={formatNum(google.ads)} />
</div> </div>

View File

@@ -2,7 +2,7 @@
import { serviceHeaders, supabaseUrl } from '@/lib/agency'; import { serviceHeaders, supabaseUrl } from '@/lib/agency';
import { resolveMetaAccessToken } from '@/lib/ads-discovery'; import { resolveMetaAccessToken } from '@/lib/ads-discovery';
import { leadFromMetaActions, nMeta } from '@/lib/meta-leads'; import { leadFromMetaActions, linkClicksFromMetaActions, nMeta } from '@/lib/meta-leads';
import { resolveGoogleAdsAccessToken } from '@/lib/google-ads-auth'; import { resolveGoogleAdsAccessToken } from '@/lib/google-ads-auth';
const META_GRAPH = 'https://graph.facebook.com/v21.0'; const META_GRAPH = 'https://graph.facebook.com/v21.0';
@@ -155,7 +155,8 @@ async function fetchMetaInsightsWindow(
ad_id: campaignId || '', ad_id: campaignId || '',
ad_name: '', ad_name: '',
impressions: n(row.impressions), impressions: n(row.impressions),
clicks: n(row.clicks), // Store link clicks so CPC = spend/clicks matches Ads Manager.
clicks: linkClicksFromMetaActions(row.actions, n(row.clicks)),
spend: Math.round(n(row.spend) * 100) / 100, spend: Math.round(n(row.spend) * 100) / 100,
platform_leads: leadFromMetaActions(row.actions), platform_leads: leadFromMetaActions(row.actions),
}); });

View File

@@ -18,3 +18,16 @@ export function leadFromMetaActions(
); );
return primary ? nMeta(primary.value) : 0; return primary ? nMeta(primary.value) : 0;
} }
/**
* Ads Manager CPC uses «clics en el enlace» (link_click), not all clicks.
* Fall back to all clicks when link_click is absent.
*/
export function linkClicksFromMetaActions(
actions: { action_type?: string; value?: string }[] | undefined,
allClicks: number
): number {
if (!actions?.length) return allClicks;
const link = actions.find((a) => a.action_type === 'link_click');
return link ? nMeta(link.value) : allClicks;
}

View File

@@ -315,6 +315,14 @@ export function formatEur(v: number): string {
})} €`; })} €`;
} }
/** CPC / CPL — always show cents like Ads Manager. */
export function formatEurCents(v: number): string {
return `${v.toLocaleString('es-ES', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})} €`;
}
export function formatPct(v: number | null): string { export function formatPct(v: number | null): string {
if (v === null || Number.isNaN(v)) return '—'; if (v === null || Number.isNaN(v)) return '—';
const sign = v > 0 ? '+' : ''; const sign = v > 0 ? '+' : '';