787 lines
25 KiB
TypeScript
787 lines
25 KiB
TypeScript
'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>
|
||
);
|
||
}
|