diff --git a/src/components/AdTrackerClient.tsx b/src/components/AdTrackerClient.tsx
index abd3dfe..810ba02 100644
--- a/src/components/AdTrackerClient.tsx
+++ b/src/components/AdTrackerClient.tsx
@@ -2,7 +2,7 @@
import React, { useEffect, useMemo, useState } from 'react';
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 {
CreativesGallery,
@@ -212,44 +212,227 @@ function SparkBars({
? d.googleClicks
: d.metaClicks + d.googleClicks;
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 maxLine = Math.max(...values.map((v) => v.line), 1);
+ 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 (
-
-
- {values.map((v, i) => (
-
- ))}
+
+
+
+
+ Barras · {barLabel}
+
+
+
+
+ Línea · {lineLabel}
+
-
-
{data[0]?.date.slice(5)}
-
{data[data.length - 1]?.date.slice(5)}
+
+
+
);
@@ -317,7 +500,7 @@ function CampaignTable({
{formatNum(r.conversions)}
- {r.cpl != null ? formatEur(r.cpl) : '—'}
+ {r.cpl != null ? formatEurCents(r.cpl) : '—'}
|
))}
@@ -491,7 +674,7 @@ export default function AdTrackerClient({
label="Impresiones"
value={formatNum(combined.impressions)}
/>
-
+
@@ -572,8 +755,9 @@ export default function AdTrackerClient({
Conversiones y CPL
- Barras: coste por lead. Línea: conversiones. CPL{' '}
- {combined.cpl != null ? formatEur(combined.cpl) : '—'}
+ Barras = {`CPL (€)`} (eje izq.). Línea = conversiones (eje
+ der.). Fechas en eje X. CPL periodo:{' '}
+ {combined.cpl != null ? formatEurCents(combined.cpl) : '—'}
@@ -604,7 +788,8 @@ export default function AdTrackerClient({
Coste y clics
- 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
@@ -651,7 +836,7 @@ export default function AdTrackerClient({
label="Impresiones"
value={formatNum(meta.impressions)}
/>
-
+
@@ -720,11 +905,11 @@ export default function AdTrackerClient({
/>
diff --git a/src/lib/ads-sync.ts b/src/lib/ads-sync.ts
index 8879720..afb00ad 100644
--- a/src/lib/ads-sync.ts
+++ b/src/lib/ads-sync.ts
@@ -2,7 +2,7 @@
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
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';
const META_GRAPH = 'https://graph.facebook.com/v21.0';
@@ -155,7 +155,8 @@ async function fetchMetaInsightsWindow(
ad_id: campaignId || '',
ad_name: '',
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,
platform_leads: leadFromMetaActions(row.actions),
});
diff --git a/src/lib/meta-leads.ts b/src/lib/meta-leads.ts
index 70ba1d3..af78935 100644
--- a/src/lib/meta-leads.ts
+++ b/src/lib/meta-leads.ts
@@ -18,3 +18,16 @@ export function leadFromMetaActions(
);
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;
+}
diff --git a/src/lib/metrics.ts b/src/lib/metrics.ts
index 3af0f61..483ac46 100644
--- a/src/lib/metrics.ts
+++ b/src/lib/metrics.ts
@@ -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 {
if (v === null || Number.isNaN(v)) return '—';
const sign = v > 0 ? '+' : '';