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 { 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 (
|
||||
<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 className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-4 text-[11px] text-gray-600 px-1">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block w-3 h-3 rounded-sm"
|
||||
style={{ background: barColor }}
|
||||
/>
|
||||
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 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 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}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 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 (
|
||||
<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>
|
||||
);
|
||||
@@ -317,7 +500,7 @@ function CampaignTable({
|
||||
{formatNum(r.conversions)}
|
||||
</td>
|
||||
<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>
|
||||
</tr>
|
||||
))}
|
||||
@@ -491,7 +674,7 @@ export default function AdTrackerClient({
|
||||
label="Impresiones"
|
||||
value={formatNum(combined.impressions)}
|
||||
/>
|
||||
<KpiTile label="Clics" value={formatNum(combined.clicks)} />
|
||||
<KpiTile label="Clics (enlace)" value={formatNum(combined.clicks)} />
|
||||
<KpiTile
|
||||
label="Conversiones"
|
||||
value={formatNum(combined.conversions)}
|
||||
@@ -504,11 +687,11 @@ export default function AdTrackerClient({
|
||||
/>
|
||||
<KpiTile
|
||||
label="CPC"
|
||||
value={combined.cpc != null ? formatEur(combined.cpc) : '—'}
|
||||
value={combined.cpc != null ? formatEurCents(combined.cpc) : '—'}
|
||||
/>
|
||||
<KpiTile
|
||||
label="CPL"
|
||||
value={combined.cpl != null ? formatEur(combined.cpl) : '—'}
|
||||
value={combined.cpl != null ? formatEurCents(combined.cpl) : '—'}
|
||||
/>
|
||||
<KpiTile label="Ads" value={formatNum(combined.ads)} />
|
||||
</div>
|
||||
@@ -572,8 +755,9 @@ export default function AdTrackerClient({
|
||||
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) : '—'}
|
||||
Barras = {`CPL (€)`} (eje izq.). Línea = conversiones (eje
|
||||
der.). Fechas en eje X. CPL periodo:{' '}
|
||||
{combined.cpl != null ? formatEurCents(combined.cpl) : '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
@@ -604,7 +788,8 @@ export default function AdTrackerClient({
|
||||
</p>
|
||||
<h3 className="text-lg font-bold text-gray-900">Coste y clics</h3>
|
||||
<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
|
||||
</p>
|
||||
</div>
|
||||
@@ -651,7 +836,7 @@ export default function AdTrackerClient({
|
||||
label="Impresiones"
|
||||
value={formatNum(meta.impressions)}
|
||||
/>
|
||||
<KpiTile label="Clics" value={formatNum(meta.clicks)} />
|
||||
<KpiTile label="Clics (enlace)" value={formatNum(meta.clicks)} />
|
||||
<KpiTile
|
||||
label="Conversiones"
|
||||
value={formatNum(meta.conversions)}
|
||||
@@ -662,11 +847,11 @@ export default function AdTrackerClient({
|
||||
/>
|
||||
<KpiTile
|
||||
label="CPC"
|
||||
value={meta.cpc != null ? formatEur(meta.cpc) : '—'}
|
||||
value={meta.cpc != null ? formatEurCents(meta.cpc) : '—'}
|
||||
/>
|
||||
<KpiTile
|
||||
label="CPL"
|
||||
value={meta.cpl != null ? formatEur(meta.cpl) : '—'}
|
||||
value={meta.cpl != null ? formatEurCents(meta.cpl) : '—'}
|
||||
/>
|
||||
<KpiTile label="Ads activos" value={formatNum(meta.ads)} />
|
||||
</div>
|
||||
@@ -720,11 +905,11 @@ export default function AdTrackerClient({
|
||||
/>
|
||||
<KpiTile
|
||||
label="CPC"
|
||||
value={google.cpc != null ? formatEur(google.cpc) : '—'}
|
||||
value={google.cpc != null ? formatEurCents(google.cpc) : '—'}
|
||||
/>
|
||||
<KpiTile
|
||||
label="CPL"
|
||||
value={google.cpl != null ? formatEur(google.cpl) : '—'}
|
||||
value={google.cpl != null ? formatEurCents(google.cpl) : '—'}
|
||||
/>
|
||||
<KpiTile label="Ads activos" value={formatNum(google.ads)} />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user