Files
4ventures-client-portal/src/components/SocialDashboard.tsx
2026-10-06 13:19:10 +02:00

787 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import React, { useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import {
Facebook,
Instagram,
Linkedin,
Loader2,
Share2,
ExternalLink,
Eye,
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;
status: string;
external_account_id?: string | null;
label?: string | null;
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;
followers_count?: number;
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 = {
username?: string;
followers_count?: number;
media_count?: number;
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 = {
id: string;
platform: 'facebook' | 'instagram';
message?: string;
mediaUrl?: string | null;
thumbnailUrl?: string | null;
permalink?: string | null;
createdAt?: string | null;
likes: number;
comments: number;
shares: number;
reach?: number | null;
impressions?: number | null;
mediaType?: string | null;
mediaProductType?: string | null;
children?: {
id?: string;
mediaType?: string | null;
mediaUrl?: string | null;
thumbnailUrl?: string | null;
}[];
};
function formatLabel(p: SocialPost): string {
const t = (p.mediaType || '').toUpperCase();
const prod = (p.mediaProductType || '').toUpperCase();
if (prod === 'REELS' || t === 'REELS' || t === 'REEL') return 'Reel';
if (t.includes('CAROUSEL') || t.includes('ALBUM')) return 'Carrusel';
if (t.includes('VIDEO')) return 'Vídeo';
if (t.includes('IMAGE') || t === 'PHOTO') return 'Imagen';
if (t === 'STORY' || prod === 'STORY') return 'Story';
return t || 'Post';
}
function isVideoSlide(mediaType?: string | null, url?: string | null): boolean {
const t = (mediaType || '').toUpperCase();
if (t.includes('VIDEO') || t === 'REELS' || t === 'REEL') return true;
return /\.mp4($|\?)/i.test(url || '') || /video/i.test(url || '');
}
function proxiedSocialUrl(raw?: string | null): string {
if (!raw) return '';
if (raw.startsWith('/api/')) return raw;
return `/api/ad-tracker/media?url=${encodeURIComponent(raw)}`;
}
function PostMedia({ post }: { post: SocialPost }) {
const slides =
post.children && post.children.length > 0
? post.children
: [
{
id: post.id,
mediaType: post.mediaType,
mediaUrl: post.mediaUrl,
thumbnailUrl: post.thumbnailUrl,
},
];
const [idx, setIdx] = useState(0);
const [dims, setDims] = useState<{ w: number; h: number } | null>(null);
const slide = slides[Math.min(idx, slides.length - 1)] || slides[0];
const video = isVideoSlide(slide?.mediaType, slide?.mediaUrl);
const src = proxiedSocialUrl(slide?.mediaUrl || slide?.thumbnailUrl);
const poster = proxiedSocialUrl(slide?.thumbnailUrl);
const aspect =
dims && dims.w > 0 && dims.h > 0 ? `${dims.w} / ${dims.h}` : video ? '9 / 16' : '1 / 1';
return (
<div className="space-y-1.5">
<div
className="relative w-full bg-gray-950 overflow-hidden rounded-t-2xl"
style={{ aspectRatio: aspect, maxHeight: 520 }}
>
{src && video ? (
<video
key={src}
src={src}
poster={poster || undefined}
className="absolute inset-0 w-full h-full object-contain bg-black"
controls
playsInline
preload="metadata"
onLoadedMetadata={(e) => {
const el = e.currentTarget;
if (el.videoWidth && el.videoHeight) {
setDims({ w: el.videoWidth, h: el.videoHeight });
}
}}
/>
) : src ? (
// eslint-disable-next-line @next/next/no-img-element
<img
key={src}
src={src}
alt=""
className="absolute inset-0 w-full h-full object-contain bg-black"
onLoad={(e) => {
const el = e.currentTarget;
if (el.naturalWidth && el.naturalHeight) {
setDims({ w: el.naturalWidth, h: el.naturalHeight });
}
}}
/>
) : (
<div className="absolute inset-0 flex items-center justify-center text-xs text-gray-400 px-4 text-center">
{post.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">
{post.platform === 'instagram' ? 'IG' : 'FB'} · {formatLabel(post)}
{slides.length > 1 ? ` · ${idx + 1}/${slides.length}` : ''}
</span>
{dims ? (
<span className="absolute top-2 right-2 text-[10px] font-semibold tabular-nums px-2 py-0.5 rounded bg-black/70 text-white">
{dims.w}×{dims.h}
{dims.w && dims.h
? ` · ${(dims.w / dims.h).toFixed(2)}:1`
: ''}
</span>
) : null}
{slides.length > 1 ? (
<>
<button
type="button"
aria-label="Anterior"
className="absolute left-2 top-1/2 -translate-y-1/2 w-8 h-8 rounded-full bg-black/60 text-white text-sm"
onClick={() => {
setDims(null);
setIdx((i) => (i - 1 + slides.length) % slides.length);
}}
>
‹
</button>
<button
type="button"
aria-label="Siguiente"
className="absolute right-2 top-1/2 -translate-y-1/2 w-8 h-8 rounded-full bg-black/60 text-white text-sm"
onClick={() => {
setDims(null);
setIdx((i) => (i + 1) % slides.length);
}}
>
›
</button>
</>
) : null}
</div>
{dims ? (
<p className="px-3 text-[10px] text-gray-500 tabular-nums">
Tamaño original: {dims.w} × {dims.h}px
{video ? ' (vídeo)' : slides.length > 1 ? ` · slide ${idx + 1}` : ''}
</p>
) : null}
</div>
);
}
const CHANNELS: {
id: string;
label: string;
icon: React.ComponentType<{ className?: string }>;
/** Live Graph analytics in this panel (Meta only today). */
metrics: boolean;
}[] = [
{ id: 'instagram', label: 'Instagram', icon: Instagram, metrics: true },
{ id: 'facebook', label: 'Facebook', icon: Facebook, metrics: true },
{ id: 'linkedin', label: 'LinkedIn', icon: Linkedin, metrics: false },
{ id: 'tiktok', label: 'TikTok', icon: Share2, metrics: false },
];
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);
}
}
function seriesDelta(series?: DayPoint[]): {
abs: number;
pct: number | null;
up: boolean;
} | null {
if (!series || series.length < 2) return null;
const first = series[0].value;
const last = series[series.length - 1].value;
const abs = last - first;
const pct = first !== 0 ? (100 * abs) / Math.abs(first) : null;
return { abs, pct, up: abs >= 0 };
}
function ChartCard({
title,
hint,
series,
total,
color,
}: {
title: string;
hint?: string;
series?: DayPoint[];
total?: number | null;
color: string;
}) {
const delta = seriesDelta(series);
return (
<div className="rounded-xl border border-gray-200 bg-white p-4 space-y-3">
<div className="flex items-start justify-between gap-2">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-gray-500">
{title}
</p>
{hint ? <p className="text-xs text-gray-400 mt-0.5">{hint}</p> : null}
</div>
<div className="text-right">
<p className="text-xl font-semibold text-gray-900 tabular-nums tracking-tight">
{n(total)}
</p>
{delta ? (
<p
className={`text-[11px] font-semibold tabular-nums ${
delta.up ? 'text-emerald-700' : 'text-red-700'
}`}
>
{delta.up ? '↑' : '↓'} {n(Math.abs(delta.abs))}
{delta.pct != null
? ` (${delta.pct > 0 ? '+' : ''}${delta.pct.toFixed(0)}%)`
: ''}
</p>
) : null}
</div>
</div>
<SparkBars
points={(series || []).map((p) => ({ date: p.day, value: p.value }))}
color={color}
formatValue={(v) => n(v)}
empty="Sin serie en el periodo"
/>
</div>
);
}
export default function SocialDashboard({
tenantLabel,
isAgency,
}: {
tenantLabel: string;
isAgency: boolean;
}) {
const { since, until, label: rangeLabel } = useDateRange();
const [loading, setLoading] = useState(true);
const [integs, setIntegs] = useState<Integ[]>([]);
const [metrics, setMetrics] = useState<{
facebook?: MetricsFacebook;
instagram?: MetricsIg;
warning?: string;
}>({});
const [posts, setPosts] = useState<SocialPost[]>([]);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
setLoading(true);
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');
else {
setIntegs(data.integrations || []);
setMetrics(data.metrics || {});
setPosts(data.posts || []);
}
setLoading(false);
})();
return () => {
cancelled = true;
};
}, [since, until]);
const by = useMemo(() => new Map(integs.map((i) => [i.provider, i])), [integs]);
const ig = metrics.instagram;
const fb = metrics.facebook;
const visiblePosts = useMemo(
() => posts.filter((p) => p.platform === 'instagram'),
[posts]
);
const kpiIg = [
{
label: 'Publicaciones IG',
value: n(ig?.media_count),
hint: 'Stock del perfil',
},
{
label: 'Visualizaciones IG',
value: n(ig?.igInsights?.impressions),
hint: 'Periodo',
},
{
label: 'Visitas perfil',
value: n(ig?.igInsights?.profileViews),
hint: 'Instagram',
},
{
label: 'Clics web IG',
value: n(ig?.igInsights?.websiteClicks),
hint: 'Desde el perfil',
},
{
label: 'Δ seguidores',
value: n(ig?.igInsights?.followerDelta),
hint: 'Cambio en el periodo',
},
];
const kpiFb = [
{
label: 'Visualizaciones FB',
value: n(fb?.pageInsights?.impressions),
hint: 'Periodo',
},
{
label: 'Engagement posts',
value: n(fb?.pageInsights?.postEngagements),
hint: 'Facebook',
},
];
const chartBlocks: {
title: string;
hint: string;
series?: DayPoint[];
total?: number | null;
color: string;
}[] = [
{
title: 'IG · Alcance / día',
hint: 'Serie diaria en el periodo',
series: ig?.series?.reach,
total: ig?.igInsights?.reach,
color: 'bg-pink-400/80',
},
{
title: 'IG · Visualizaciones',
hint: 'views (metric_type=total_value)',
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_total_media_view_unique',
series: fb?.series?.reach,
total: fb?.pageInsights?.reach,
color: 'bg-sky-400/80',
},
{
title: 'FB · Visualizaciones',
hint: 'page_media_view',
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',
},
];
return (
<div className="space-y-8">
<PageHeader
eyebrow="Social"
title="Redes sociales"
description={`${tenantLabel} · Instagram y Facebook · ${rangeLabel}`}
actions={<SyncNowButton forceModule="social" />}
/>
{err && (
<p className="text-xs text-red-700 bg-red-50 border border-red-200 rounded-lg px-3 py-2">
{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 analytics…
</p>
) : (
<>
<HeroBand>
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4 md:gap-6">
<HeroStat
label="Seguidores IG"
value={n(ig?.followers_count)}
/>
<HeroStat
label="Alcance IG"
value={n(ig?.igInsights?.reach)}
/>
<HeroStat
label="Interacciones IG"
value={n(
ig?.igInsights?.interactions ??
ig?.igInsights?.accountsEngaged
)}
/>
<HeroStat
label="Fans FB"
value={n(
fb?.followers_count ?? fb?.fan_count ?? fb?.pageInsights?.fans
)}
/>
<HeroStat
label="Alcance FB"
value={n(fb?.pageInsights?.reach)}
/>
</div>
</HeroBand>
<HelpDetails title="Cómo leer Social">
<p>
Los totales de alcance e interacciones son del periodo del
header. Seguidores y fans son stock actual del perfil.
</p>
<p>
En los gráficos, la flecha es el cambio del primer al último día
(no es un MoM).
</p>
<p>
Instagram y Facebook sí tienen métricas (Graph API). LinkedIn y
TikTok, si aparecen, son solo el perfil guardado en la ficha:
aún no hay evolución de impresiones ni seguidores.
</p>
</HelpDetails>
<div className="grid grid-cols-2 lg:grid-cols-5 gap-3">
{kpiIg.map((k) => (
<StatMini key={k.label} label={k.label} value={k.value} hint={k.hint} />
))}
</div>
<div className="grid grid-cols-2 gap-3">
{kpiFb.map((k) => (
<StatMini key={k.label} label={k.label} value={k.value} hint={k.hint} />
))}
</div>
{(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?.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>
<SectionHead
icon={<BarChart3 className="w-4 h-4" />}
title="Evolución diaria"
hint={`${rangeLabel} · barras = valor diario · flecha = primer→último día`}
/>
<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>
<SectionHead
icon={<Instagram className="w-4 h-4" />}
title="Publicaciones Instagram"
hint="Feed, reels y carruseles. Sin posts de Facebook."
/>
{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 publicaciones de Instagram en este periodo.
</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"
>
<PostMedia post={p} />
<div className="p-3 space-y-2 flex-1 flex flex-col">
<p className="text-[11px] text-gray-500">
{fmtDate(p.createdAt)}
{p.mediaProductType
? ` · ${p.mediaProductType}`
: ''}
</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>
)}
{p.impressions != null && (
<span className="inline-flex items-center gap-1">
<Eye className="w-3 h-3" /> {n(p.impressions)} views
</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">
{CHANNELS.map((ch) => {
const row = by.get(ch.id);
const linked =
Boolean(row?.external_account_id) &&
(row?.status === 'connected' || row?.status === 'pending');
const live = linked && ch.metrics;
const Icon = ch.icon;
const url =
row?.meta?.public_url ||
(row?.external_account_id?.startsWith('http')
? row.external_account_id
: null);
const badge = !linked
? { text: 'Sin enlazar', cls: 'bg-gray-100 text-gray-500' }
: live
? { text: 'Métricas en vivo', cls: 'bg-emerald-50 text-emerald-700' }
: {
text: 'Perfil guardado',
cls: 'bg-amber-50 text-amber-800',
};
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 ${badge.cls}`}
>
{badge.text}
</span>
</div>
<p className="text-xs text-gray-600 truncate">
{row?.meta?.account_name ||
row?.label ||
row?.external_account_id ||
'—'}
</p>
{linked && !ch.metrics ? (
<p className="text-[11px] text-gray-500">
Hay un enlace de página en la ficha. No se leen
impresiones, seguidores ni interacciones todavía.
</p>
) : null}
{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="flex flex-wrap gap-2">
<Link
href="/dashboard/calendario"
className="text-xs font-semibold px-3 py-1.5 rounded-lg bg-brand-500 text-white hover:bg-brand-600"
>
Calendario editorial
</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
</Link>
)}
</div>
</div>
);
}