diff --git a/src/components/MetricsDashboard.tsx b/src/components/MetricsDashboard.tsx index 583eb99..038b901 100644 --- a/src/components/MetricsDashboard.tsx +++ b/src/components/MetricsDashboard.tsx @@ -1,18 +1,22 @@ 'use client'; -import React, { useMemo, useState } from 'react'; +import React, { useMemo } from 'react'; import Link from 'next/link'; import { ArrowDownRight, ArrowUpRight, DollarSign, + GitCompare, + Globe, + Handshake, + Info, + Megaphone, Minus, + MousePointerClick, + Search, Target, TrendingUp, Users, - MousePointerClick, - Globe, - Search, } from 'lucide-react'; import { AdDailyRow, @@ -23,6 +27,7 @@ import { Delta, MetricTotals, formatEur, + formatEurCents, formatNum, formatPct, attributionCross, @@ -36,7 +41,7 @@ import { withCohortFromDeals, KpiWeeklyRow, } from '@/lib/metrics'; -import { PageHeader } from '@/components/ui'; +import { PageHeader, Surface } from '@/components/ui'; import SyncNowButton from '@/components/SyncNowButton'; import { useDateRange } from '@/components/DateRangeContext'; import { OrganicDailyRow } from '@/lib/organic'; @@ -48,11 +53,46 @@ type Card = { format: (v: number) => string; icon: React.ReactNode; invert?: boolean; - /** Business outcome from Clientify won deals. */ cohort?: boolean; }; -function DeltaTriple({ +function toneForDelta(v: number | null, invert?: boolean): string { + if (v === null) return 'text-gray-500 bg-gray-50 border-gray-200'; + if (v === 0) return 'text-gray-600 bg-gray-50 border-gray-200'; + const good = invert ? v < 0 : v > 0; + return good + ? 'text-emerald-800 bg-emerald-50 border-emerald-200' + : 'text-red-700 bg-red-50 border-red-200'; +} + +function DeltaChip({ + label, + v, + invert, + title, +}: { + label: string; + v: number | null; + invert?: boolean; + title?: string; +}) { + const Icon = + v === null || v === 0 ? Minus : (invert ? v < 0 : v > 0) ? ArrowUpRight : ArrowDownRight; + return ( + + + {label} {formatPct(v)} + + ); +} + +function DeltaRow({ delta, invert, empty, @@ -65,102 +105,197 @@ function DeltaTriple({ empty?: boolean; format: (v: number) => string; current: number; - /** Hide WoW when selected range is longer than a week. */ showWow: boolean; }) { if (empty) { return ( -

- Sin cierres en este rango — amplía el periodo o revisa oportunidades - ganadas en el CRM +

+ Sin cierres en este rango. Amplía el periodo o revisa oportunidades + ganadas en el CRM.

); } - const cells: { - label: string; - v: number | null; - base: number | null; - }[] = [ - ...(showWow - ? [{ label: 'WoW', v: delta.wow, base: delta.wowBase }] - : []), - { label: 'MoM', v: delta.mom, base: delta.momBase }, - { label: 'YoY', v: delta.yoy, base: delta.yoyBase }, + const items: { label: string; v: number | null; base: number | null }[] = [ + ...(showWow ? [{ label: 'sem.', v: delta.wow, base: delta.wowBase }] : []), + { label: 'mes ant.', v: delta.mom, base: delta.momBase }, + { label: 'año ant.', v: delta.yoy, base: delta.yoyBase }, ]; return ( -
- {cells.map((c) => { - const good = c.v === null ? null : invert ? c.v < 0 : c.v > 0; - const color = - c.v === null - ? 'text-gray-500' - : good - ? 'text-emerald-700' - : c.v === 0 - ? 'text-gray-600' - : 'text-red-600'; - const Icon = - c.v === null || c.v === 0 - ? Minus - : good - ? ArrowUpRight - : ArrowDownRight; +
+ {items.map((c) => { const hasBase = c.base !== null && Number.isFinite(c.base) && !(c.base === 0 && c.v === null); - const absDelta = - hasBase && c.base !== null ? current - c.base : null; return ( -
-

- {c.label} -

-

- - {formatPct(c.v)} -

- {hasBase ? ( - <> -

- prev {format(c.base ?? 0)} -

- {absDelta !== null && ( -

- Δ{' '} - {absDelta > 0 - ? `+${format(absDelta)}` - : format(absDelta)} -

- )} - - ) : ( -

- sin histórico -

- )} -
+ label={c.label} + v={c.v} + invert={invert} + title={ + hasBase + ? `Anterior: ${format(c.base ?? 0)} · ahora ${format(current)}` + : 'Sin histórico suficiente' + } + /> ); })}
); } -function gapClass(gap: number): string { - if (gap === 0) return 'text-gray-600'; +function gapTone(gap: number): string { + if (gap === 0) return 'text-gray-700 bg-gray-100'; + if (gap > 0) return 'text-amber-900 bg-amber-50 border border-amber-200'; const abs = Math.abs(gap); - if (abs <= 5) return 'text-emerald-700'; - if (abs <= 25) return 'text-amber-700'; - return 'text-red-600'; + if (abs <= 5) return 'text-emerald-800 bg-emerald-50 border border-emerald-200'; + return 'text-red-800 bg-red-50 border border-red-200'; +} + +function SparkBars({ + points, + color, + formatValue, +}: { + points: { date: string; value: number }[]; + color: string; + formatValue: (v: number) => string; +}) { + if (points.length === 0) { + return

Sin filas diarias.

; + } + const max = Math.max(1, ...points.map((p) => p.value)); + const first = points[0]?.date; + const last = points[points.length - 1]?.date; + const fmtDay = (d?: string) => + d + ? new Date(`${d}T00:00:00`).toLocaleDateString('es-ES', { + day: 'numeric', + month: 'short', + }) + : ''; + + return ( +
+
+ {formatValue(max)} + máx. +
+
+ {points.map((d) => ( +
+
+
+ ))} +
+
+ {fmtDay(first)} + {fmtDay(last)} +
+
+ ); +} + +function SectionHead({ + icon, + title, + hint, +}: { + icon: React.ReactNode; + title: string; + hint: string; +}) { + return ( +
+ + {icon} + +
+

+ {title} +

+

{hint}

+
+
+ ); +} + +function MetricTile({ + label, + hint, + value, + icon, + delta, + invert, + empty, + format, + current, + showWow, +}: { + label: string; + hint: string; + value: string; + icon: React.ReactNode; + delta: Delta; + invert?: boolean; + empty?: boolean; + format: (v: number) => string; + current: number; + showWow: boolean; +}) { + return ( +
+
+

+ {label} +

+ {icon} +
+

+ {value} +

+

{hint}

+ +
+ ); +} + +function StatMini({ + label, + value, + hint, + icon, +}: { + label: string; + value: string; + hint: string; + icon?: React.ReactNode; +}) { + return ( +
+

+ {icon} + {label} +

+

+ {value} +

+

{hint}

+
+ ); } export default function MetricsDashboard({ @@ -203,6 +338,10 @@ export default function MetricsDashboard({ integrations?: any[]; cohortSource?: 'deals' | 'cliente_leads' | 'none'; }) { + void tenantId; + void week; + void daily; + const { since, until, label: rangeHint } = useDateRange(); const rangedDaily = useMemo(() => { @@ -235,7 +374,6 @@ export default function MetricsDashboard({ return withCohortFromDeals(base, rangedDeals); }, [rangedDaily, rangedDeals, weeklyRows, since, until, month]); - /** Comparatives relative to the selected range (not “today”). */ const rangeDeltas = useMemo( () => buildRangeDeltas(dailyRows, wonDeals, since, until), [dailyRows, wonDeals, since, until] @@ -300,89 +438,73 @@ export default function MetricsDashboard({ const revenueMissing = hasCohort && periodTotals.revenue <= 0 && cohortFromLeads; const showWow = rangeDaySpan(since, until) <= 7; + const cplCrm = + hasCrm && crmPeriodTotal > 0 ? periodTotals.spend / crmPeriodTotal : 0; + const gapAdsCrm = hasCrm ? crmPeriodTotal - periodTotals.leads : 0; const organicPeriod = useMemo(() => { - const rows = organicRows.filter((r) => { - const d = (r.day || r.date || '').slice(0, 10); - return d >= since && d <= until; - }); - const gscClicks = rows.reduce((a, r) => a + (Number(r.gsc_clicks) || 0), 0); - const gscImpressions = rows.reduce( - (a, r) => a + (Number(r.gsc_impressions) || 0), - 0 - ); - const ga4Sessions = rows.reduce( - (a, r) => a + (Number(r.ga4_sessions) || 0), - 0 - ); - const ga4Conversions = rows.reduce( - (a, r) => a + (Number(r.ga4_conversions) || 0), - 0 - ); - const denom = periodTotals.leads + ga4Conversions; - return { - gscClicks, - gscImpressions, - ga4Sessions, - ga4Conversions, - organicShare: denom > 0 ? (ga4Conversions / denom) * 100 : 0, - }; + return organicPeriodSafe(organicRows, since, until, periodTotals.leads); }, [organicRows, since, until, periodTotals.leads]); + const seoCtr = + organicPeriod.gscImpressions > 0 + ? (organicPeriod.gscClicks / organicPeriod.gscImpressions) * 100 + : 0; + const cards: Card[] = [ { key: 'spend', label: 'Inversión ads', hint: 'Gasto Meta + Google del periodo', format: formatEur, - icon: , + icon: , }, { key: 'leads', label: 'Leads plataforma', - hint: 'Conversiones reportadas por ads (no CRM)', + hint: 'Conversiones que reportan los anuncios', format: formatNum, - icon: , + icon: , }, { key: 'cpl', label: 'CPL ads', - hint: 'Inversión ÷ leads plataforma', - format: (v) => `${formatNum(v)} €`, - icon: , + hint: 'Inversión ÷ leads de plataforma', + format: (v) => formatEurCents(v), + icon: , invert: true, }, { key: 'deals', - label: 'Ventas (CRM)', + label: 'Ventas CRM', hint: hasCohort ? cohortFromLeads - ? 'Contactos marcados cliente en el CRM (sin importe)' - : `Oportunidades ganadas · importe ${formatEur(periodTotals.revenue)}` - : 'Oportunidades ganadas en el CRM en el periodo', + ? 'Contactos marcados cliente (sin importe)' + : `Oportunidades ganadas · ${formatEur(periodTotals.revenue)}` + : 'Oportunidades ganadas en el periodo', format: formatNum, - icon: , + icon: , cohort: true, }, { key: 'cac', label: 'CAC', hint: hasCohort - ? `Inversión ads ÷ ${formatNum(ventasCount)} ventas` - : 'Inversión ÷ deals ganados (no leads)', - format: (v) => `${formatNum(v)} €`, - icon: , + ? `Inversión ÷ ${formatNum(ventasCount)} ventas` + : 'Inversión ÷ deals ganados', + format: (v) => formatEurCents(v), + icon: , invert: true, cohort: true, }, { key: 'roas', - label: 'ROAS ventas', + label: 'ROAS', hint: revenueMissing - ? 'Falta importe en el CRM para calcular ROAS' - : `Importe CRM (${formatEur(periodTotals.revenue)}) ÷ inversión ads (${formatEur(periodTotals.spend)})`, + ? 'Falta importe en el CRM' + : `Importe CRM ÷ inversión ads`, format: (v) => `${formatNum(v)}x`, - icon: , + icon: , cohort: true, }, ]; @@ -402,12 +524,9 @@ export default function MetricsDashboard({ .map(([date, v]) => ({ date, ...v })); }, [rangedDaily]); - const maxSpend = Math.max(1, ...dailyChart.map((d) => d.spend)); - const channelInsight = useMemo(() => { if (!attributionRows.length) return null; const paid = attributionRows.filter((r) => r.spend > 0); - const top = paid[0] || attributionRows[0]; const worstGap = [...attributionRows].sort( (a, b) => Math.abs(b.gap) - Math.abs(a.gap) )[0]; @@ -415,7 +534,7 @@ export default function MetricsDashboard({ const crmTotal = attributionRows.reduce((a, r) => a + r.crmLeads, 0); const crmPaid = paid.reduce((a, r) => a + r.crmLeads, 0); const crmOther = crmTotal - crmPaid; - return { top, worstGap, platTotal, crmTotal, crmPaid, crmOther }; + return { worstGap, platTotal, crmTotal, crmPaid, crmOther }; }, [attributionRows]); const crmName = useMemo(() => { @@ -437,480 +556,557 @@ export default function MetricsDashboard({ const outcomeCards = cards.filter((c) => ['deals', 'cac', 'roas'].includes(c.key) ); + const spendMax = Math.max(1, ...attributionRows.map((r) => r.spend)); + const heroRoasEmpty = !hasCohort; + const heroDeltas = rangeDeltas; return (
} /> - {sparse && ( -

- Histórico limitado: si faltan deltas o canales, el warehouse aún no - tiene semanas suficientes. -

+ {(sparse || !hasCohort || (cohortFromLeads && hasCohort)) && ( +
+ {sparse && ( +

+ Histórico limitado: el warehouse aún no tiene semanas suficientes + para algunos deltas. +

+ )} + {!hasCohort && ( +

+ Sin ventas en {rangeHint} + {wonDeals.length + ? ` (hay ${formatNum(wonDeals.length)} en el histórico — prueba 30 días o Mes).` + : '.'}{' '} + El gasto de ads no cuenta como venta. +

+ )} + {cohortFromLeads && hasCohort && ( +

+ Cohorte desde contactos «cliente» (no hay oportunidades ganadas + sincronizadas). El importe puede ir incompleto. +

+ )} +
)} - {!hasCohort && ( -

- Ventas / CAC / ROAS vacíos en {rangeHint}: no hay - deals ganados ni contactos con status «cliente» en este rango - {wonDeals.length - ? ` (sí hay ${formatNum(wonDeals.length)} en el histórico — prueba «30 días» o «Mes»).` - : '.'}{' '} - El gasto de ads no cuenta como venta. -

- )} - {cohortFromLeads && hasCohort && ( -

- Cohorte desde contactos del CRM con status «cliente» (no hay oportunidades - ganadas sincronizadas). El importe puede ir incompleto. -

- )} - -
-

- Cómo leer este panel -

-

- Publicidad: lo que Meta y Google dicen (gasto, - conversiones, CPL). No es el listado de clientes. -

-

- {crmName}: personas y ventas reales en el CRM. Un - lead de ads y un alta CRM casi nunca coinciden 1 a 1 (el gap lo - explica). -

-

- Web y SEO: visitas y clics orgánicos (Search Console - y Analytics). No se suman a los leads de anuncios. +

+

+ Resumen del periodo

+
+ + } + /> + + } + /> + + {formatNum(periodTotals.leads)} plat. + + } + /> + + ) : ( + sin cierres + ) + } + /> + pendiente CRM + ) : ( + + ) + } + /> +
-
-
-

- 1. Publicidad -

-

- Inversión y conversiones de las plataformas de ads · {rangeHint} +

+ + + Cómo leer ads, CRM y web + + mostrar + ocultar + + +
+

+ Publicidad: lo que Meta y + Google reportan. No es el listado de clientes. +

+

+ {crmName}: altas y ventas + reales. Un lead de ads y un alta CRM casi nunca coinciden 1 a 1. +

+

+ Web y SEO: Search Console + y Analytics. No se suman a los leads de anuncios.

-
- {adsCards.map((c) => { - const emptyCohort = Boolean(c.cohort) && !hasCohort; - const value = emptyCohort ? '—' : c.format(periodTotals[c.key]); - return ( -
-
- - {c.label} - - {c.icon} -
-

- {value} -

-

{c.hint}

- -
- ); - })} +
+ +
+ } + title="Publicidad" + hint={`Meta + Google Ads · ${rangeHint}`} + /> +
+ {adsCards.map((c) => ( + + ))} +
+
+ +

+ Inversión por día +

+ ({ date: d.date, value: d.spend }))} + color="bg-brand-500 hover:bg-brand-600" + formatValue={formatEur} + /> +
+ +

+ Leads de plataforma por día +

+ ({ date: d.date, value: d.leads }))} + color="bg-sky-500 hover:bg-sky-600" + formatValue={formatNum} + /> +
-
-
-

- 2. Negocio ({crmName}) -

-

- Altas y ventas del CRM · no uses estos números como si fueran los de - ads -

-
-
-
-

- Leads CRM -

-

- {hasCrm ? formatNum(crmPeriodTotal) : '—'} -

-

- Altas en {crmName} en el periodo -

-
-
-

- CPL CRM -

-

- {hasCrm && crmPeriodTotal > 0 - ? `${formatNum(periodTotals.spend / crmPeriodTotal)} €` - : '—'} -

-

- Inversión ads ÷ leads CRM -

-
-
-

- Gap ads vs CRM -

-

- {hasCrm - ? `${crmPeriodTotal - periodTotals.leads > 0 ? '+' : ''}${formatNum(crmPeriodTotal - periodTotals.leads)}` - : '—'} -

-

- CRM − conversiones ads · detalle por canal abajo -

-
-
-
- {outcomeCards.map((c) => { - const emptyCohort = Boolean(c.cohort) && !hasCohort; - const value = emptyCohort ? '—' : c.format(periodTotals[c.key]); - return ( -
-
- - {c.label} - - {c.icon} -
-

- {value} -

-

{c.hint}

- -
- ); - })} -
-
- -
-
-

3. Web y SEO

-

- Tráfico del sitio · mismo periodo · no es publicidad -

-
-
-
-

- Clics SEO -

-

- {formatNum(organicPeriod.gscClicks)} -

-

Search Console

-
-
-

- Impresiones SEO -

-

- {formatNum(organicPeriod.gscImpressions)} -

-

Apariciones en Google

-
-
-

- Sesiones web -

-

- {formatNum(organicPeriod.ga4Sessions)} -

-

Analytics (GA4)

-
-
-

- Conv. web -

-

- {formatNum(organicPeriod.ga4Conversions)} -

-

- Eventos GA4 · cuota {formatPct(organicPeriod.organicShare)} -

-
-
-
- -
-
-
-

- 4. Cruce por canal · ads vs {crmName} -

-

- Leads plat = conversiones que reporta el anuncio. Leads CRM = - altas en {crmName} según la fuente del contacto. Ventas = - oportunidades ganadas (importe {'>'} 0). Gap = CRM − plat. +

+ } + title={`Negocio · ${crmName}`} + hint="Altas y ventas reales · no uses estos números como si fueran los de ads" + /> +
+ + +
+

+ Gap ads vs CRM +

+

+ {hasCrm + ? `${gapAdsCrm > 0 ? '+' : ''}${formatNum(gapAdsCrm)}` + : '—'} +

+

+ CRM − conversiones ads. Positivo = más altas en CRM que las que + reporta el anuncio.

-

- Gap = CRM − plat -

- {channelInsight && ( -

- Resumen: plat {formatNum(channelInsight.platTotal)}{' '} - vs CRM {formatNum(channelInsight.crmTotal)} (gap{' '} - {channelInsight.crmTotal - channelInsight.platTotal > 0 ? '+' : ''} - {formatNum(channelInsight.crmTotal - channelInsight.platTotal)}). De - los CRM, {formatNum(channelInsight.crmPaid)} en canales con gasto - ads y {formatNum(channelInsight.crmOther)} en otras fuentes - (integrations, ecommerce, messenger…). Mayor desfase en{' '} - - {channelInsight.worstGap.channel} - - . -

- )} - {attributionRows.length === 0 ? ( -

- Sin series de canal en el periodo. -

- ) : ( -
- - - - - - - - - - - - - - - - - {attributionRows.map((row) => ( - - - - - - - - - - - - - ))} - - {attributionRows.length > 0 && ( - - - - - - - - - - - - - - - )} -
CanalInversiónLeads platLeads CRMGapVentasImporteROASCPL platCPL CRM
- {row.spend > 0 ? ( - - {row.channel} - - ) : ( - - {row.channel} - - )} - - {row.spend ? formatEur(row.spend) : '—'} - - {formatNum(row.platformLeads)} - - {hasCrm ? formatNum(row.crmLeads) : '—'} - - {hasCrm - ? `${row.gap > 0 ? '+' : ''}${formatNum(row.gap)}` - : '—'} - - {row.ventas > 0 ? formatNum(row.ventas) : '—'} - - {row.importe > 0 ? formatEur(row.importe) : '—'} - - {row.roas > 0 ? `${formatNum(row.roas)}x` : '—'} - - {row.cplPlatform - ? `${formatNum(row.cplPlatform)} €` - : '—'} - - {hasCrm && row.cplCrm - ? `${formatNum(row.cplCrm)} €` - : '—'} -
Total - {attributionTotals.spend - ? formatEur(attributionTotals.spend) - : '—'} - - {formatNum(attributionTotals.platformLeads)} - - {hasCrm ? formatNum(attributionTotals.crmLeads) : '—'} - - {hasCrm - ? `${attributionTotals.gap > 0 ? '+' : ''}${formatNum(attributionTotals.gap)}` - : '—'} - - {formatNum( - attributionTotals.ventas || ventasCount - )} - - {attributionTotals.importe > 0 || periodTotals.revenue > 0 - ? formatEur( - attributionTotals.importe || periodTotals.revenue - ) - : '—'} - - {attributionTotals.spend > 0 && - (attributionTotals.importe || periodTotals.revenue) > 0 - ? `${formatNum( - (attributionTotals.importe || - periodTotals.revenue) / attributionTotals.spend - )}x` - : '—'} - - {attributionTotals.platformLeads > 0 - ? `${formatNum( - attributionTotals.spend / - attributionTotals.platformLeads - )} €` - : '—'} - - {hasCrm && attributionTotals.crmLeads > 0 - ? `${formatNum( - attributionTotals.spend / - attributionTotals.crmLeads - )} €` - : '—'} -
-
- )} - -
-
-

- Ads · inversión / día -

- {dailyChart.length === 0 ? ( -

Sin filas diarias.

- ) : ( -
- {dailyChart.map((d) => ( -
-
-
- ))} -
- )} -
-
-

- Ads · leads plat. / día -

- {dailyChart.length === 0 ? ( -

Sin filas diarias.

- ) : ( -
- {dailyChart.map((d) => ( -
-
x.leads))) * - 80 - )}px`, - }} - /> -
- ))} -
- )} -
+
+ {outcomeCards.map((c) => { + const emptyCohort = Boolean(c.cohort) && !hasCohort; + return ( + + ); + })}
-

- Barras por día · mismo periodo del header -

+
+ } + title="Web y SEO" + hint="Tráfico orgánico · mismo periodo · no es publicidad" + /> +
+ } + /> + + } + /> + +
+
+ +
+ } + title={`Cruce por canal · ads vs ${crmName}`} + hint="Leads plat = el anuncio. Leads CRM = fuente del contacto. Gap = CRM − plat." + /> + {channelInsight && ( +
+
+

+ Plat. +

+

+ {formatNum(channelInsight.platTotal)} +

+
+
+

+ CRM +

+

+ {formatNum(channelInsight.crmTotal)} +

+
+
+

+ Con gasto ads +

+

+ {formatNum(channelInsight.crmPaid)} +

+
+
+

+ Otras fuentes +

+

+ {formatNum(channelInsight.crmOther)} +

+
+
+ )} + {channelInsight && ( +

+ Mayor desfase en{' '} + + {channelInsight.worstGap.channel} + + . Ecommerce, messenger e integrations no son fallos de ads. +

+ )} + + {attributionRows.length === 0 ? ( +

+ Sin series de canal en el periodo. +

+ ) : ( +
+ + + + + + + + + + + + + + + + + {attributionRows.map((row) => ( + 0 ? 'bg-white' : 'bg-gray-50/40' + }`} + > + + + + + + + + + + + + ))} + + {attributionRows.length > 0 && ( + + + + + + + + + + + + + + + )} +
Canal + Inversión + Plat.CRMGapVentasImporteROASCPL platCPL CRM
+ {row.spend > 0 ? ( + + {row.channel} + + ) : ( + + {row.channel} + + )} + + {row.spend ? ( +
+ + {formatEur(row.spend)} + +
+
+
+
+ ) : ( + — + )} +
+ {formatNum(row.platformLeads)} + + {hasCrm ? formatNum(row.crmLeads) : '—'} + + {hasCrm ? ( + + {row.gap > 0 ? '+' : ''} + {formatNum(row.gap)} + + ) : ( + '—' + )} + + {row.ventas > 0 ? formatNum(row.ventas) : '—'} + + {row.importe > 0 ? formatEur(row.importe) : '—'} + + {row.roas > 0 ? `${formatNum(row.roas)}x` : '—'} + + {row.cplPlatform + ? formatEurCents(row.cplPlatform) + : '—'} + + {hasCrm && row.cplCrm + ? formatEurCents(row.cplCrm) + : '—'} +
Total + {attributionTotals.spend + ? formatEur(attributionTotals.spend) + : '—'} + + {formatNum(attributionTotals.platformLeads)} + + {hasCrm ? formatNum(attributionTotals.crmLeads) : '—'} + + {hasCrm ? ( + + {attributionTotals.gap > 0 ? '+' : ''} + {formatNum(attributionTotals.gap)} + + ) : ( + '—' + )} + + {formatNum(attributionTotals.ventas || ventasCount)} + + {attributionTotals.importe > 0 || + periodTotals.revenue > 0 + ? formatEur( + attributionTotals.importe || periodTotals.revenue + ) + : '—'} + + {attributionTotals.spend > 0 && + (attributionTotals.importe || periodTotals.revenue) > 0 + ? `${formatNum( + (attributionTotals.importe || + periodTotals.revenue) / attributionTotals.spend + )}x` + : '—'} + + {attributionTotals.platformLeads > 0 + ? formatEurCents( + attributionTotals.spend / + attributionTotals.platformLeads + ) + : '—'} + + {hasCrm && attributionTotals.crmLeads > 0 + ? formatEurCents( + attributionTotals.spend / + attributionTotals.crmLeads + ) + : '—'} +
+
+ )} +
+
); } + +function HeroStat({ + label, + value, + chip, +}: { + label: string; + value: string; + chip: React.ReactNode; +}) { + return ( +
+

+ {label} +

+

+ {value} +

+
{chip}
+
+ ); +} + +function organicPeriodSafe( + organicRows: OrganicDailyRow[], + since: string, + until: string, + adsLeads = 0 +) { + const rows = organicRows.filter((r) => { + const d = (r.day || r.date || '').slice(0, 10); + return d >= since && d <= until; + }); + const gscClicks = rows.reduce((a, r) => a + (Number(r.gsc_clicks) || 0), 0); + const gscImpressions = rows.reduce( + (a, r) => a + (Number(r.gsc_impressions) || 0), + 0 + ); + const ga4Sessions = rows.reduce( + (a, r) => a + (Number(r.ga4_sessions) || 0), + 0 + ); + const ga4Conversions = rows.reduce( + (a, r) => a + (Number(r.ga4_conversions) || 0), + 0 + ); + const denom = adsLeads + ga4Conversions; + return { + gscClicks, + gscImpressions, + ga4Sessions, + ga4Conversions, + organicShare: denom > 0 ? (ga4Conversions / denom) * 100 : 0, + }; +}