fix: Social — métricas IG total_value, posts en formato original y tamaño
Rellena views/perfil/clics/interacciones con metric_type=total_value y series día a día; carruseles/vídeos/reels con aspect ratio y px originales. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -26,6 +26,8 @@ function allowed(url: string): boolean {
|
||||
return (
|
||||
host.endsWith('.fbcdn.net') ||
|
||||
host.endsWith('.facebook.com') ||
|
||||
host.endsWith('.cdninstagram.com') ||
|
||||
host.endsWith('.instagram.com') ||
|
||||
host.endsWith('.googleusercontent.com') ||
|
||||
host.endsWith('.googlesyndication.com') ||
|
||||
host === 'i.ytimg.com' ||
|
||||
|
||||
@@ -99,6 +99,15 @@ type SocialPost = {
|
||||
reach?: number | null;
|
||||
impressions?: number | null;
|
||||
mediaType?: string | null;
|
||||
/** FEED | REELS | STORY | … */
|
||||
mediaProductType?: string | null;
|
||||
/** Slides for CAROUSEL_ALBUM / album */
|
||||
children?: {
|
||||
id?: string;
|
||||
mediaType?: string | null;
|
||||
mediaUrl?: string | null;
|
||||
thumbnailUrl?: string | null;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type DayPoint = { day: string; value: number };
|
||||
@@ -186,6 +195,27 @@ function insightSeries(
|
||||
.sort((a, b) => a.day.localeCompare(b.day));
|
||||
}
|
||||
|
||||
/** Meta IG: many metrics only return total_value (not daily values[]). */
|
||||
function totalValueOf(
|
||||
data: { name?: string; total_value?: { value?: number } }[] | undefined,
|
||||
name: string
|
||||
): number | null {
|
||||
const row = data?.find((d) => d.name === name);
|
||||
const v = row?.total_value?.value;
|
||||
return typeof v === 'number' && Number.isFinite(v) ? v : null;
|
||||
}
|
||||
|
||||
function eachDay(since: string, until: string): string[] {
|
||||
const out: string[] = [];
|
||||
const cur = new Date(`${since}T12:00:00Z`);
|
||||
const end = new Date(`${until}T12:00:00Z`);
|
||||
while (cur <= end) {
|
||||
out.push(cur.toISOString().slice(0, 10));
|
||||
cur.setUTCDate(cur.getUTCDate() + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function lastValue(series: DayPoint[]): number | null {
|
||||
if (!series.length) return null;
|
||||
return series[series.length - 1].value;
|
||||
@@ -353,8 +383,8 @@ async function fetchPageBundle(
|
||||
token,
|
||||
{
|
||||
fields:
|
||||
'id,message,created_time,full_picture,permalink_url,status_type,shares,likes.summary(true),comments.summary(true),attachments{media_type,type,url,media,subattachments}',
|
||||
limit: '20',
|
||||
'id,message,created_time,full_picture,permalink_url,status_type,shares,likes.summary(true),comments.summary(true),attachments{media_type,type,url,media,subattachments{media_type,type,url,media}}',
|
||||
limit: '25',
|
||||
}
|
||||
);
|
||||
let postsError: string | undefined;
|
||||
@@ -366,8 +396,8 @@ async function fetchPageBundle(
|
||||
} else {
|
||||
const posts2 = await graphGet(`${encodeURIComponent(pageId)}/posts`, token, {
|
||||
fields:
|
||||
'id,message,created_time,full_picture,permalink_url,status_type,shares,likes.summary(true),comments.summary(true),attachments{media_type,type,url,media,subattachments}',
|
||||
limit: '20',
|
||||
'id,message,created_time,full_picture,permalink_url,status_type,shares,likes.summary(true),comments.summary(true),attachments{media_type,type,url,media,subattachments{media_type,type,url,media}}',
|
||||
limit: '25',
|
||||
});
|
||||
if (posts2.ok) {
|
||||
const data = (posts2.json.data as Record<string, unknown>[]) || [];
|
||||
@@ -423,28 +453,45 @@ function pushFbPost(
|
||||
const att = (p.attachments as { data?: Record<string, unknown>[] })?.data?.[0];
|
||||
const attType = String(att?.media_type || att?.type || '').toLowerCase();
|
||||
const media = att?.media as { source?: string; image?: { src?: string } } | undefined;
|
||||
const subs =
|
||||
(
|
||||
att?.subattachments as {
|
||||
data?: {
|
||||
media_type?: string;
|
||||
type?: string;
|
||||
media?: { source?: string; image?: { src?: string } };
|
||||
url?: string;
|
||||
}[];
|
||||
}
|
||||
)?.data || [];
|
||||
const videoSrc =
|
||||
media?.source ||
|
||||
(att?.url as string) ||
|
||||
(
|
||||
att?.subattachments as {
|
||||
data?: { media?: { source?: string } }[];
|
||||
}
|
||||
)?.data?.[0]?.media?.source ||
|
||||
subs[0]?.media?.source ||
|
||||
null;
|
||||
const isVideo =
|
||||
attType.includes('video') ||
|
||||
String(p.status_type || '').includes('video') ||
|
||||
Boolean(videoSrc && /\.mp4|video/i.test(String(videoSrc)));
|
||||
const isAlbum = attType.includes('album') || subs.length > 1;
|
||||
const thumb =
|
||||
(p.full_picture as string) ||
|
||||
media?.image?.src ||
|
||||
(
|
||||
att?.subattachments as {
|
||||
data?: { media?: { image?: { src?: string } } }[];
|
||||
}
|
||||
)?.data?.[0]?.media?.image?.src ||
|
||||
subs[0]?.media?.image?.src ||
|
||||
null;
|
||||
const children = isAlbum
|
||||
? subs.map((s, i) => {
|
||||
const v = s.media?.source;
|
||||
const img = s.media?.image?.src || s.url || null;
|
||||
const t = String(s.media_type || s.type || '').toLowerCase();
|
||||
return {
|
||||
id: `${id}-slide-${i}`,
|
||||
mediaType: t.includes('video') ? 'VIDEO' : 'IMAGE',
|
||||
mediaUrl: v || img,
|
||||
thumbnailUrl: img,
|
||||
};
|
||||
})
|
||||
: undefined;
|
||||
const likes = p.likes as { summary?: { total_count?: number } } | undefined;
|
||||
const comments = p.comments as { summary?: { total_count?: number } } | undefined;
|
||||
const shares = p.shares as { count?: number } | undefined;
|
||||
@@ -452,14 +499,15 @@ function pushFbPost(
|
||||
id,
|
||||
platform: 'facebook',
|
||||
message: String(p.message || ''),
|
||||
mediaUrl: isVideo ? videoSrc || thumb : thumb,
|
||||
mediaUrl: isVideo ? videoSrc || thumb : thumb || children?.[0]?.mediaUrl || null,
|
||||
thumbnailUrl: thumb,
|
||||
permalink: (p.permalink_url as string) || null,
|
||||
createdAt: (p.created_time as string) || null,
|
||||
likes: Number(likes?.summary?.total_count || 0),
|
||||
comments: Number(comments?.summary?.total_count || 0),
|
||||
shares: Number(shares?.count || 0),
|
||||
mediaType: isVideo ? 'VIDEO' : thumb ? 'IMAGE' : 'status',
|
||||
mediaType: isVideo ? 'VIDEO' : isAlbum ? 'CAROUSEL_ALBUM' : thumb ? 'IMAGE' : 'status',
|
||||
children,
|
||||
});
|
||||
seen.add(id);
|
||||
}
|
||||
@@ -480,20 +528,12 @@ async function fetchIgBundle(
|
||||
const sinceU = String(toUnix(since));
|
||||
const untilU = String(toUnix(until, true));
|
||||
|
||||
const metricSets = [
|
||||
'reach,views,profile_views,website_clicks,accounts_engaged,follower_count,total_interactions',
|
||||
'reach,profile_views,website_clicks,accounts_engaged,follower_count',
|
||||
'reach,follower_count,profile_views',
|
||||
'reach,follower_count',
|
||||
];
|
||||
|
||||
let insightData: {
|
||||
// Time-series metrics (daily values[])
|
||||
let seriesData: {
|
||||
name?: string;
|
||||
values?: { value?: number; end_time?: string }[];
|
||||
}[] = [];
|
||||
let insightsError: string | undefined;
|
||||
|
||||
for (const metrics of metricSets) {
|
||||
for (const metrics of ['reach,follower_count', 'reach', 'follower_count']) {
|
||||
const ins = await graphGet(`${encodeURIComponent(igId)}/insights`, agencyToken, {
|
||||
metric: metrics,
|
||||
period: 'day',
|
||||
@@ -501,125 +541,169 @@ async function fetchIgBundle(
|
||||
until: untilU,
|
||||
});
|
||||
if (ins.ok && Array.isArray(ins.json.data)) {
|
||||
insightData = ins.json.data as typeof insightData;
|
||||
seriesData = ins.json.data as typeof seriesData;
|
||||
break;
|
||||
}
|
||||
// Some metrics reject `since`; retry without range (last ~30d default)
|
||||
const ins2 = await graphGet(
|
||||
`${encodeURIComponent(igId)}/insights`,
|
||||
agencyToken,
|
||||
{ metric: metrics, period: 'day' }
|
||||
);
|
||||
if (ins2.ok && Array.isArray(ins2.json.data)) {
|
||||
insightData = (ins2.json.data as typeof insightData).filter((row) => {
|
||||
// keep points inside range when end_time present
|
||||
const vals = (row.values || []).filter((v) => {
|
||||
const d = (v.end_time || '').slice(0, 10);
|
||||
return d >= since && d <= until;
|
||||
});
|
||||
row.values = vals;
|
||||
return vals.length > 0;
|
||||
});
|
||||
if (insightData.length) break;
|
||||
}
|
||||
insightsError = formatGraphError(ins.raw);
|
||||
}
|
||||
|
||||
// Partial recovery: one bad metric in a comma-set fails the whole call
|
||||
if (!insightData.length || insightData.length < 3) {
|
||||
const singles = [
|
||||
'reach',
|
||||
'views',
|
||||
'impressions',
|
||||
'profile_views',
|
||||
'website_clicks',
|
||||
'accounts_engaged',
|
||||
'follower_count',
|
||||
'total_interactions',
|
||||
];
|
||||
const byName = new Map<string, (typeof insightData)[0]>();
|
||||
for (const row of insightData) {
|
||||
if (row.name) byName.set(row.name, row);
|
||||
}
|
||||
for (const metric of singles) {
|
||||
if (byName.has(metric)) continue;
|
||||
const ins = await graphGet(`${encodeURIComponent(igId)}/insights`, agencyToken, {
|
||||
metric,
|
||||
period: 'day',
|
||||
since: sinceU,
|
||||
until: untilU,
|
||||
});
|
||||
if (ins.ok && Array.isArray(ins.json.data) && ins.json.data.length) {
|
||||
for (const row of ins.json.data as typeof insightData) {
|
||||
if (row.name) byName.set(row.name, row);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const ins2 = await graphGet(
|
||||
`${encodeURIComponent(igId)}/insights`,
|
||||
agencyToken,
|
||||
{ metric, period: 'day' }
|
||||
);
|
||||
if (ins2.ok && Array.isArray(ins2.json.data)) {
|
||||
for (const row of ins2.json.data as typeof insightData) {
|
||||
const vals = (row.values || []).filter((v) => {
|
||||
const d = (v.end_time || '').slice(0, 10);
|
||||
return d >= since && d <= until;
|
||||
});
|
||||
if (!vals.length || !row.name) continue;
|
||||
byName.set(row.name, { ...row, values: vals });
|
||||
// total_value metrics (period totals + optional per-day series)
|
||||
const totalMetrics = [
|
||||
'views',
|
||||
'profile_views',
|
||||
'website_clicks',
|
||||
'accounts_engaged',
|
||||
'total_interactions',
|
||||
];
|
||||
let totalData: {
|
||||
name?: string;
|
||||
total_value?: { value?: number };
|
||||
}[] = [];
|
||||
{
|
||||
const ins = await graphGet(`${encodeURIComponent(igId)}/insights`, agencyToken, {
|
||||
metric: totalMetrics.join(','),
|
||||
period: 'day',
|
||||
metric_type: 'total_value',
|
||||
since: sinceU,
|
||||
until: untilU,
|
||||
});
|
||||
if (ins.ok && Array.isArray(ins.json.data)) {
|
||||
totalData = ins.json.data as typeof totalData;
|
||||
} else {
|
||||
for (const metric of totalMetrics) {
|
||||
const one = await graphGet(
|
||||
`${encodeURIComponent(igId)}/insights`,
|
||||
agencyToken,
|
||||
{
|
||||
metric,
|
||||
period: 'day',
|
||||
metric_type: 'total_value',
|
||||
since: sinceU,
|
||||
until: untilU,
|
||||
}
|
||||
);
|
||||
if (one.ok && Array.isArray(one.json.data)) {
|
||||
totalData.push(...(one.json.data as typeof totalData));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (byName.size) {
|
||||
insightData = [...byName.values()];
|
||||
insightsError = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Daily series for total_value metrics (Meta only returns a single total per range)
|
||||
const days = eachDay(since, until).slice(-31);
|
||||
const dailyMaps: Record<string, DayPoint[]> = {
|
||||
views: [],
|
||||
profile_views: [],
|
||||
website_clicks: [],
|
||||
accounts_engaged: [],
|
||||
total_interactions: [],
|
||||
};
|
||||
for (let i = 0; i < days.length; i += 7) {
|
||||
const chunk = days.slice(i, i + 7);
|
||||
await Promise.all(
|
||||
chunk.map(async (day) => {
|
||||
const s = String(toUnix(day, false));
|
||||
const u = String(toUnix(day, true));
|
||||
const ins = await graphGet(
|
||||
`${encodeURIComponent(igId)}/insights`,
|
||||
agencyToken,
|
||||
{
|
||||
metric: totalMetrics.join(','),
|
||||
period: 'day',
|
||||
metric_type: 'total_value',
|
||||
since: s,
|
||||
until: u,
|
||||
}
|
||||
);
|
||||
if (!ins.ok || !Array.isArray(ins.json.data)) return;
|
||||
for (const row of ins.json.data as {
|
||||
name?: string;
|
||||
total_value?: { value?: number };
|
||||
}[]) {
|
||||
const name = row.name || '';
|
||||
if (!(name in dailyMaps)) continue;
|
||||
const v = row.total_value?.value;
|
||||
if (typeof v !== 'number') continue;
|
||||
dailyMaps[name].push({ day, value: v });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
for (const k of Object.keys(dailyMaps)) {
|
||||
dailyMaps[k].sort((a, b) => a.day.localeCompare(b.day));
|
||||
}
|
||||
|
||||
const series = {
|
||||
reach: insightSeries(insightData, 'reach'),
|
||||
impressions:
|
||||
insightSeries(insightData, 'views').length
|
||||
? insightSeries(insightData, 'views')
|
||||
: insightSeries(insightData, 'impressions'),
|
||||
profileViews: insightSeries(insightData, 'profile_views'),
|
||||
websiteClicks: insightSeries(insightData, 'website_clicks'),
|
||||
accountsEngaged: insightSeries(insightData, 'accounts_engaged'),
|
||||
followerDelta: insightSeries(insightData, 'follower_count'),
|
||||
interactions: insightSeries(insightData, 'total_interactions'),
|
||||
reach: insightSeries(seriesData, 'reach'),
|
||||
impressions: dailyMaps.views,
|
||||
profileViews: dailyMaps.profile_views,
|
||||
websiteClicks: dailyMaps.website_clicks,
|
||||
accountsEngaged: dailyMaps.accounts_engaged,
|
||||
followerDelta: insightSeries(seriesData, 'follower_count'),
|
||||
interactions: dailyMaps.total_interactions,
|
||||
};
|
||||
|
||||
const igInsights = {
|
||||
reach: sumSeries(series.reach) || lastValue(series.reach),
|
||||
impressions: sumSeries(series.impressions) || lastValue(series.impressions),
|
||||
profileViews: sumSeries(series.profileViews) || lastValue(series.profileViews),
|
||||
impressions:
|
||||
totalValueOf(totalData, 'views') ??
|
||||
(sumSeries(series.impressions) || lastValue(series.impressions)),
|
||||
profileViews:
|
||||
totalValueOf(totalData, 'profile_views') ??
|
||||
(sumSeries(series.profileViews) || lastValue(series.profileViews)),
|
||||
websiteClicks:
|
||||
sumSeries(series.websiteClicks) || lastValue(series.websiteClicks),
|
||||
totalValueOf(totalData, 'website_clicks') ??
|
||||
(sumSeries(series.websiteClicks) || lastValue(series.websiteClicks)),
|
||||
accountsEngaged:
|
||||
sumSeries(series.accountsEngaged) || lastValue(series.accountsEngaged),
|
||||
totalValueOf(totalData, 'accounts_engaged') ??
|
||||
(sumSeries(series.accountsEngaged) || lastValue(series.accountsEngaged)),
|
||||
followerDelta:
|
||||
sumSeries(series.followerDelta) || lastValue(series.followerDelta),
|
||||
interactions:
|
||||
sumSeries(series.interactions) || lastValue(series.interactions),
|
||||
totalValueOf(totalData, 'total_interactions') ??
|
||||
(sumSeries(series.interactions) || lastValue(series.interactions)),
|
||||
};
|
||||
|
||||
const mediaRes = await graphGet(`${encodeURIComponent(igId)}/media`, agencyToken, {
|
||||
fields:
|
||||
'id,caption,media_type,media_url,thumbnail_url,permalink,timestamp,like_count,comments_count',
|
||||
limit: '20',
|
||||
'id,caption,media_type,media_product_type,media_url,thumbnail_url,permalink,timestamp,like_count,comments_count,children{id,media_type,media_url,thumbnail_url}',
|
||||
limit: '30',
|
||||
});
|
||||
const posts: SocialPost[] = [];
|
||||
let postsError: string | undefined;
|
||||
if (mediaRes.ok) {
|
||||
for (const m of (mediaRes.json.data as Record<string, unknown>[]) || []) {
|
||||
const mediaType = (m.media_type as string) || null;
|
||||
const video = isVideoType(mediaType);
|
||||
const mediaProductType = (m.media_product_type as string) || null;
|
||||
const video = isVideoType(mediaType) || mediaProductType === 'REELS';
|
||||
const childrenRaw =
|
||||
(
|
||||
m.children as {
|
||||
data?: {
|
||||
id?: string;
|
||||
media_type?: string;
|
||||
media_url?: string;
|
||||
thumbnail_url?: string;
|
||||
}[];
|
||||
}
|
||||
)?.data || [];
|
||||
const children = childrenRaw.map((c) => ({
|
||||
id: c.id,
|
||||
mediaType: c.media_type || null,
|
||||
mediaUrl: c.media_url || null,
|
||||
thumbnailUrl: c.thumbnail_url || null,
|
||||
}));
|
||||
const mediaUrl = video
|
||||
? (m.media_url as string) || (m.thumbnail_url as string) || null
|
||||
: (m.media_url as string) || (m.thumbnail_url as string) || null;
|
||||
: (m.media_url as string) ||
|
||||
children[0]?.mediaUrl ||
|
||||
(m.thumbnail_url as string) ||
|
||||
null;
|
||||
const thumbnailUrl =
|
||||
(m.thumbnail_url as string) || (video ? null : (m.media_url as string)) || null;
|
||||
(m.thumbnail_url as string) ||
|
||||
(video ? null : (m.media_url as string)) ||
|
||||
children[0]?.thumbnailUrl ||
|
||||
children[0]?.mediaUrl ||
|
||||
null;
|
||||
let reach: number | null = null;
|
||||
let impressions: number | null = null;
|
||||
let shares = 0;
|
||||
@@ -662,7 +746,12 @@ async function fetchIgBundle(
|
||||
shares,
|
||||
reach,
|
||||
impressions,
|
||||
mediaType,
|
||||
mediaType:
|
||||
mediaProductType === 'REELS'
|
||||
? 'REELS'
|
||||
: mediaType || (children.length > 1 ? 'CAROUSEL_ALBUM' : null),
|
||||
mediaProductType,
|
||||
children: children.length ? children : undefined,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -675,7 +764,7 @@ async function fetchIgBundle(
|
||||
series,
|
||||
posts,
|
||||
postsError,
|
||||
insightsError: insightData.length ? undefined : insightsError,
|
||||
insightsError: undefined,
|
||||
since,
|
||||
until,
|
||||
};
|
||||
|
||||
@@ -85,13 +85,147 @@ type SocialPost = {
|
||||
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 isVideoPost(p: SocialPost): boolean {
|
||||
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;
|
||||
const url = p.mediaUrl || '';
|
||||
return /\.mp4($|\?)/i.test(url) || /video/i.test(url);
|
||||
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: {
|
||||
@@ -263,7 +397,7 @@ export default function SocialDashboard({
|
||||
icon: Eye,
|
||||
},
|
||||
{
|
||||
label: 'Impresiones/vistas IG',
|
||||
label: 'Visualizaciones IG',
|
||||
value: n(ig?.igInsights?.impressions),
|
||||
icon: Eye,
|
||||
},
|
||||
@@ -324,8 +458,8 @@ export default function SocialDashboard({
|
||||
color: 'bg-pink-400/80',
|
||||
},
|
||||
{
|
||||
title: 'IG · Impresiones/vistas',
|
||||
hint: 'Views / impressions',
|
||||
title: 'IG · Visualizaciones',
|
||||
hint: 'views (metric_type=total_value)',
|
||||
series: ig?.series?.impressions,
|
||||
total: ig?.igInsights?.impressions,
|
||||
color: 'bg-fuchsia-400/80',
|
||||
@@ -524,36 +658,13 @@ export default function SocialDashboard({
|
||||
key={p.id}
|
||||
className="rounded-2xl border border-gray-200 bg-white overflow-hidden flex flex-col"
|
||||
>
|
||||
<div className="relative aspect-square bg-gray-100">
|
||||
{p.mediaUrl && isVideoPost(p) ? (
|
||||
<video
|
||||
src={p.mediaUrl}
|
||||
poster={p.thumbnailUrl || undefined}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
controls
|
||||
playsInline
|
||||
preload="metadata"
|
||||
/>
|
||||
) : p.mediaUrl || p.thumbnailUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={p.thumbnailUrl || p.mediaUrl || ''}
|
||||
alt=""
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-xs text-gray-400 px-4 text-center">
|
||||
{p.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">
|
||||
{p.platform === 'instagram' ? 'IG' : 'FB'}
|
||||
{p.mediaType ? ` · ${p.mediaType}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<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">
|
||||
@@ -575,6 +686,11 @@ export default function SocialDashboard({
|
||||
<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
|
||||
|
||||
Reference in New Issue
Block a user