fix: Meta Ads muestra clics todos y clics enlace (y ambos CPC)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-29 01:51:45 +02:00
parent ed2afff556
commit 9270584934
6 changed files with 150 additions and 18 deletions

View File

@@ -0,0 +1,3 @@
-- Meta: clics en el enlace (Ads Manager) aparte de clicks = clics todos.
ALTER TABLE public.ad_daily
ADD COLUMN IF NOT EXISTS link_clicks numeric(14, 6) NOT NULL DEFAULT 0;

View File

@@ -5,6 +5,7 @@ import { isAgencyRole, userRole } from '@/lib/agency';
import { AdDailyRow } from '@/lib/metrics'; import { AdDailyRow } from '@/lib/metrics';
import { import {
AdCreativeCard, AdCreativeCard,
fetchMetaAccountClickSplit,
fetchMetaCreativeCards, fetchMetaCreativeCards,
} from '@/lib/ad-creatives-meta'; } from '@/lib/ad-creatives-meta';
import { fetchGoogleCreativeCards } from '@/lib/ad-creatives-google'; import { fetchGoogleCreativeCards } from '@/lib/ad-creatives-google';
@@ -66,16 +67,29 @@ export async function GET(req: NextRequest) {
const warnings: string[] = []; const warnings: string[] = [];
let metaAds: AdCreativeCard[] = []; let metaAds: AdCreativeCard[] = [];
let googleAds: AdCreativeCard[] = []; let googleAds: AdCreativeCard[] = [];
let metaClickSplit: {
clicks: number;
linkClicks: number;
spend: number;
} | null = null;
if (platform === 'meta' || platform === 'all') { if (platform === 'meta' || platform === 'all') {
const meta = await fetchMetaCreativeCards({ const [meta, clickSplit] = await Promise.all([
fetchMetaCreativeCards({
tenantId: ctx.tenantId, tenantId: ctx.tenantId,
dailyRows: daily, dailyRows: daily,
since, since,
until, until,
limit: 16, limit: 16,
}); }),
fetchMetaAccountClickSplit({
tenantId: ctx.tenantId,
since,
until,
}),
]);
metaAds = meta.ads; metaAds = meta.ads;
metaClickSplit = clickSplit;
if (meta.warning) warnings.push(meta.warning); if (meta.warning) warnings.push(meta.warning);
} }
@@ -99,6 +113,7 @@ export async function GET(req: NextRequest) {
ads: [...metaAds, ...googleAds], ads: [...metaAds, ...googleAds],
metaCount: metaAds.length, metaCount: metaAds.length,
googleCount: googleAds.length, googleCount: googleAds.length,
metaClickSplit,
warnings, warnings,
}); });
} }

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, formatEurCents, formatNum } from '@/lib/metrics'; import { AdDailyRow, formatEur, formatEurCents, formatNum, linkClicksOf } from '@/lib/metrics';
import type { AdCreativeCard } from '@/lib/ad-creatives-meta'; import type { AdCreativeCard } from '@/lib/ad-creatives-meta';
import { import {
CreativesGallery, CreativesGallery,
@@ -527,6 +527,11 @@ export default function AdTrackerClient({
const [creatives, setCreatives] = useState<AdCreativeCard[]>([]); const [creatives, setCreatives] = useState<AdCreativeCard[]>([]);
const [creativesLoading, setCreativesLoading] = useState(false); const [creativesLoading, setCreativesLoading] = useState(false);
const [creativesWarning, setCreativesWarning] = useState<string | null>(null); const [creativesWarning, setCreativesWarning] = useState<string | null>(null);
const [metaClickSplit, setMetaClickSplit] = useState<{
clicks: number;
linkClicks: number;
spend: number;
} | null>(null);
const [zoomAd, setZoomAd] = useState<AdCreativeCard | null>(null); const [zoomAd, setZoomAd] = useState<AdCreativeCard | null>(null);
const range = useMemo(() => ({ since, until, label: rangeLabel }), [ const range = useMemo(() => ({ since, until, label: rangeLabel }), [
@@ -540,6 +545,7 @@ export default function AdTrackerClient({
setCreatives([]); setCreatives([]);
setCreativesWarning(null); setCreativesWarning(null);
setCreativesLoading(false); setCreativesLoading(false);
setMetaClickSplit(null);
return; return;
} }
let cancelled = false; let cancelled = false;
@@ -560,15 +566,25 @@ export default function AdTrackerClient({
if (cancelled) return; if (cancelled) return;
if (!r.ok) { if (!r.ok) {
setCreatives([]); setCreatives([]);
setMetaClickSplit(null);
setCreativesWarning(data.hint || data.error || 'No se pudieron cargar creatividades'); setCreativesWarning(data.hint || data.error || 'No se pudieron cargar creatividades');
return; return;
} }
setCreatives((data.ads as AdCreativeCard[]) || []); setCreatives((data.ads as AdCreativeCard[]) || []);
if (
data.metaClickSplit &&
typeof data.metaClickSplit.clicks === 'number'
) {
setMetaClickSplit(data.metaClickSplit);
} else if (platform === 'google') {
setMetaClickSplit(null);
}
const w = Array.isArray(data.warnings) ? data.warnings.filter(Boolean) : []; const w = Array.isArray(data.warnings) ? data.warnings.filter(Boolean) : [];
setCreativesWarning(w.length ? w.join(' · ') : null); setCreativesWarning(w.length ? w.join(' · ') : null);
} catch { } catch {
if (!cancelled) { if (!cancelled) {
setCreatives([]); setCreatives([]);
setMetaClickSplit(null);
setCreativesWarning('Error al cargar creatividades'); setCreativesWarning('Error al cargar creatividades');
} }
} finally { } finally {
@@ -605,6 +621,22 @@ export default function AdTrackerClient({
ads: meta.ads + google.ads, ads: meta.ads + google.ads,
}); });
// Meta: clics todos (warehouse) + clics enlace (live Ads Manager o warehouse).
const metaClicksAll = metaClickSplit?.clicks ?? meta.clicks;
const metaLinkFromWarehouse = [...metaRows, ...otherRows].reduce(
(a, r) => a + linkClicksOf(r),
0
);
const metaClicksLink =
metaClickSplit?.linkClicks ??
(metaLinkFromWarehouse > 0 ? metaLinkFromWarehouse : meta.clicks);
const metaCpcAll =
metaClicksAll > 0 ? (metaClickSplit?.spend ?? meta.spend) / metaClicksAll : null;
const metaCpcLink =
metaClicksLink > 0
? (metaClickSplit?.spend ?? meta.spend) / metaClicksLink
: null;
const daily = buildSeries(filtered); const daily = buildSeries(filtered);
const metaCampaigns = byCampaign([...metaRows, ...otherRows]); const metaCampaigns = byCampaign([...metaRows, ...otherRows]);
const googleCampaigns = byCampaign(googleRows); const googleCampaigns = byCampaign(googleRows);
@@ -674,7 +706,7 @@ export default function AdTrackerClient({
label="Impresiones" label="Impresiones"
value={formatNum(combined.impressions)} value={formatNum(combined.impressions)}
/> />
<KpiTile label="Clics (enlace)" value={formatNum(combined.clicks)} /> <KpiTile label="Clics" value={formatNum(combined.clicks)} />
<KpiTile <KpiTile
label="Conversiones" label="Conversiones"
value={formatNum(combined.conversions)} value={formatNum(combined.conversions)}
@@ -836,18 +868,30 @@ export default function AdTrackerClient({
label="Impresiones" label="Impresiones"
value={formatNum(meta.impressions)} value={formatNum(meta.impressions)}
/> />
<KpiTile label="Clics (enlace)" value={formatNum(meta.clicks)} /> <KpiTile label="Clics (todos)" value={formatNum(metaClicksAll)} />
<KpiTile
label="Clics (enlace)"
value={formatNum(metaClicksLink)}
/>
<KpiTile <KpiTile
label="Conversiones" label="Conversiones"
value={formatNum(meta.conversions)} value={formatNum(meta.conversions)}
/> />
<KpiTile <KpiTile
label="CTR" label="CTR"
value={meta.ctr != null ? `${meta.ctr.toFixed(2)}%` : '—'} value={
meta.impressions > 0
? `${((100 * metaClicksAll) / meta.impressions).toFixed(2)}%`
: '—'
}
/> />
<KpiTile <KpiTile
label="CPC" label="CPC (todos)"
value={meta.cpc != null ? formatEurCents(meta.cpc) : '—'} value={metaCpcAll != null ? formatEurCents(metaCpcAll) : '—'}
/>
<KpiTile
label="CPC (enlace)"
value={metaCpcLink != null ? formatEurCents(metaCpcLink) : '—'}
/> />
<KpiTile <KpiTile
label="CPL" label="CPL"

View File

@@ -2,7 +2,7 @@
import { resolveMetaAccessToken } from '@/lib/ads-discovery'; import { resolveMetaAccessToken } from '@/lib/ads-discovery';
import { serviceHeaders, supabaseUrl } from '@/lib/agency'; import { serviceHeaders, supabaseUrl } from '@/lib/agency';
import { leadFromMetaActions } from '@/lib/meta-leads'; import { leadFromMetaActions, linkClicksFromMetaActions } from '@/lib/meta-leads';
const META_GRAPH = 'https://graph.facebook.com/v21.0'; const META_GRAPH = 'https://graph.facebook.com/v21.0';
@@ -761,3 +761,50 @@ export function buildGoogleCardsFromDaily(
status: undefined, status: undefined,
})); }));
} }
/**
* Account-level Meta clicks for the period:
* - clicks = todos (Ads Manager)
* - linkClicks = clics en el enlace
*/
export async function fetchMetaAccountClickSplit(opts: {
tenantId: string;
since: string;
until: string;
}): Promise<{ clicks: number; linkClicks: number; spend: number } | null> {
const token = await resolveMetaAccessToken();
if (!token) return null;
const account = await resolveMetaAccountId(opts.tenantId);
if (!account) return null;
const act = account.startsWith('act_') ? account : `act_${account}`;
const timeRange = JSON.stringify({ since: opts.since, until: opts.until });
try {
const raw = await graphGet<{
data?: {
spend?: string;
clicks?: string;
actions?: { action_type?: string; value?: string }[];
}[];
}>(
`${act}/insights`,
{
fields: 'spend,clicks,actions',
level: 'account',
time_increment: 'all_days',
time_range: timeRange,
limit: '1',
},
token
);
const row = raw.data?.[0];
if (!row) return null;
const clicks = n(row.clicks);
return {
clicks,
linkClicks: linkClicksFromMetaActions(row.actions, clicks),
spend: Math.round(n(row.spend) * 100) / 100,
};
} catch {
return null;
}
}

View File

@@ -55,6 +55,8 @@ type AdDailyInsert = {
clicks: number; clicks: number;
spend: number; spend: number;
platform_leads: number; platform_leads: number;
/** Meta: clics en el enlace. Google: igual que clicks. */
link_clicks?: number;
}; };
const n = nMeta; const n = nMeta;
@@ -155,10 +157,11 @@ async function fetchMetaInsightsWindow(
ad_id: campaignId || '', ad_id: campaignId || '',
ad_name: '', ad_name: '',
impressions: n(row.impressions), impressions: n(row.impressions),
// Store link clicks so CPC = spend/clicks matches Ads Manager. // Ads Manager «Clics (todos)» — link clicks se exponen aparte en UI Meta.
clicks: linkClicksFromMetaActions(row.actions, n(row.clicks)), clicks: 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),
link_clicks: linkClicksFromMetaActions(row.actions, n(row.clicks)),
}); });
} }
url = j.paging?.next || null; url = j.paging?.next || null;
@@ -321,6 +324,7 @@ async function fetchGoogleWindow(
clicks: r.clicks, clicks: r.clicks,
spend: r.spend, spend: r.spend,
platform_leads: Math.round(r.conversions * 1e6) / 1e6, platform_leads: Math.round(r.conversions * 1e6) / 1e6,
link_clicks: r.clicks,
})); }));
} }
@@ -361,9 +365,16 @@ async function replaceDaily(
); );
} }
// chunk inserts // chunk inserts (link_clicks → ad_name marker until column exists)
for (let i = 0; i < rows.length; i += 400) { for (let i = 0; i < rows.length; i += 400) {
const chunk = rows.slice(i, i + 400); const chunk = rows.slice(i, i + 400).map((r) => {
const link = Math.round(r.link_clicks ?? r.clicks);
const { link_clicks: _lc, ...rest } = r;
if (r.platform === 'meta') {
return { ...rest, ad_name: `__lc:${link}` };
}
return rest;
});
const r = await fetch(`${base}/rest/v1/ad_daily`, { const r = await fetch(`${base}/rest/v1/ad_daily`, {
method: 'POST', method: 'POST',
headers: { headers: {

View File

@@ -18,6 +18,8 @@ export type AdDailyRow = {
platform_leads?: number; platform_leads?: number;
impressions?: number; impressions?: number;
clicks?: number; clicks?: number;
/** Meta link clicks when stored; else parse from ad_name `__lc:N`. */
link_clicks?: number;
platform?: string; platform?: string;
campaign_name?: string; campaign_name?: string;
campaign?: string; campaign?: string;
@@ -27,6 +29,16 @@ export type AdDailyRow = {
tenant_id?: string; tenant_id?: string;
}; };
/** Meta link clicks stored as ad_name `__lc:N` until link_clicks column is used. */
export function linkClicksOf(r: AdDailyRow): number {
if (r.link_clicks != null && Number.isFinite(Number(r.link_clicks))) {
return Number(r.link_clicks) || 0;
}
const m = String(r.ad_name || '').match(/^__lc:(\d+(?:\.\d+)?)$/);
if (m) return Number(m[1]) || 0;
return Number(r.clicks || 0) || 0;
}
export type MetricTotals = { export type MetricTotals = {
spend: number; spend: number;
leads: number; leads: number;