feat: Ads KPIs completos, Social feed+analytics, Orgánico por sección
Meta/Google con las mismas métricas que Resumen y solo campañas/ads con actividad en el periodo; Social con insights FB/IG y preview de posts (likes/comentarios/shares); Orgánico con paneles distintos por ítem del menú y prioridades/notas solo en visión general. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -12,6 +12,9 @@ import {
|
||||
Users,
|
||||
Eye,
|
||||
Image as ImageIcon,
|
||||
Heart,
|
||||
MessageCircle,
|
||||
Repeat2,
|
||||
} from 'lucide-react';
|
||||
import { PageHeader } from '@/components/ui';
|
||||
|
||||
@@ -29,6 +32,14 @@ type MetricsFacebook = {
|
||||
followers_count?: number;
|
||||
link?: string;
|
||||
error?: string;
|
||||
postsError?: string;
|
||||
pageInsights?: {
|
||||
impressions?: number | null;
|
||||
reach?: number | null;
|
||||
postEngagements?: number | null;
|
||||
fans?: number | null;
|
||||
pageViews?: number | null;
|
||||
};
|
||||
};
|
||||
|
||||
type MetricsIg = {
|
||||
@@ -37,7 +48,29 @@ type MetricsIg = {
|
||||
media_count?: number;
|
||||
follows_count?: number;
|
||||
error?: string;
|
||||
insights?: { data?: { name?: string; values?: { value?: number }[] }[] };
|
||||
postsError?: string;
|
||||
igInsights?: {
|
||||
reach?: number | null;
|
||||
impressions?: number | null;
|
||||
profileViews?: number | null;
|
||||
websiteClicks?: number | null;
|
||||
accountsEngaged?: number | null;
|
||||
};
|
||||
};
|
||||
|
||||
type SocialPost = {
|
||||
id: string;
|
||||
platform: 'facebook' | 'instagram';
|
||||
message?: string;
|
||||
mediaUrl?: string | null;
|
||||
permalink?: string | null;
|
||||
createdAt?: string | null;
|
||||
likes: number;
|
||||
comments: number;
|
||||
shares: number;
|
||||
reach?: number | null;
|
||||
impressions?: number | null;
|
||||
mediaType?: string | null;
|
||||
};
|
||||
|
||||
const CHANNELS: {
|
||||
@@ -51,11 +84,24 @@ const CHANNELS: {
|
||||
{ id: 'tiktok', label: 'TikTok', icon: Share2 },
|
||||
];
|
||||
|
||||
function n(v?: number) {
|
||||
function n(v?: number | null) {
|
||||
if (v == null || !Number.isFinite(v)) return '—';
|
||||
return new Intl.NumberFormat('es-ES').format(v);
|
||||
}
|
||||
|
||||
function fmtDate(iso?: string | null) {
|
||||
if (!iso) return '—';
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString('es-ES', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
} catch {
|
||||
return iso.slice(0, 10);
|
||||
}
|
||||
}
|
||||
|
||||
export default function SocialDashboard({
|
||||
tenantLabel,
|
||||
isAgency,
|
||||
@@ -70,6 +116,10 @@ export default function SocialDashboard({
|
||||
instagram?: MetricsIg;
|
||||
warning?: string;
|
||||
}>({});
|
||||
const [posts, setPosts] = useState<SocialPost[]>([]);
|
||||
const [feedFilter, setFeedFilter] = useState<'all' | 'instagram' | 'facebook'>(
|
||||
'all'
|
||||
);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -83,6 +133,7 @@ export default function SocialDashboard({
|
||||
else {
|
||||
setIntegs(data.integrations || []);
|
||||
setMetrics(data.metrics || {});
|
||||
setPosts(data.posts || []);
|
||||
}
|
||||
setLoading(false);
|
||||
})();
|
||||
@@ -94,15 +145,80 @@ 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;
|
||||
const visiblePosts =
|
||||
feedFilter === 'all'
|
||||
? posts
|
||||
: posts.filter((p) => p.platform === feedFilter);
|
||||
|
||||
const kpiCards = [
|
||||
{
|
||||
label: 'Seguidores IG',
|
||||
value: n(ig?.followers_count),
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
label: 'Publicaciones IG',
|
||||
value: n(ig?.media_count),
|
||||
icon: ImageIcon,
|
||||
},
|
||||
{
|
||||
label: 'Alcance IG (día)',
|
||||
value: n(ig?.igInsights?.reach),
|
||||
icon: Eye,
|
||||
},
|
||||
{
|
||||
label: 'Impresiones IG',
|
||||
value: n(ig?.igInsights?.impressions),
|
||||
icon: Eye,
|
||||
},
|
||||
{
|
||||
label: 'Visitas perfil IG',
|
||||
value: n(ig?.igInsights?.profileViews),
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
label: 'Cuentas engaged IG',
|
||||
value: n(ig?.igInsights?.accountsEngaged),
|
||||
icon: Heart,
|
||||
},
|
||||
{
|
||||
label: 'Fans / seguidores FB',
|
||||
value: n(fb?.followers_count ?? fb?.fan_count),
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
label: 'Alcance FB (día)',
|
||||
value: n(fb?.pageInsights?.reach),
|
||||
icon: Eye,
|
||||
},
|
||||
{
|
||||
label: 'Impresiones FB',
|
||||
value: n(fb?.pageInsights?.impressions),
|
||||
icon: Eye,
|
||||
},
|
||||
{
|
||||
label: 'Engagement posts FB',
|
||||
value: n(fb?.pageInsights?.postEngagements),
|
||||
icon: Heart,
|
||||
},
|
||||
{
|
||||
label: 'Vistas página FB',
|
||||
value: n(fb?.pageInsights?.pageViews),
|
||||
icon: Eye,
|
||||
},
|
||||
{
|
||||
label: 'Clics web IG',
|
||||
value: n(ig?.igInsights?.websiteClicks),
|
||||
icon: ExternalLink,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
eyebrow={`Social · ${tenantLabel}`}
|
||||
title="Redes sociales"
|
||||
description="Perfiles orgánicos y métricas de audiencia. Paid ads están en Ads / Métricas."
|
||||
description="Analytics orgánicos IG/FB + preview del contenido publicado. Paid ads están en Ads."
|
||||
/>
|
||||
|
||||
{err && (
|
||||
@@ -118,33 +234,12 @@ export default function SocialDashboard({
|
||||
|
||||
{loading ? (
|
||||
<p className="text-sm text-gray-500 flex items-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Cargando…
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Cargando analytics…
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<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) => (
|
||||
{kpiCards.map((k) => (
|
||||
<div
|
||||
key={k.label}
|
||||
className="rounded-xl border border-gray-200 bg-white p-4"
|
||||
@@ -162,37 +257,113 @@ export default function SocialDashboard({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(ig?.error || fb?.error) && (
|
||||
{(ig?.error || fb?.error || ig?.postsError || fb?.postsError) && (
|
||||
<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.
|
||||
{[ig?.error && `IG: ${ig.error}`, fb?.error && `FB: ${fb.error}`, ig?.postsError && `IG posts: ${ig.postsError}`, fb?.postsError && `FB posts: ${fb.postsError}`]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</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>
|
||||
<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 className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold text-gray-900">
|
||||
Contenido publicado
|
||||
</h3>
|
||||
<div className="flex gap-1">
|
||||
{(
|
||||
[
|
||||
['all', 'Todo'],
|
||||
['instagram', 'IG'],
|
||||
['facebook', 'FB'],
|
||||
] as const
|
||||
).map(([id, lbl]) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setFeedFilter(id)}
|
||||
className={`px-2.5 py-1 rounded-lg text-[11px] font-semibold ${
|
||||
feedFilter === id
|
||||
? 'bg-gray-900 text-white'
|
||||
: 'bg-gray-100 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{lbl}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{visiblePosts.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 border border-dashed border-gray-200 rounded-xl px-4 py-10 text-center">
|
||||
Sin posts recientes (o faltan permisos pages_read_engagement /
|
||||
instagram_manage_insights).
|
||||
</p>
|
||||
) : (
|
||||
<ul className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{visiblePosts.map((p) => (
|
||||
<li
|
||||
key={p.id}
|
||||
className="rounded-2xl border border-gray-200 bg-white overflow-hidden flex flex-col"
|
||||
>
|
||||
<div className="relative aspect-square bg-gray-100">
|
||||
{p.mediaUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={p.mediaUrl}
|
||||
alt=""
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-xs text-gray-400 px-4 text-center">
|
||||
{p.message?.slice(0, 120) || 'Sin media'}
|
||||
</div>
|
||||
)}
|
||||
<span className="absolute top-2 left-2 text-[10px] font-bold uppercase tracking-wide px-2 py-0.5 rounded bg-black/70 text-white">
|
||||
{p.platform === 'instagram' ? 'IG' : 'FB'}
|
||||
{p.mediaType ? ` · ${p.mediaType}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-3 space-y-2 flex-1 flex flex-col">
|
||||
<p className="text-[11px] text-gray-500">
|
||||
{fmtDate(p.createdAt)}
|
||||
</p>
|
||||
{p.message ? (
|
||||
<p className="text-xs text-gray-800 line-clamp-3 whitespace-pre-wrap">
|
||||
{p.message}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex flex-wrap gap-3 text-[11px] text-gray-700 pt-1 mt-auto">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Heart className="w-3 h-3" /> {n(p.likes)}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<MessageCircle className="w-3 h-3" /> {n(p.comments)}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Repeat2 className="w-3 h-3" /> {n(p.shares)}
|
||||
</span>
|
||||
{p.reach != null && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Eye className="w-3 h-3" /> {n(p.reach)} alc.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{p.permalink && (
|
||||
<a
|
||||
href={p.permalink}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-[11px] text-brand-600 hover:underline"
|
||||
>
|
||||
Ver en red <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<ul className="grid gap-3 sm:grid-cols-2">
|
||||
@@ -234,11 +405,6 @@ export default function SocialDashboard({
|
||||
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}
|
||||
|
||||
Reference in New Issue
Block a user