-
- Campañas activas
+
+
+
+ Campañas
-
{title}
+
+ {title}
+
{rows.length === 0 ? (
- {empty}
+ {empty}
) : (
-
+
- Campaña
- Gasto
- Clics
- Conv.
- CPL
+ Campaña
+ Gasto
+ Clics
+ Conv.
+ CPL
{rows.slice(0, 25).map((r) => (
-
-
+
+
{r.name}
-
- {formatEur(r.spend)}
+
+
-
+
{formatNum(r.clicks)}
-
+
{formatNum(r.conversions)}
-
+
{r.cpl != null ? formatEurCents(r.cpl) : '—'}
@@ -684,86 +690,78 @@ export default function AdTrackerClient({
];
return (
-
+
}
/>
- {/* Tabs */}
-
- {(
- [
- { id: 'resumen', label: 'Resumen' },
- { id: 'meta', label: 'Meta Ads' },
- { id: 'google', label: 'Google Ads' },
- ] as const
- ).map((t) => (
- setTab(t.id)}
- className={`px-3 py-1.5 rounded-lg text-xs font-semibold ${
- tab === t.id
- ? 'bg-gray-900 text-white'
- : 'bg-gray-100 text-gray-600'
- }`}
- >
- {t.label}
-
- ))}
-
+
{tab === 'resumen' && (
-
-
- Resumen de cuenta
-
-
- Meta + Google, juntos
-
-
- Gasto, impresiones, clics y conversiones sumados. CTR, CPC y CPL
- se recalculan sobre el total (no se suman). Ventana:{' '}
- {range.since} → {range.until}.
-
-
+
+
+
+
+
+
+
+
-
-
-
+
+ Gasto, impresiones, clics y conversiones se suman entre Meta y
+ Google. CTR, CPC y CPL se recalculan sobre el total (no se
+ suman porcentajes).
+
+
+
+
+
-
-
-
+
-
-
-
+
-
+
@@ -773,17 +771,21 @@ export default function AdTrackerClient({
title={`Google ${googleShare.toFixed(0)}%`}
/>
+
+ Reparto de inversión · Meta {metaShare.toFixed(0)}% · Google{' '}
+ {googleShare.toFixed(0)}%
+
setTab('meta')}
- className="rounded-xl border border-gray-200 bg-white p-4 text-left hover:border-[#7C66B8]/50"
+ className="rounded-xl border border-gray-200 bg-white p-4 text-left hover:border-brand-400 hover:bg-brand-50/40 transition-colors"
>
-
+
Meta Ads
-
+
{formatEur(meta.spend)}
@@ -796,10 +798,10 @@ export default function AdTrackerClient({
onClick={() => setTab('google')}
className="rounded-xl border border-gray-200 bg-white p-4 text-left hover:border-sky-500/50"
>
-
+
Google Ads
-
+
{formatEur(google.spend)}
@@ -895,42 +897,46 @@ export default function AdTrackerClient({
{tab === 'meta' && (
-
-
-
+
+
+
+
+ 0
+ ? `${((100 * metaClicksAll) / meta.impressions).toFixed(2)}%`
+ : '—'
+ }
+ />
+
+
+
+
-
-
+
-
- 0
- ? `${((100 * metaClicksAll) / meta.impressions).toFixed(2)}%`
- : '—'
- }
- />
-
-
-
-
+
-
-
-
+
+
+
+
+
+
+
+
+
-
-
-
-
+
-
-
+
= 8) return 'text-emerald-400';
- if (rate >= 3) return 'text-amber-400';
- if (rate > 0) return 'text-orange-400';
+ if (rate >= 8) return 'text-emerald-800';
+ if (rate >= 3) return 'text-amber-800';
+ if (rate > 0) return 'text-orange-800';
return 'text-gray-500';
}
const LENS_HELP: Record = {
created:
- 'Altas: leads cuya fecha de creación en Clientify cae en el periodo del header.',
+ 'Altas: leads cuya fecha de creación en el CRM cae en el periodo del header.',
status:
- 'Conversiones: usa la fecha de cambio de status (→ cliente). Requiere columnas status_changed_at (sql/leads_status_timing.sql).',
+ 'Conversiones: usa la fecha de cambio de status (→ cliente). Requiere columnas status_changed_at.',
snapshot:
- 'Estado actual: ignora el periodo para el funnel; muestra todos los contactos como están ahora en el warehouse.',
+ 'Estado actual: ignora el periodo para el funnel; muestra todos los contactos como están ahora.',
};
export default function CrmDashboard({
@@ -100,206 +108,214 @@ export default function CrmDashboard({
const otherChannels = channelRows.filter(
(r) => !['meta', 'google'].includes(r.channel)
);
+ const leadMax = Math.max(1, ...channelRows.map((r) => r.leads));
+ const statusMax = Math.max(1, ...statusRows.map((r) => r.count));
return (
}
/>
-
-
- Lente CRM
+
+
+ Lente
-
- {(
- [
- ['created', 'Altas en periodo'],
- ['status', 'Conversiones en periodo'],
- ['snapshot', 'Estado actual'],
- ] as const
- ).map(([id, lab]) => (
- setLens(id)}
- className={`px-3 py-1.5 rounded-lg text-xs font-semibold border transition-colors ${
- lens === id
- ? 'bg-brand-500 text-white border-brand-500'
- : 'bg-white text-gray-700 border-gray-200 hover:border-brand-300'
- }`}
- >
- {lab}
-
- ))}
-
+
-
{LENS_HELP[lens]}
+
+
+ {LENS_HELP[lens]}
+
+ El % a cliente es del CRM, no de ads. La inversión por canal es solo
+ referencia del periodo.
+
+
+
{lens === 'status' && !hasStatusTiming ? (
-
- Aún no hay status_changed_at en
- warehouse — aplicando filtro por alta. Ejecuta{' '}
- sql/leads_status_timing.sql en
- Supabase y deja que los webhooks actualicen contactos.
+
+ Aún no hay fecha de cambio de status en warehouse — filtrando por
+ alta. Ejecuta sql/leads_status_timing.sql{' '}
+ y deja que los webhooks actualicen contactos.
) : null}
{!crmLeads.length ? (
-
- Sin leads Clientify en este contexto. Importa el histórico o activa el
- webhook (`?tenant=…`). En portafolio, elige un cliente en Contexto.
+
+ Sin leads CRM en este contexto. Importa el histórico o activa el
+ webhook. En portafolio, elige un cliente en Contexto.
) : null}
-
- {[
- {
- label: 'Leads CRM',
- value: formatNum(funnel.total),
- icon:
,
- },
- {
- label: 'Clientes (ganado)',
- value: formatNum(funnel.cliente),
- icon:
,
- },
- {
- label: '% a cliente',
- value: `${funnel.clienteRate.toFixed(1)}%`,
- icon:
,
- },
- {
- label: 'En oportunidad',
- value: formatNum(funnel.enOportunidad),
- icon:
,
- },
- {
- label: 'Lead frío',
- value: formatNum(funnel.leadFrio),
- icon:
,
- },
- {
- label: 'Lead perdido',
- value: formatNum(funnel.leadPerdido),
- icon:
,
- },
- ].map((s) => (
-
-
-
- {s.label}
+
+
+
+
+ {funnel.enOportunidad} en oportunidad
- {s.icon}
-
-
- {s.value}
-
-
- ))}
+ }
+ />
+
+
+ {formatNum(funnel.leadFrio)} fríos
+
+ }
+ />
+
+
+
+
+ }
+ />
+ }
+ />
+ }
+ />
-
-
-
- % a cliente por canal
-
-
- Estado Clientify = cliente {' '}
- (ganado). Ads spend del periodo es referencia.
-
-
- {!channelRows.length ? (
-
- Sin leads en esta lente/periodo.
-
- ) : (
-
-
-
-
- Canal
- Leads
- Clientes
- % a cliente
- Inversión ads
-
-
-
- {[...paidChannels, ...otherChannels].map((row) => (
-
-
- {row.channel}
-
-
- {formatNum(row.leads)}
-
-
- {formatNum(row.cliente)}
-
-
- {row.clienteRate.toFixed(1)}%
-
-
- {spendByChannel.get(row.channel)
- ? formatEur(spendByChannel.get(row.channel)!)
- : '—'}
-
+
+ }
+ title="% a cliente por canal"
+ hint="Estado ganado en el CRM. Ads spend del periodo es referencia."
+ />
+
+ {!channelRows.length ? (
+
+ Sin leads en esta lente/periodo.
+
+ ) : (
+
+
+
+
+ Canal
+
+ Leads
+
+ Clientes
+ % a cliente
+ Inversión ads
- ))}
-
-
-
- )}
-
- La inversión por canal (si hay ads) es solo referencia; el % a cliente
- es CRM. Lente activa: {lens}.
-
+
+
+ {[...paidChannels, ...otherChannels].map((row) => (
+
+
+ {row.channel}
+
+
+
+
+
+ {formatNum(row.cliente)}
+
+
+ {row.clienteRate.toFixed(1)}%
+
+
+ {spendByChannel.get(row.channel)
+ ? formatEur(spendByChannel.get(row.channel)!)
+ : '—'}
+
+
+ ))}
+
+
+
+ )}
+
-
- Desglose de status
- {!statusRows.length ? (
- Sin datos.
- ) : (
-
-
-
-
- Status
- N
- %
-
-
-
- {statusRows.map((row) => (
-
- {row.status}
-
- {formatNum(row.count)}
-
-
- {row.pct.toFixed(1)}%
-
+
+ }
+ title="Desglose de status"
+ hint="Distribución de contactos en el warehouse con la lente activa"
+ />
+
+ {!statusRows.length ? (
+ Sin datos.
+ ) : (
+
+
+
+
+ Status
+ N
+ %
- ))}
-
-
-
- )}
+
+
+ {statusRows.map((row) => (
+
+ {row.status}
+
+
+
+
+ {row.pct.toFixed(1)}%
+
+
+ ))}
+
+
+
+ )}
+
);
diff --git a/src/components/OrganicoDashboard.tsx b/src/components/OrganicoDashboard.tsx
index 9d6da40..af27a21 100644
--- a/src/components/OrganicoDashboard.tsx
+++ b/src/components/OrganicoDashboard.tsx
@@ -33,8 +33,15 @@ import {
ShieldAlert,
Sparkles,
} from 'lucide-react';
-import { PageHeader, SegmentedControl } from '@/components/ui';
+import { PageHeader, SegmentedControl, Surface } from '@/components/ui';
import SyncNowButton from '@/components/SyncNowButton';
+import {
+ HelpDetails,
+ HeroBand,
+ HeroStat,
+ SparkBars,
+ StatMini,
+} from '@/components/dash-ui';
type Props = {
label: string;
@@ -55,32 +62,13 @@ function Kpi({
label,
value,
hint,
- accent,
}: {
label: string;
value: string;
hint?: string;
accent?: boolean;
}) {
- return (
-
-
- {label}
-
-
{value}
- {hint ?
{hint}
: null}
-
- );
+ return
;
}
export default function OrganicoDashboard({
@@ -212,8 +200,6 @@ export default function OrganicoDashboard({
sessions: Number(r.ga4_organic_sessions) || 0,
}));
}, [organic]);
- const maxClicks = Math.max(1, ...seoDaily.map((d) => d.clicks));
- const maxSessions = Math.max(1, ...seoDaily.map((d) => d.sessions));
const section = useMemo(
() =>
@@ -431,9 +417,9 @@ export default function OrganicoDashboard({
return (
@@ -462,6 +448,13 @@ export default function OrganicoDashboard({
)}
+
+
+ Search Console son clics e impresiones en Google. GA4 son sesiones y
+ conversiones del sitio. No se suman a los leads de anuncios.
+
+
+
{/* Connections */}
{[
@@ -576,10 +569,27 @@ export default function OrganicoDashboard({
{/* Paneles SEO por sección */}
{section.id === 'seo-overview' && (
<>
+
+
+
+
+
+
+
+
-
-
-
-
{seoDaily.length > 0 && (
-
-
- Clics GSC · 28d
+
+
+ Clics GSC
-
- {seoDaily.map((d) => (
-
- ))}
-
-
-
-
- Sesiones orgánicas GA4 · 28d
+ ({
+ date: d.day,
+ value: d.clicks,
+ }))}
+ color="bg-emerald-500 hover:bg-emerald-600"
+ formatValue={(v) => formatNum(v)}
+ />
+
+
+
+ Sesiones orgánicas GA4
-
- {seoDaily.map((d) => (
-
- ))}
-
-
+
({
+ date: d.day,
+ value: d.sessions,
+ }))}
+ color="bg-sky-500 hover:bg-sky-600"
+ formatValue={(v) => formatNum(v)}
+ />
+
)}
{totals.days === 0 && (
@@ -741,36 +742,32 @@ export default function OrganicoDashboard({
{seoDaily.length > 0 ? (
-
-
+
+
Clics · evolución
-
- {seoDaily.map((d) => (
-
- ))}
-
-
-
-
+ ({
+ date: d.day,
+ value: d.clicks,
+ }))}
+ color="bg-emerald-500 hover:bg-emerald-600"
+ formatValue={(v) => formatNum(v)}
+ />
+
+
+
Sesiones orgánicas · evolución
-
- {seoDaily.map((d) => (
-
- ))}
-
-
+
({
+ date: d.day,
+ value: d.sessions,
+ }))}
+ color="bg-sky-500 hover:bg-sky-600"
+ formatValue={(v) => formatNum(v)}
+ />
+
) : (
@@ -1244,15 +1241,14 @@ function SeoTables({
function SectionHero({ section }: { section: OrganicoSection }) {
return (
-
-
- {section.group}
-
-
{section.label}
+
+
+ {section.label}
+
{section.blurb}
-
+
);
}
diff --git a/src/components/SocialDashboard.tsx b/src/components/SocialDashboard.tsx
index a02883c..1f93561 100644
--- a/src/components/SocialDashboard.tsx
+++ b/src/components/SocialDashboard.tsx
@@ -9,16 +9,23 @@ import {
Loader2,
Share2,
ExternalLink,
- Users,
Eye,
- Image as ImageIcon,
Heart,
MessageCircle,
Repeat2,
+ BarChart3,
} from 'lucide-react';
import { PageHeader } from '@/components/ui';
import SyncNowButton from '@/components/SyncNowButton';
import { useDateRange } from '@/components/DateRangeContext';
+import {
+ HelpDetails,
+ HeroBand,
+ HeroStat,
+ SectionHead,
+ SparkBars,
+ StatMini,
+} from '@/components/dash-ui';
type Integ = {
provider: string;
@@ -258,46 +265,6 @@ function fmtDate(iso?: string | null) {
}
}
-function shortDay(day: string) {
- const d = day.slice(5); // MM-DD
- return d.replace('-', '/');
-}
-
-function MiniBars({
- series,
- color = 'bg-brand-400/80',
- empty = 'Sin serie en el periodo',
-}: {
- series?: DayPoint[];
- color?: string;
- empty?: string;
-}) {
- const pts = series || [];
- if (!pts.length) {
- return
{empty}
;
- }
- const max = Math.max(1, ...pts.map((p) => p.value));
- return (
-
- {pts.map((p) => {
- const pct = Math.max(6, Math.round((p.value / max) * 100));
- return (
-
- );
- })}
-
- );
-}
-
function seriesDelta(series?: DayPoint[]): {
abs: number;
pct: number | null;
@@ -326,38 +293,38 @@ function ChartCard({
}) {
const delta = seriesDelta(series);
return (
-
+
-
+
{title}
- {hint ?
{hint}
: null}
+ {hint ?
{hint}
: null}
-
{n(total)}
+
+ {n(total)}
+
{delta ? (
{delta.up ? '↑' : '↓'} {n(Math.abs(delta.abs))}
{delta.pct != null
? ` (${delta.pct > 0 ? '+' : ''}${delta.pct.toFixed(0)}%)`
: ''}
- inicio→fin
) : null}
-
- {series && series.length > 1 ? (
-
- {shortDay(series[0].day)}
- {shortDay(series[series.length - 1].day)}
-
- ) : null}
+
({ date: p.day, value: p.value }))}
+ color={color}
+ formatValue={(v) => n(v)}
+ empty="Sin serie en el periodo"
+ />
);
}
@@ -410,66 +377,43 @@ export default function SocialDashboard({
[posts]
);
- const kpiCards = [
+ const kpiIg = [
{
- label: 'Seguidores IG (global)',
- value: n(ig?.followers_count),
- icon: Users,
- },
- {
- label: 'Publicaciones IG (global)',
+ label: 'Publicaciones IG',
value: n(ig?.media_count),
- icon: ImageIcon,
- },
- {
- label: 'Alcance IG (periodo)',
- value: n(ig?.igInsights?.reach),
- icon: Eye,
+ hint: 'Stock del perfil',
},
{
label: 'Visualizaciones IG',
value: n(ig?.igInsights?.impressions),
- icon: Eye,
+ hint: 'Periodo',
},
{
- label: 'Visitas perfil IG',
+ label: 'Visitas perfil',
value: n(ig?.igInsights?.profileViews),
- icon: Users,
- },
- {
- label: 'Interacciones IG',
- value: n(ig?.igInsights?.interactions ?? ig?.igInsights?.accountsEngaged),
- icon: Heart,
+ hint: 'Instagram',
},
{
label: 'Clics web IG',
value: n(ig?.igInsights?.websiteClicks),
- icon: ExternalLink,
+ hint: 'Desde el perfil',
},
{
- label: 'Δ seguidores IG',
+ label: 'Δ seguidores',
value: n(ig?.igInsights?.followerDelta),
- icon: Users,
- },
- {
- label: 'Fans FB (global)',
- value: n(fb?.followers_count ?? fb?.fan_count ?? fb?.pageInsights?.fans),
- icon: Users,
- },
- {
- label: 'Alcance FB (periodo)',
- value: n(fb?.pageInsights?.reach),
- icon: Eye,
+ hint: 'Cambio en el periodo',
},
+ ];
+ const kpiFb = [
{
label: 'Visualizaciones FB',
value: n(fb?.pageInsights?.impressions),
- icon: Eye,
+ hint: 'Periodo',
},
{
- label: 'Engagement posts FB',
+ label: 'Engagement posts',
value: n(fb?.pageInsights?.postEngagements),
- icon: Heart,
+ hint: 'Facebook',
},
];
@@ -567,11 +511,11 @@ export default function SocialDashboard({
];
return (
-
+
}
/>
@@ -592,22 +536,55 @@ export default function SocialDashboard({
) : (
<>
-
- {kpiCards.map((k) => (
-
+
+
+
+
+
+
+
+
+
+
+
+
+ Los totales de alcance e interacciones son del periodo del
+ header. Seguidores y fans son stock actual del perfil.
+
+
+ En los gráficos, la flecha es el cambio del primer al último día
+ (no es un MoM).
+
+
+
+
+ {kpiIg.map((k) => (
+
+ ))}
+
+
+ {kpiFb.map((k) => (
+
))}
@@ -631,16 +608,12 @@ export default function SocialDashboard({
)}
-
-
-
- Evolución diaria · {rangeLabel}
-
-
- Barras = valor diario. Flecha = cambio del primer al último día
- del periodo. Totales arriba a la derecha.
-
-
+
+ }
+ title="Evolución diaria"
+ hint={`${rangeLabel} · barras = valor diario · flecha = primer→último día`}
+ />
{chartBlocks.map((c) => (
@@ -648,15 +621,12 @@ export default function SocialDashboard({
-
-
-
- Publicaciones Instagram
-
-
- Solo IG (feed, reels y carruseles). Sin posts de Facebook.
-
-
+
+ }
+ title="Publicaciones Instagram"
+ hint="Feed, reels y carruseles. Sin posts de Facebook."
+ />
{visiblePosts.length === 0 ? (
diff --git a/src/components/dash-ui.tsx b/src/components/dash-ui.tsx
new file mode 100644
index 0000000..3a0ee77
--- /dev/null
+++ b/src/components/dash-ui.tsx
@@ -0,0 +1,197 @@
+'use client';
+
+import React from 'react';
+import { Info } from 'lucide-react';
+
+/** Banda de resumen del periodo — misma que Métricas. */
+export function HeroBand({
+ eyebrow = 'Resumen del periodo',
+ children,
+}: {
+ eyebrow?: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+ {eyebrow}
+
+ {children}
+
+ );
+}
+
+export function HeroStat({
+ label,
+ value,
+ chip,
+}: {
+ label: string;
+ value: string;
+ chip?: React.ReactNode;
+}) {
+ return (
+
+
+ {label}
+
+
+ {value}
+
+ {chip ?
{chip}
: null}
+
+ );
+}
+
+export function SectionHead({
+ icon,
+ title,
+ hint,
+}: {
+ icon: React.ReactNode;
+ title: string;
+ hint?: string;
+}) {
+ return (
+
+
+ {icon}
+
+
+
+ {title}
+
+ {hint ?
{hint}
: null}
+
+
+ );
+}
+
+export function StatMini({
+ label,
+ value,
+ hint,
+ icon,
+}: {
+ label: string;
+ value: string;
+ hint?: string;
+ icon?: React.ReactNode;
+}) {
+ return (
+
+
+ {icon}
+ {label}
+
+
+ {value}
+
+ {hint ?
{hint}
: null}
+
+ );
+}
+
+export function HelpDetails({
+ title,
+ children,
+}: {
+ title: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+ {title}
+
+ mostrar
+ ocultar
+
+
+
+ {children}
+
+
+ );
+}
+
+export function SparkBars({
+ points,
+ color,
+ formatValue,
+ empty = 'Sin filas diarias.',
+}: {
+ points: { date: string; value: number }[];
+ color: string;
+ formatValue: (v: number) => string;
+ empty?: string;
+}) {
+ if (points.length === 0) {
+ return {empty}
;
+ }
+ 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)}
+
+
+ );
+}
+
+export function tableHeadClass() {
+ return 'border-b border-gray-200 bg-gray-50/80 text-[11px] uppercase tracking-wider text-gray-500';
+}
+
+export function ShareBar({
+ value,
+ max,
+ label,
+}: {
+ value: number;
+ max: number;
+ label: string;
+}) {
+ if (!value) return — ;
+ return (
+
+ );
+}