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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user