diff --git a/sql/ad_daily_link_clicks.sql b/sql/ad_daily_link_clicks.sql new file mode 100644 index 0000000..efdbdfa --- /dev/null +++ b/sql/ad_daily_link_clicks.sql @@ -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; diff --git a/src/app/api/ad-tracker/creatives/route.ts b/src/app/api/ad-tracker/creatives/route.ts index c3ef26b..d620049 100644 --- a/src/app/api/ad-tracker/creatives/route.ts +++ b/src/app/api/ad-tracker/creatives/route.ts @@ -5,6 +5,7 @@ import { isAgencyRole, userRole } from '@/lib/agency'; import { AdDailyRow } from '@/lib/metrics'; import { AdCreativeCard, + fetchMetaAccountClickSplit, fetchMetaCreativeCards, } from '@/lib/ad-creatives-meta'; import { fetchGoogleCreativeCards } from '@/lib/ad-creatives-google'; @@ -66,16 +67,29 @@ export async function GET(req: NextRequest) { const warnings: string[] = []; let metaAds: AdCreativeCard[] = []; let googleAds: AdCreativeCard[] = []; + let metaClickSplit: { + clicks: number; + linkClicks: number; + spend: number; + } | null = null; if (platform === 'meta' || platform === 'all') { - const meta = await fetchMetaCreativeCards({ - tenantId: ctx.tenantId, - dailyRows: daily, - since, - until, - limit: 16, - }); + const [meta, clickSplit] = await Promise.all([ + fetchMetaCreativeCards({ + tenantId: ctx.tenantId, + dailyRows: daily, + since, + until, + limit: 16, + }), + fetchMetaAccountClickSplit({ + tenantId: ctx.tenantId, + since, + until, + }), + ]); metaAds = meta.ads; + metaClickSplit = clickSplit; if (meta.warning) warnings.push(meta.warning); } @@ -99,6 +113,7 @@ export async function GET(req: NextRequest) { ads: [...metaAds, ...googleAds], metaCount: metaAds.length, googleCount: googleAds.length, + metaClickSplit, warnings, }); } diff --git a/src/components/AdTrackerClient.tsx b/src/components/AdTrackerClient.tsx index 810ba02..edfafe3 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, formatEurCents, formatNum } from '@/lib/metrics'; +import { AdDailyRow, formatEur, formatEurCents, formatNum, linkClicksOf } from '@/lib/metrics'; import type { AdCreativeCard } from '@/lib/ad-creatives-meta'; import { CreativesGallery, @@ -527,6 +527,11 @@ export default function AdTrackerClient({ const [creatives, setCreatives] = useState([]); const [creativesLoading, setCreativesLoading] = useState(false); const [creativesWarning, setCreativesWarning] = useState(null); + const [metaClickSplit, setMetaClickSplit] = useState<{ + clicks: number; + linkClicks: number; + spend: number; + } | null>(null); const [zoomAd, setZoomAd] = useState(null); const range = useMemo(() => ({ since, until, label: rangeLabel }), [ @@ -540,6 +545,7 @@ export default function AdTrackerClient({ setCreatives([]); setCreativesWarning(null); setCreativesLoading(false); + setMetaClickSplit(null); return; } let cancelled = false; @@ -560,15 +566,25 @@ export default function AdTrackerClient({ if (cancelled) return; if (!r.ok) { setCreatives([]); + setMetaClickSplit(null); setCreativesWarning(data.hint || data.error || 'No se pudieron cargar creatividades'); return; } 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) : []; setCreativesWarning(w.length ? w.join(' · ') : null); } catch { if (!cancelled) { setCreatives([]); + setMetaClickSplit(null); setCreativesWarning('Error al cargar creatividades'); } } finally { @@ -605,6 +621,22 @@ export default function AdTrackerClient({ 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 metaCampaigns = byCampaign([...metaRows, ...otherRows]); const googleCampaigns = byCampaign(googleRows); @@ -674,7 +706,7 @@ export default function AdTrackerClient({ label="Impresiones" value={formatNum(combined.impressions)} /> - + - + + 0 + ? `${((100 * metaClicksAll) / meta.impressions).toFixed(2)}%` + : '—' + } /> + { + 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; + } +} diff --git a/src/lib/ads-sync.ts b/src/lib/ads-sync.ts index afb00ad..47df6f3 100644 --- a/src/lib/ads-sync.ts +++ b/src/lib/ads-sync.ts @@ -55,6 +55,8 @@ type AdDailyInsert = { clicks: number; spend: number; platform_leads: number; + /** Meta: clics en el enlace. Google: igual que clicks. */ + link_clicks?: number; }; const n = nMeta; @@ -155,10 +157,11 @@ async function fetchMetaInsightsWindow( ad_id: campaignId || '', ad_name: '', impressions: n(row.impressions), - // Store link clicks so CPC = spend/clicks matches Ads Manager. - clicks: linkClicksFromMetaActions(row.actions, n(row.clicks)), + // Ads Manager «Clics (todos)» — link clicks se exponen aparte en UI Meta. + clicks: n(row.clicks), spend: Math.round(n(row.spend) * 100) / 100, platform_leads: leadFromMetaActions(row.actions), + link_clicks: linkClicksFromMetaActions(row.actions, n(row.clicks)), }); } url = j.paging?.next || null; @@ -321,6 +324,7 @@ async function fetchGoogleWindow( clicks: r.clicks, spend: r.spend, 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) { - 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`, { method: 'POST', headers: { diff --git a/src/lib/metrics.ts b/src/lib/metrics.ts index 483ac46..20b3096 100644 --- a/src/lib/metrics.ts +++ b/src/lib/metrics.ts @@ -18,6 +18,8 @@ export type AdDailyRow = { platform_leads?: number; impressions?: number; clicks?: number; + /** Meta link clicks when stored; else parse from ad_name `__lc:N`. */ + link_clicks?: number; platform?: string; campaign_name?: string; campaign?: string; @@ -27,6 +29,16 @@ export type AdDailyRow = { 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 = { spend: number; leads: number;