feat: Social con gráficas diarias por periodo y token Meta 4V2030

Series IG/FB (alcance, impresiones, clics, engagement, fans) según el rango del header; Meta y Google quedan como tokens separados.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-24 17:30:44 +02:00
parent 224f060e9d
commit 5e5fc87710
4 changed files with 707 additions and 257 deletions

View File

@@ -1,6 +1,6 @@
'use client';
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import {
Facebook,
@@ -17,6 +17,7 @@ import {
Repeat2,
} from 'lucide-react';
import { PageHeader } from '@/components/ui';
import { useDateRange } from '@/components/DateRangeContext';
type Integ = {
provider: string;
@@ -26,6 +27,10 @@ type Integ = {
meta?: { public_url?: string; account_name?: string; warning?: string } | null;
};
type DayPoint = { day: string; value: number };
type SeriesBag = Partial<Record<string, DayPoint[]>>;
type MetricsFacebook = {
name?: string;
fan_count?: number;
@@ -33,13 +38,17 @@ type MetricsFacebook = {
link?: string;
error?: string;
postsError?: string;
insightsError?: string;
pageInsights?: {
impressions?: number | null;
reach?: number | null;
postEngagements?: number | null;
fans?: number | null;
pageViews?: number | null;
follows?: number | null;
reactions?: number | null;
};
series?: SeriesBag;
};
type MetricsIg = {
@@ -49,13 +58,17 @@ type MetricsIg = {
follows_count?: number;
error?: string;
postsError?: string;
insightsError?: string;
igInsights?: {
reach?: number | null;
impressions?: number | null;
profileViews?: number | null;
websiteClicks?: number | null;
accountsEngaged?: number | null;
followerDelta?: number | null;
interactions?: number | null;
};
series?: SeriesBag;
};
type SocialPost = {
@@ -110,6 +123,78 @@ 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 <p className="text-xs text-gray-500 py-6 text-center">{empty}</p>;
}
const max = Math.max(1, ...pts.map((p) => p.value));
return (
<div className="flex items-end gap-0.5 h-24 w-full">
{pts.map((p) => (
<div
key={p.day}
className="flex-1 min-w-0"
title={`${p.day}: ${n(p.value)}`}
>
<div
className={`w-full rounded-t ${color} hover:opacity-90`}
style={{ height: `${Math.max(4, (p.value / max) * 100)}%` }}
/>
</div>
))}
</div>
);
}
function ChartCard({
title,
hint,
series,
total,
color,
}: {
title: string;
hint?: string;
series?: DayPoint[];
total?: number | null;
color: string;
}) {
return (
<div className="rounded-xl border border-gray-200 bg-white p-4 space-y-2">
<div className="flex items-start justify-between gap-2">
<div>
<p className="text-[10px] font-semibold uppercase tracking-wider text-gray-600">
{title}
</p>
{hint ? <p className="text-[10px] text-gray-400 mt-0.5">{hint}</p> : null}
</div>
<p className="text-lg font-bold text-gray-900 tabular-nums">{n(total)}</p>
</div>
<MiniBars series={series} color={color} />
{series && series.length > 1 ? (
<div className="flex justify-between text-[9px] text-gray-400 tabular-nums">
<span>{shortDay(series[0].day)}</span>
<span>{shortDay(series[series.length - 1].day)}</span>
</div>
) : null}
</div>
);
}
export default function SocialDashboard({
tenantLabel,
isAgency,
@@ -117,6 +202,7 @@ export default function SocialDashboard({
tenantLabel: string;
isAgency: boolean;
}) {
const { since, until, label: rangeLabel } = useDateRange();
const [loading, setLoading] = useState(true);
const [integs, setIntegs] = useState<Integ[]>([]);
const [metrics, setMetrics] = useState<{
@@ -134,7 +220,9 @@ export default function SocialDashboard({
let cancelled = false;
(async () => {
setLoading(true);
const res = await fetch('/api/social');
setErr(null);
const qs = new URLSearchParams({ since, until });
const res = await fetch(`/api/social?${qs}`);
const data = await res.json().catch(() => ({}));
if (cancelled) return;
if (!res.ok) setErr(data.error || 'No se pudo cargar Social');
@@ -148,9 +236,9 @@ export default function SocialDashboard({
return () => {
cancelled = true;
};
}, []);
}, [since, until]);
const by = new Map(integs.map((i) => [i.provider, i]));
const by = useMemo(() => new Map(integs.map((i) => [i.provider, i])), [integs]);
const ig = metrics.instagram;
const fb = metrics.facebook;
const visiblePosts =
@@ -160,22 +248,22 @@ export default function SocialDashboard({
const kpiCards = [
{
label: 'Seguidores IG',
label: 'Seguidores IG (global)',
value: n(ig?.followers_count),
icon: Users,
},
{
label: 'Publicaciones IG',
label: 'Publicaciones IG (global)',
value: n(ig?.media_count),
icon: ImageIcon,
},
{
label: 'Alcance IG (día)',
label: 'Alcance IG (periodo)',
value: n(ig?.igInsights?.reach),
icon: Eye,
},
{
label: 'Impresiones IG',
label: 'Impresiones/vistas IG',
value: n(ig?.igInsights?.impressions),
icon: Eye,
},
@@ -185,17 +273,27 @@ export default function SocialDashboard({
icon: Users,
},
{
label: 'Cuentas engaged IG',
value: n(ig?.igInsights?.accountsEngaged),
label: 'Interacciones IG',
value: n(ig?.igInsights?.interactions ?? ig?.igInsights?.accountsEngaged),
icon: Heart,
},
{
label: 'Fans / seguidores FB',
value: n(fb?.followers_count ?? fb?.fan_count),
label: 'Clics web IG',
value: n(ig?.igInsights?.websiteClicks),
icon: ExternalLink,
},
{
label: 'Δ seguidores IG',
value: n(ig?.igInsights?.followerDelta),
icon: Users,
},
{
label: 'Alcance FB (día)',
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,
},
@@ -209,15 +307,98 @@ export default function SocialDashboard({
value: n(fb?.pageInsights?.postEngagements),
icon: Heart,
},
];
const chartBlocks: {
title: string;
hint: string;
series?: DayPoint[];
total?: number | null;
color: string;
}[] = [
{
label: 'Vistas página FB',
value: n(fb?.pageInsights?.pageViews),
icon: Eye,
title: 'IG · Alcance / día',
hint: 'Serie diaria en el periodo',
series: ig?.series?.reach,
total: ig?.igInsights?.reach,
color: 'bg-pink-400/80',
},
{
label: 'Clics web IG',
value: n(ig?.igInsights?.websiteClicks),
icon: ExternalLink,
title: 'IG · Impresiones/vistas',
hint: 'Views / impressions',
series: ig?.series?.impressions,
total: ig?.igInsights?.impressions,
color: 'bg-fuchsia-400/80',
},
{
title: 'IG · Visitas al perfil',
hint: 'profile_views',
series: ig?.series?.profileViews,
total: ig?.igInsights?.profileViews,
color: 'bg-rose-400/80',
},
{
title: 'IG · Clics web',
hint: 'website_clicks',
series: ig?.series?.websiteClicks,
total: ig?.igInsights?.websiteClicks,
color: 'bg-orange-400/80',
},
{
title: 'IG · Cuentas engaged',
hint: 'accounts_engaged',
series: ig?.series?.accountsEngaged,
total: ig?.igInsights?.accountsEngaged,
color: 'bg-amber-400/80',
},
{
title: 'IG · Δ seguidores / día',
hint: 'follower_count (delta)',
series: ig?.series?.followerDelta,
total: ig?.igInsights?.followerDelta,
color: 'bg-yellow-500/70',
},
{
title: 'FB · Alcance / día',
hint: 'page_impressions_unique',
series: fb?.series?.reach,
total: fb?.pageInsights?.reach,
color: 'bg-sky-400/80',
},
{
title: 'FB · Impresiones',
hint: 'page_impressions',
series: fb?.series?.impressions,
total: fb?.pageInsights?.impressions,
color: 'bg-blue-500/70',
},
{
title: 'FB · Engagement posts',
hint: 'page_post_engagements',
series: fb?.series?.postEngagements,
total: fb?.pageInsights?.postEngagements,
color: 'bg-indigo-400/80',
},
{
title: 'FB · Fans (stock)',
hint: 'page_fans diario',
series: fb?.series?.fans,
total: fb?.pageInsights?.fans,
color: 'bg-violet-400/80',
},
{
title: 'FB · Vistas página',
hint: 'page_views_total',
series: fb?.series?.pageViews,
total: fb?.pageInsights?.pageViews,
color: 'bg-cyan-400/80',
},
{
title: 'FB · Nuevos follows',
hint: 'page_follows / daily_follows',
series: fb?.series?.follows,
total: fb?.pageInsights?.follows,
color: 'bg-teal-400/80',
},
];
@@ -226,7 +407,7 @@ export default function SocialDashboard({
<PageHeader
eyebrow={`Social · ${tenantLabel}`}
title="Redes sociales"
description="Analytics orgánicos IG/FB + preview del contenido publicado. Paid ads están en Ads."
description={`Analytics orgánicos IG/FB (token Meta 4V2030) · periodo ${rangeLabel}. Paid ads en Ads.`}
/>
{err && (
@@ -265,14 +446,43 @@ export default function SocialDashboard({
))}
</div>
{(ig?.error || fb?.error || ig?.postsError || fb?.postsError) && (
{(ig?.error ||
fb?.error ||
ig?.postsError ||
fb?.postsError ||
ig?.insightsError ||
fb?.insightsError) && (
<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}`, ig?.postsError && `IG posts: ${ig.postsError}`, fb?.postsError && `FB posts: ${fb.postsError}`]
{[
ig?.error && `IG: ${ig.error}`,
fb?.error && `FB: ${fb.error}`,
ig?.insightsError && `IG insights: ${ig.insightsError}`,
fb?.insightsError && `FB insights: ${fb.insightsError}`,
ig?.postsError && `IG posts: ${ig.postsError}`,
fb?.postsError && `FB posts: ${fb.postsError}`,
]
.filter(Boolean)
.join(' · ')}
</p>
)}
<section className="space-y-3">
<div>
<h3 className="text-sm font-semibold text-gray-900">
Evolución diaria · {rangeLabel}
</h3>
<p className="text-xs text-gray-500 mt-0.5">
Totales del periodo arriba a la derecha de cada gráfica. Globales
(seguidores/publicaciones) en las KPI.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
{chartBlocks.map((c) => (
<ChartCard key={c.title} {...c} />
))}
</div>
</section>
<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">
@@ -304,8 +514,8 @@ export default function SocialDashboard({
{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).
Sin posts en este periodo (o el token Meta no puede leer la
Página/IG).
</p>
) : (
<ul className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">