feat: periodo global, métricas negocio, seguimiento notas, Drive/Social/Orgánico
Selector de fechas en header (con Personalizado) compartido en el dashboard; Métricas con Ventas/CAC/ROAS CRM, cruce canal y gráficas diarias; Seguimiento solo notas + sync Superhuman; Activos con navegación Drive; Social con métricas IG/FB; Orgánico con prioridades SEO/GEO expertas. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -9,6 +9,9 @@ import {
|
||||
Loader2,
|
||||
Share2,
|
||||
ExternalLink,
|
||||
Users,
|
||||
Eye,
|
||||
Image as ImageIcon,
|
||||
} from 'lucide-react';
|
||||
import { PageHeader } from '@/components/ui';
|
||||
|
||||
@@ -17,10 +20,26 @@ type Integ = {
|
||||
status: string;
|
||||
external_account_id?: string | null;
|
||||
label?: string | null;
|
||||
last_sync_at?: string | null;
|
||||
meta?: { public_url?: string; account_name?: string; warning?: string } | null;
|
||||
};
|
||||
|
||||
type MetricsFacebook = {
|
||||
name?: string;
|
||||
fan_count?: number;
|
||||
followers_count?: number;
|
||||
link?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type MetricsIg = {
|
||||
username?: string;
|
||||
followers_count?: number;
|
||||
media_count?: number;
|
||||
follows_count?: number;
|
||||
error?: string;
|
||||
insights?: { data?: { name?: string; values?: { value?: number }[] }[] };
|
||||
};
|
||||
|
||||
const CHANNELS: {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -32,6 +51,11 @@ const CHANNELS: {
|
||||
{ id: 'tiktok', label: 'TikTok', icon: Share2 },
|
||||
];
|
||||
|
||||
function n(v?: number) {
|
||||
if (v == null || !Number.isFinite(v)) return '—';
|
||||
return new Intl.NumberFormat('es-ES').format(v);
|
||||
}
|
||||
|
||||
export default function SocialDashboard({
|
||||
tenantLabel,
|
||||
isAgency,
|
||||
@@ -41,6 +65,11 @@ export default function SocialDashboard({
|
||||
}) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [integs, setIntegs] = useState<Integ[]>([]);
|
||||
const [metrics, setMetrics] = useState<{
|
||||
facebook?: MetricsFacebook;
|
||||
instagram?: MetricsIg;
|
||||
warning?: string;
|
||||
}>({});
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -51,7 +80,10 @@ export default function SocialDashboard({
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (cancelled) return;
|
||||
if (!res.ok) setErr(data.error || 'No se pudo cargar Social');
|
||||
else setIntegs(data.integrations || []);
|
||||
else {
|
||||
setIntegs(data.integrations || []);
|
||||
setMetrics(data.metrics || {});
|
||||
}
|
||||
setLoading(false);
|
||||
})();
|
||||
return () => {
|
||||
@@ -60,13 +92,17 @@ export default function SocialDashboard({
|
||||
}, []);
|
||||
|
||||
const by = new Map(integs.map((i) => [i.provider, i]));
|
||||
const ig = metrics.instagram;
|
||||
const fb = metrics.facebook;
|
||||
const igReach =
|
||||
ig?.insights?.data?.find((d) => d.name === 'reach')?.values?.[0]?.value;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
eyebrow={`Social · ${tenantLabel}`}
|
||||
title="Redes sociales"
|
||||
description="Perfiles orgánicos (FB, IG, LinkedIn, TikTok). Distinto de Ads pagados. Publicación y métricas live: siguiente fase."
|
||||
description="Perfiles orgánicos y métricas de audiencia. Paid ads están en Ads / Métricas."
|
||||
/>
|
||||
|
||||
{err && (
|
||||
@@ -74,95 +110,167 @@ export default function SocialDashboard({
|
||||
{err}
|
||||
</p>
|
||||
)}
|
||||
{metrics.warning && (
|
||||
<p className="text-xs text-amber-800 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
|
||||
{metrics.warning}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<p className="text-sm text-gray-500 flex items-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Cargando…
|
||||
</p>
|
||||
) : (
|
||||
<ul className="grid gap-3 sm:grid-cols-2">
|
||||
{CHANNELS.map((ch) => {
|
||||
const row = by.get(ch.id);
|
||||
const connected =
|
||||
row?.status === 'connected' && Boolean(row.external_account_id);
|
||||
const Icon = ch.icon;
|
||||
const url =
|
||||
row?.meta?.public_url ||
|
||||
(row?.external_account_id?.startsWith('http')
|
||||
? row.external_account_id
|
||||
: null);
|
||||
return (
|
||||
<li
|
||||
key={ch.id}
|
||||
className="rounded-xl border border-gray-200 bg-white p-4 space-y-2"
|
||||
<>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{[
|
||||
{
|
||||
label: 'Seguidores IG',
|
||||
value: n(ig?.followers_count),
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
label: 'Publicaciones IG',
|
||||
value: n(ig?.media_count),
|
||||
icon: ImageIcon,
|
||||
},
|
||||
{
|
||||
label: 'Fans / seguidores FB',
|
||||
value: n(fb?.followers_count ?? fb?.fan_count),
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
label: 'Alcance IG (día)',
|
||||
value: n(igReach),
|
||||
icon: Eye,
|
||||
},
|
||||
].map((k) => (
|
||||
<div
|
||||
key={k.label}
|
||||
className="rounded-xl border border-gray-200 bg-white p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="w-4 h-4 text-gray-700" />
|
||||
<p className="text-sm font-semibold text-gray-900">
|
||||
{ch.label}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`text-[10px] font-bold uppercase tracking-wide px-2 py-0.5 rounded ${
|
||||
connected
|
||||
? 'bg-emerald-50 text-emerald-700'
|
||||
: 'bg-gray-100 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{connected ? 'Conectado' : 'Sin enlazar'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 truncate">
|
||||
{row?.meta?.account_name ||
|
||||
row?.label ||
|
||||
row?.external_account_id ||
|
||||
'—'}
|
||||
</p>
|
||||
{row?.meta?.warning && (
|
||||
<p className="text-[11px] text-amber-800 bg-amber-50 border border-amber-200 rounded-lg px-2 py-1">
|
||||
{row.meta.warning}
|
||||
<div className="flex items-center justify-between text-gray-600 mb-1">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider">
|
||||
{k.label}
|
||||
</p>
|
||||
)}
|
||||
{url && (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-[11px] text-brand-600 hover:underline"
|
||||
>
|
||||
Abrir perfil <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)}
|
||||
<k.icon className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
<p className="text-xl font-bold text-gray-900 tabular-nums">
|
||||
{k.value}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(ig?.error || fb?.error) && (
|
||||
<p className="text-xs text-amber-800 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
|
||||
{ig?.error && `IG: ${ig.error} `}
|
||||
{fb?.error && `FB: ${fb.error}`}
|
||||
{' · '}
|
||||
Suele faltar Page token o permisos de insights en el system user.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<section className="rounded-xl border border-gray-200 bg-white p-4 space-y-2">
|
||||
<h3 className="text-sm font-semibold text-gray-900">
|
||||
Insights (social expert)
|
||||
</h3>
|
||||
<ul className="text-xs text-gray-700 space-y-1.5 list-disc pl-4">
|
||||
<li>
|
||||
Prioriza contenido que convierta a lead/venta (CTA + landings
|
||||
medidas en CRM), no solo vanity metrics.
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<li>
|
||||
Si seguidores IG ≫ fans FB, concentra producción en Reels /
|
||||
carruseles y usa FB como amplificación ligera.
|
||||
</li>
|
||||
<li>
|
||||
Revisa Calendario editorial: cada pieza debería tener objetivo
|
||||
(awareness / consideración / conversión).
|
||||
</li>
|
||||
<li>
|
||||
Menciones brand: próxima fase con listening; hoy el foco es
|
||||
audiencia propia + engagement on-profile.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<ul className="grid gap-3 sm:grid-cols-2">
|
||||
{CHANNELS.map((ch) => {
|
||||
const row = by.get(ch.id);
|
||||
const connected =
|
||||
row?.status === 'connected' && Boolean(row.external_account_id);
|
||||
const Icon = ch.icon;
|
||||
const url =
|
||||
row?.meta?.public_url ||
|
||||
(row?.external_account_id?.startsWith('http')
|
||||
? row.external_account_id
|
||||
: null);
|
||||
return (
|
||||
<li
|
||||
key={ch.id}
|
||||
className="rounded-xl border border-gray-200 bg-white p-4 space-y-2"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="w-4 h-4 text-gray-700" />
|
||||
<p className="text-sm font-semibold text-gray-900">
|
||||
{ch.label}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`text-[10px] font-bold uppercase tracking-wide px-2 py-0.5 rounded ${
|
||||
connected
|
||||
? 'bg-emerald-50 text-emerald-700'
|
||||
: 'bg-gray-100 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{connected ? 'Conectado' : 'Sin enlazar'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 truncate">
|
||||
{row?.meta?.account_name ||
|
||||
row?.label ||
|
||||
row?.external_account_id ||
|
||||
'—'}
|
||||
</p>
|
||||
{row?.meta?.warning && (
|
||||
<p className="text-[11px] text-amber-800 bg-amber-50 border border-amber-200 rounded-lg px-2 py-1">
|
||||
{row.meta.warning}
|
||||
</p>
|
||||
)}
|
||||
{url && (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-[11px] text-brand-600 hover:underline"
|
||||
>
|
||||
Abrir perfil <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-4 space-y-2">
|
||||
<p className="text-sm font-semibold text-gray-900">Próximo en Social</p>
|
||||
<ul className="text-xs text-gray-600 space-y-1 list-disc pl-4">
|
||||
<li>Métricas orgánicas (alcance, engagement) vía Graph API</li>
|
||||
<li>Últimas publicaciones y rendimiento</li>
|
||||
<li>Puente con Calendario editorial</li>
|
||||
</ul>
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link
|
||||
href="/dashboard/calendario"
|
||||
className="text-xs font-semibold px-3 py-1.5 rounded-lg border border-gray-200 text-gray-800 hover:bg-gray-50"
|
||||
>
|
||||
Calendario editorial
|
||||
</Link>
|
||||
{isAgency && (
|
||||
<Link
|
||||
href="/dashboard/calendario"
|
||||
href="/dashboard/agencia"
|
||||
className="text-xs font-semibold px-3 py-1.5 rounded-lg border border-gray-200 text-gray-800 hover:bg-gray-50"
|
||||
>
|
||||
Calendario editorial
|
||||
Emparejar en Agencia
|
||||
</Link>
|
||||
{isAgency && (
|
||||
<Link
|
||||
href="/dashboard/agencia"
|
||||
className="text-xs font-semibold px-3 py-1.5 rounded-lg border border-gray-200 text-gray-800 hover:bg-gray-50"
|
||||
>
|
||||
Emparejar en Agencia / ficha
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user