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:
@@ -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>
|
||||||
|
|||||||
@@ -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),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 ? '+' : '';
|
||||||
|
|||||||
Reference in New Issue
Block a user