'use client'; import React, { useEffect, useMemo, useState } from 'react'; import { normalizePlatform } from '@/lib/ad-tracker-range'; import { AdDailyRow, formatEur, formatEurCents, formatNum, linkClicksOf } from '@/lib/metrics'; import type { AdCreativeCard } from '@/lib/ad-creatives-meta'; import { CreativesGallery, CreativeZoomModal, } from '@/components/AdCreativesGallery'; import { PageHeader, SegmentedControl } from '@/components/ui'; import SyncNowButton from '@/components/SyncNowButton'; import { useDateRange } from '@/components/DateRangeContext'; import { HelpDetails, HeroBand, HeroStat, ShareBar, StatMini, tableHeadClass, } from '@/components/dash-ui'; type PlatformKpis = { spend: number; impressions: number; clicks: number; conversions: number; ads: number; ctr: number | null; cpc: number | null; cpl: number | null; }; type DailyPoint = { date: string; metaSpend: number; googleSpend: number; metaClicks: number; googleClicks: number; metaConv: number; googleConv: number; }; type CampaignRow = { name: string; spend: number; impressions: number; clicks: number; conversions: number; cpl: number | null; }; type Tab = 'resumen' | 'meta' | 'google'; type ChartMode = 'combined' | 'meta' | 'google'; function rates(input: Omit): PlatformKpis { const { spend, impressions, clicks, conversions, ads } = input; return { spend, impressions, clicks, conversions, ads, ctr: impressions > 0 ? (100 * clicks) / impressions : null, cpc: clicks > 0 ? spend / clicks : null, cpl: conversions > 0 ? spend / conversions : null, }; } function convOf(r: AdDailyRow): number { const x = r.platform_leads ?? r.conversions; const n = Number(x); return Number.isFinite(n) ? n : 0; } function campaignName(r: AdDailyRow): string { return r.campaign_name || r.campaign || r.ad_name || 'Sin nombre'; } function inRange(date: string | undefined, since: string, until: string) { if (!date) return false; const d = date.slice(0, 10); return d >= since && d <= until; } function buildSeries(rows: AdDailyRow[]): DailyPoint[] { const map = new Map(); for (const r of rows) { const d = (r.date || '').slice(0, 10); if (!d) continue; const cur = map.get(d) || ({ date: d, metaSpend: 0, googleSpend: 0, metaClicks: 0, googleClicks: 0, metaConv: 0, googleConv: 0, } satisfies DailyPoint); const plat = normalizePlatform(r.platform); const spend = Number(r.spend || 0); const clicks = Number(r.clicks || 0); const conv = convOf(r); if (plat === 'meta') { cur.metaSpend += spend; cur.metaClicks += clicks; cur.metaConv += conv; } else if (plat === 'google') { cur.googleSpend += spend; cur.googleClicks += clicks; cur.googleConv += conv; } else { // unknown → split weight to meta bucket for visibility cur.metaSpend += spend; cur.metaClicks += clicks; cur.metaConv += conv; } map.set(d, cur); } return [...map.values()].sort((a, b) => a.date.localeCompare(b.date)); } function aggregate(rows: AdDailyRow[]): PlatformKpis { const active = rows.filter( (r) => Number(r.spend || 0) > 0 || Number(r.impressions || 0) > 0 || Number(r.clicks || 0) > 0 || convOf(r) > 0 ); const spend = active.reduce((a, r) => a + Number(r.spend || 0), 0); const impressions = active.reduce((a, r) => a + Number(r.impressions || 0), 0); const clicks = active.reduce((a, r) => a + Number(r.clicks || 0), 0); const conversions = active.reduce((a, r) => a + convOf(r), 0); // Round once after sum (Ads Manager style; Google stores fractional daily). const conversionsRounded = Math.round(conversions * 100) / 100; const ads = new Set( active .map((r) => String(r.ad_id || campaignName(r) || '').trim()) .filter((n) => n && n !== 'Sin nombre') ).size; return rates({ spend, impressions, clicks, conversions: Math.round(conversionsRounded), ads, }); } function byCampaign(rows: AdDailyRow[]): CampaignRow[] { const map = new Map(); for (const r of rows) { const spend = Number(r.spend || 0); const impressions = Number(r.impressions || 0); const clicks = Number(r.clicks || 0); const conversions = convOf(r); if (spend <= 0 && impressions <= 0 && clicks <= 0 && conversions <= 0) { continue; } const name = campaignName(r); const cur = map.get(name) || ({ name, spend: 0, impressions: 0, clicks: 0, conversions: 0, cpl: null, } satisfies CampaignRow); cur.spend += spend; cur.impressions += impressions; cur.clicks += clicks; cur.conversions += conversions; map.set(name, cur); } return [...map.values()] .map((c) => ({ ...c, conversions: Math.round(c.conversions * 100) / 100, cpl: c.conversions > 0 ? c.spend / c.conversions : null, })) .sort((a, b) => b.spend - a.spend); } function SparkBars({ data, mode, metric, }: { data: DailyPoint[]; mode: ChartMode; metric: 'cpl' | 'spend'; }) { if (!data.length) { return (

Sin serie diaria para este rango

); } const values = data.map((d) => { const spend = mode === 'meta' ? d.metaSpend : mode === 'google' ? d.googleSpend : d.metaSpend + d.googleSpend; const conv = mode === 'meta' ? d.metaConv : mode === 'google' ? d.googleConv : d.metaConv + d.googleConv; const clicks = mode === 'meta' ? d.metaClicks : mode === 'google' ? d.googleClicks : d.metaClicks + d.googleClicks; if (metric === 'cpl') { return { bar: conv > 0 ? spend / conv : 0, line: conv, date: d.date, }; } return { bar: spend, line: clicks, date: d.date }; }); 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 (
Barras · {barLabel} Línea · {lineLabel}
{/* 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)} ))}
); } function CampaignTable({ title, rows, empty, }: { title: string; rows: CampaignRow[]; empty: string; }) { const spendMax = Math.max(1, ...rows.map((r) => r.spend)); return (

Campañas

{title}

{rows.length === 0 ? (

{empty}

) : (
{rows.slice(0, 25).map((r) => ( ))}
Campaña Gasto Clics Conv. CPL
{r.name} {formatNum(r.clicks)} {formatNum(r.conversions)} {r.cpl != null ? formatEurCents(r.cpl) : '—'}
)}
); } export default function AdTrackerClient({ label, tagline, rows, }: { label: string; tagline?: string; rows: AdDailyRow[]; }) { const { since, until, label: rangeLabel } = useDateRange(); const [tab, setTab] = useState('resumen'); const [chartMode, setChartMode] = useState('combined'); 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 [rowsLive, setRowsLive] = useState(rows); const [rowsLoading, setRowsLoading] = useState(false); const range = useMemo(() => ({ since, until, label: rangeLabel }), [ since, until, rangeLabel, ]); useEffect(() => { setRowsLive(rows); }, [rows]); useEffect(() => { let cancelled = false; async function loadRange() { setRowsLoading(true); try { const qs = new URLSearchParams({ since: range.since, until: range.until, }); const r = await fetch(`/api/ads/daily?${qs}`, { cache: 'no-store' }); const data = await r.json().catch(() => ({})); if (cancelled) return; if (r.ok && Array.isArray(data.rows)) { setRowsLive(data.rows as AdDailyRow[]); } } finally { if (!cancelled) setRowsLoading(false); } } void loadRange(); return () => { cancelled = true; }; }, [range.since, range.until]); useEffect(() => { if (tab === 'resumen') { setCreatives([]); setCreativesWarning(null); setCreativesLoading(false); setMetaClickSplit(null); return; } let cancelled = false; async function load() { setCreativesLoading(true); setCreativesWarning(null); const platform = tab === 'meta' ? 'meta' : 'google'; const baseQs = { since: range.since, until: range.until, platform, }; let hasFast = false; // 1) Warehouse first — instant metrics (source of truth from sync) try { const qs = new URLSearchParams({ ...baseQs, mode: 'fast' }); const r = await fetch(`/api/ad-tracker/creatives?${qs}`, { cache: 'no-store', }); const data = await r.json().catch(() => ({})); if (cancelled) return; if (r.ok) { hasFast = true; setCreatives((data.ads as AdCreativeCard[]) || []); if ( data.metaClickSplit && typeof data.metaClickSplit.clicks === 'number' ) { setMetaClickSplit(data.metaClickSplit); } else if (platform === 'google') { setMetaClickSplit(null); } setCreativesLoading(false); } } catch { /* continue to live */ } // 2) Live enrich (previews) — never blocks empty UI try { const qs = new URLSearchParams({ ...baseQs, mode: 'live' }); const r = await fetch(`/api/ad-tracker/creatives?${qs}`, { cache: 'no-store', }); const data = await r.json().catch(() => ({})); if (cancelled) return; if (!r.ok) { if (!hasFast) { setCreativesWarning( data.hint || data.error || 'No se pudieron cargar creatividades' ); } return; } const liveAds = (data.ads as AdCreativeCard[]) || []; if (liveAds.length) setCreatives(liveAds); if ( data.metaClickSplit && typeof data.metaClickSplit.clicks === 'number' ) { setMetaClickSplit(data.metaClickSplit); } const w = Array.isArray(data.warnings) ? data.warnings.filter(Boolean) : []; setCreativesWarning(w.length ? w.join(' · ') : null); } catch { if (!cancelled && !hasFast) { setCreativesWarning('Error al cargar creatividades'); } } finally { if (!cancelled) setCreativesLoading(false); } } void load(); return () => { cancelled = true; }; }, [range.since, range.until, tab]); const filtered = useMemo( () => rowsLive.filter((r) => inRange(r.date, range.since, range.until)), [rowsLive, range.since, range.until] ); const metaRows = filtered.filter((r) => normalizePlatform(r.platform) === 'meta'); const googleRows = filtered.filter( (r) => normalizePlatform(r.platform) === 'google' ); // rows without platform count toward combined via meta bucket already in series const otherRows = filtered.filter( (r) => normalizePlatform(r.platform) === 'other' ); const meta = aggregate([...metaRows, ...otherRows]); const google = aggregate(googleRows); const combined = rates({ spend: meta.spend + google.spend, impressions: meta.impressions + google.impressions, clicks: meta.clicks + google.clicks, conversions: meta.conversions + google.conversions, 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); const totalSpend = combined.spend || 1; const metaShare = Math.min(100, (100 * meta.spend) / totalSpend); const googleShare = Math.min(100, (100 * google.spend) / totalSpend); const chips: { id: ChartMode; label: string }[] = [ { id: 'combined', label: 'Conjunto' }, { id: 'meta', label: 'Meta' }, { id: 'google', label: 'Google' }, ]; return (
} /> {tab === 'resumen' && (

Gasto, impresiones, clics y conversiones se suman entre Meta y Google. CTR, CPC y CPL se recalculan sobre el total (no se suman porcentajes).

Reparto de inversión · Meta {metaShare.toFixed(0)}% · Google{' '} {googleShare.toFixed(0)}%

Rendimiento diario

Conversiones y CPL

Barras = {`CPL (€)`} (eje izq.). Línea = conversiones (eje der.). Fechas en eje X. CPL periodo:{' '} {combined.cpl != null ? formatEurCents(combined.cpl) : '—'}

{chips.map((c) => ( ))}

Rendimiento diario

Coste y clics

Barras = gasto (€) (eje izq.). Línea = clics (eje der.). Fechas en eje X. {formatEur(combined.spend)} ·{' '} {formatNum(combined.clicks)} clics

{chips.map((c) => ( ))}
)} {tab === 'meta' && (
0 ? `${((100 * metaClicksAll) / meta.impressions).toFixed(2)}%` : '—' } />
c.platform === 'meta' && (c.spend > 0 || c.impressions > 0 || c.clicks > 0 || c.conversions > 0) )} loading={creativesLoading} warning={creativesWarning} onZoom={setZoomAd} />

Conversiones y CPL · Meta

Coste y clics · Meta

)} {tab === 'google' && (
c.platform === 'google' && (c.spend > 0 || c.impressions > 0 || c.clicks > 0 || c.conversions > 0) )} loading={creativesLoading} warning={creativesWarning} onZoom={setZoomAd} />

Conversiones y CPL · Google

Coste y clics · Google

)} {zoomAd ? ( setZoomAd(null)} /> ) : null}
); }