Files
4ventures-client-portal/src/components/SocialDashboard.tsx
2026-09-28 17:57:56 +02:00

796 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,
Users,
Eye,
Image as ImageIcon,
Heart,
MessageCircle,
Repeat2,
} from 'lucide-react';
import { PageHeader } from '@/components/ui';
import { useDateRange } from '@/components/DateRangeContext';
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 }>;
}[] = [
{ id: 'instagram', label: 'Instagram', icon: Instagram },
{ id: 'facebook', label: 'Facebook', icon: Facebook },
{ id: 'linkedin', label: 'LinkedIn', icon: Linkedin },
{ id: 'tiktok', label: 'TikTok', icon: Share2 },
];
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 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-28 w-full">
{pts.map((p) => {
const pct = Math.max(6, Math.round((p.value / max) * 100));
return (
<div
key={p.day}
className="flex-1 min-w-0 h-full flex flex-col justify-end"
title={`${p.day}: ${n(p.value)}`}
>
<div
className={`w-full rounded-t ${color}`}
style={{ height: `${pct}%` }}
/>
</div>
);
})}
</div>
);
}
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-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>
<div className="text-right">
<p className="text-lg font-bold text-gray-900 tabular-nums">{n(total)}</p>
{delta ? (
<p
className={`text-[10px] font-semibold tabular-nums ${
delta.up ? 'text-emerald-600' : 'text-rose-600'
}`}
>
{delta.up ? '↑' : '↓'} {n(Math.abs(delta.abs))}
{delta.pct != null
? ` (${delta.pct > 0 ? '+' : ''}${delta.pct.toFixed(0)}%)`
: ''}
<span className="text-gray-400 font-normal"> inicio→fin</span>
</p>
) : null}
</div>
</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,
}: {
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 kpiCards = [
{
label: 'Seguidores IG (global)',
value: n(ig?.followers_count),
icon: Users,
},
{
label: 'Publicaciones IG (global)',
value: n(ig?.media_count),
icon: ImageIcon,
},
{
label: 'Alcance IG (periodo)',
value: n(ig?.igInsights?.reach),
icon: Eye,
},
{
label: 'Visualizaciones IG',
value: n(ig?.igInsights?.impressions),
icon: Eye,
},
{
label: 'Visitas perfil IG',
value: n(ig?.igInsights?.profileViews),
icon: Users,
},
{
label: 'Interacciones IG',
value: n(ig?.igInsights?.interactions ?? ig?.igInsights?.accountsEngaged),
icon: Heart,
},
{
label: 'Clics web IG',
value: n(ig?.igInsights?.websiteClicks),
icon: ExternalLink,
},
{
label: 'Δ seguidores IG',
value: n(ig?.igInsights?.followerDelta),
icon: Users,
},
{
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,
},
{
label: 'Visualizaciones FB',
value: n(fb?.pageInsights?.impressions),
icon: Eye,
},
{
label: 'Engagement posts FB',
value: n(fb?.pageInsights?.postEngagements),
icon: Heart,
},
];
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-6">
<PageHeader
eyebrow={`Social · ${tenantLabel}`}
title="Redes sociales"
description={`Analytics orgánicos IG/FB · periodo ${rangeLabel}`}
/>
{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>
) : (
<>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{kpiCards.map((k) => (
<div
key={k.label}
className="rounded-xl border border-gray-200 bg-white p-4"
>
<div className="flex items-center justify-between text-gray-600 mb-1">
<p className="text-[10px] font-semibold uppercase tracking-wider">
{k.label}
</p>
<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 ||
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 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">
Barras = valor diario. Flecha = cambio del primer al último día
del periodo. Totales arriba a la derecha.
</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>
<h3 className="text-sm font-semibold text-gray-900">
Publicaciones Instagram
</h3>
<p className="text-xs text-gray-500 mt-0.5">
Solo IG (feed, reels y carruseles). Sin posts de Facebook.
</p>
</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 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 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>
{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>
);
}