From c83efa492dcadc9fbd739c9e3b1b4febdcf8edad Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Tue, 29 Sep 2026 01:28:59 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20Ads=20CPC=20con=20c=C3=A9ntimos=20+=20cl?= =?UTF-8?q?ics=20enlace=20Meta;=20gr=C3=A1ficas=20barras/l=C3=ADnea=20con?= =?UTF-8?q?=20ejes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- src/components/AdTrackerClient.tsx | 275 ++++++++++++++++++++++++----- src/lib/ads-sync.ts | 5 +- src/lib/meta-leads.ts | 13 ++ src/lib/metrics.ts | 8 + 4 files changed, 254 insertions(+), 47 deletions(-) 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)} + +
+ + {/* Grid */} + {yBarTicks.map((t, i) => ( + + ))} + + {/* 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 ( + + + {v.date}: {barLabel} {fmtBar(v.bar)} · {lineLabel}{' '} + {fmtLine(v.line)} + + + ); + })} + + {/* Line */} + {n > 1 ? ( + + ) : null} + {values.map((v, i) => ( + + + {v.date}: {lineLabel} {fmtLine(v.line)} + + + ))} + + {/* Y left (bars) */} + {yBarTicks.map((t, i) => ( + + {fmtBar(t)} + + ))} + + {barLabel} + + + {/* Y right (line) */} + {yLineTicks.map((t, i) => ( + + {fmtLine(t)} + + ))} + + {lineLabel} + + + {/* X axis */} + + {xTicks.map((idx) => ( + + {fmtDate(values[idx].date)} + + ))} +
); @@ -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 ? '+' : '';