Published page: every client's posts with their numbers
- Feed merges Instagram/Facebook snapshot posts (with insights) and everything Quorla published - Filters (client, platform, 7/30/90 days), sort by interactions/reach/views, via-Quorla badge - YouTube view/like/comment counts fetched nightly and on demand (1 quota unit per 50 videos) - Snapshots now keep post thumbnails (IG media/thumbnail url, FB full_picture) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
e5f24799ea
commit
999e9fbcec
@ -148,7 +148,7 @@ export async function igAccountInsights(igId, { sinceDays = 28, metrics = IG_ACC
|
|||||||
|
|
||||||
export async function igRecentMedia(igId, { limit = 12, withInsights = true } = {}) {
|
export async function igRecentMedia(igId, { limit = 12, withInsights = true } = {}) {
|
||||||
const r = await graph(`${igId}/media`, {
|
const r = await graph(`${igId}/media`, {
|
||||||
fields: "id,caption,media_type,media_product_type,permalink,timestamp,like_count,comments_count",
|
fields: "id,caption,media_type,media_product_type,media_url,thumbnail_url,permalink,timestamp,like_count,comments_count",
|
||||||
limit,
|
limit,
|
||||||
});
|
});
|
||||||
const media = r.data || [];
|
const media = r.data || [];
|
||||||
@ -204,7 +204,7 @@ export async function pageRecentPosts(account, { limit = 10 } = {}) {
|
|||||||
needPage(account);
|
needPage(account);
|
||||||
const r = await graph(
|
const r = await graph(
|
||||||
`${account.page_id}/posts`,
|
`${account.page_id}/posts`,
|
||||||
{ fields: "id,message,created_time,permalink_url,shares,reactions.summary(total_count).limit(0),comments.summary(total_count).limit(0)", limit },
|
{ fields: "id,message,created_time,permalink_url,full_picture,shares,reactions.summary(total_count).limit(0),comments.summary(total_count).limit(0)", limit },
|
||||||
{ token: account.page_token }
|
{ token: account.page_token }
|
||||||
);
|
);
|
||||||
return (r.data || []).map((p) => ({
|
return (r.data || []).map((p) => ({
|
||||||
@ -212,6 +212,7 @@ export async function pageRecentPosts(account, { limit = 10 } = {}) {
|
|||||||
created_time: p.created_time,
|
created_time: p.created_time,
|
||||||
permalink_url: p.permalink_url,
|
permalink_url: p.permalink_url,
|
||||||
message: p.message,
|
message: p.message,
|
||||||
|
picture: p.full_picture || null,
|
||||||
reactions: p.reactions?.summary?.total_count ?? 0,
|
reactions: p.reactions?.summary?.total_count ?? 0,
|
||||||
comments: p.comments?.summary?.total_count ?? 0,
|
comments: p.comments?.summary?.total_count ?? 0,
|
||||||
shares: p.shares?.count ?? 0,
|
shares: p.shares?.count ?? 0,
|
||||||
|
|||||||
@ -97,6 +97,19 @@ export async function setTarget(connId, targetId) {
|
|||||||
return vault.updateConnection(connId, { target: t });
|
return vault.updateConnection(connId, { target: t });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Per-post numbers for platforms whose adapter can read them (YouTube today). ids: the platform's post ids. */
|
||||||
|
export async function postStats(platform, client, ids) {
|
||||||
|
const a = adapter(platform);
|
||||||
|
if (!a.stats || !ids.length) return {};
|
||||||
|
const conn = vault.connectionFor(client, platform);
|
||||||
|
if (!conn) return {};
|
||||||
|
const { tokens } = await tokensFor(conn.id);
|
||||||
|
if (platform === "youtube") quota.ensure("youtube", null, "read");
|
||||||
|
const out = await a.stats(tokens, ids);
|
||||||
|
if (platform === "youtube") quota.record("youtube", null, "read");
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/** Re-read the account (name, followers) to check the connection still works. */
|
/** Re-read the account (name, followers) to check the connection still works. */
|
||||||
export async function checkConnection(connId) {
|
export async function checkConnection(connId) {
|
||||||
const { conn, tokens } = await tokensFor(connId);
|
const { conn, tokens } = await tokensFor(connId);
|
||||||
|
|||||||
@ -32,6 +32,16 @@ export default {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** View/like/comment counts for uploaded videos (1 quota unit per call, up to 50 ids). */
|
||||||
|
async stats(tokens, ids) {
|
||||||
|
if (!ids.length) return {};
|
||||||
|
const { data } = await request("YouTube", `${API}/videos`, { token: tokens.accessToken, query: { part: "statistics,snippet", id: ids.slice(0, 50).join(",") } });
|
||||||
|
return Object.fromEntries((data.items || []).map((v) => [v.id, {
|
||||||
|
views: Number(v.statistics?.viewCount ?? 0), likes: Number(v.statistics?.likeCount ?? 0), comments: Number(v.statistics?.commentCount ?? 0),
|
||||||
|
thumb: v.snippet?.thumbnails?.medium?.url || v.snippet?.thumbnails?.default?.url || null,
|
||||||
|
}]));
|
||||||
|
},
|
||||||
|
|
||||||
async publish({ tokens, post, opts, caption }) {
|
async publish({ tokens, post, opts, caption }) {
|
||||||
const src = (post.media || [])[0];
|
const src = (post.media || [])[0];
|
||||||
if (!src || !isVideo(src)) throw new Error("YouTube needs one video file.");
|
if (!src || !isVideo(src)) throw new Error("YouTube needs one video file.");
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { login, logout, currentUser, requireAuth, requireAdmin, setupFirstAdmin,
|
|||||||
import * as users from "./users.js";
|
import * as users from "./users.js";
|
||||||
import * as clients from "./clients.js";
|
import * as clients from "./clients.js";
|
||||||
import * as metrics from "./metrics.js";
|
import * as metrics from "./metrics.js";
|
||||||
|
import * as published from "./published.js";
|
||||||
import * as odoo from "./odoo.js";
|
import * as odoo from "./odoo.js";
|
||||||
import * as jobs from "./jobs.js";
|
import * as jobs from "./jobs.js";
|
||||||
import * as docs from "./docs.js";
|
import * as docs from "./docs.js";
|
||||||
@ -288,6 +289,10 @@ app.get("/api/schedule/media", h(async (req, res) => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
||||||
|
// ---------- published posts and their numbers ----------
|
||||||
|
app.get("/api/published", h(async (req) => published.feed({ client: req.query.client ? (clients.safeSlug(req.query.client), String(req.query.client)) : undefined, platform: req.query.platform ? String(req.query.platform) : undefined, days: Number(req.query.days) || 30 })));
|
||||||
|
app.post("/api/published/refresh", h(async () => published.refreshStats()));
|
||||||
|
|
||||||
// ---------- content library: team uploads finished work per client ----------
|
// ---------- content library: team uploads finished work per client ----------
|
||||||
const who = (req) => req.user.name || req.user.email;
|
const who = (req) => req.user.name || req.user.email;
|
||||||
const esc = (s) => String(s || "").replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c]);
|
const esc = (s) => String(s || "").replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c]);
|
||||||
@ -369,6 +374,7 @@ function scheduleNightly() {
|
|||||||
if (next <= new Date()) next.setDate(next.getDate() + 1);
|
if (next <= new Date()) next.setDate(next.getDate() + 1);
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
try { console.log("Nightly refresh:", await metrics.refreshAll()); } catch (e) { console.error("Nightly refresh failed", e); }
|
try { console.log("Nightly refresh:", await metrics.refreshAll()); } catch (e) { console.error("Nightly refresh failed", e); }
|
||||||
|
try { console.log("Post stats:", await published.refreshStats()); } catch (e) { console.error("Post stats failed", e); }
|
||||||
scheduleNightly();
|
scheduleNightly();
|
||||||
}, next - new Date());
|
}, next - new Date());
|
||||||
}
|
}
|
||||||
|
|||||||
124
dashboard/server/published.js
Normal file
124
dashboard/server/published.js
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
// "Published" feed: every post that went out on a client's accounts, with its numbers.
|
||||||
|
// Instagram and Facebook come from the nightly snapshots (so posts made outside Quorla show too);
|
||||||
|
// other platforms come from what Quorla published, with YouTube counts fetched on refresh.
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { DATA_DIR } from "./config.js";
|
||||||
|
import * as clients from "./clients.js";
|
||||||
|
import * as metrics from "./metrics.js";
|
||||||
|
import * as sched from "../../connector/src/schedule.js";
|
||||||
|
import * as extra from "../../connector/src/platforms/index.js";
|
||||||
|
|
||||||
|
const STATS_FILE = path.join(DATA_DIR, "post-stats.json");
|
||||||
|
const readStats = () => { try { return JSON.parse(fs.readFileSync(STATS_FILE, "utf8")); } catch { return { fetchedAt: null, posts: {} }; } };
|
||||||
|
|
||||||
|
const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
|
||||||
|
const sum = (...vs) => { const xs = vs.map(num).filter((x) => x !== null); return xs.length ? xs.reduce((a, b) => a + b, 0) : null; };
|
||||||
|
|
||||||
|
function igItem(client, p, viaQuorla) {
|
||||||
|
const i = p.insights || {};
|
||||||
|
const likes = num(p.like_count), comments = num(p.comments_count), saves = num(i.saved), shares = num(i.shares);
|
||||||
|
return {
|
||||||
|
key: `instagram:${p.id}`, client: client.slug, clientName: client.name, platform: "instagram",
|
||||||
|
id: p.id, at: p.timestamp, caption: p.caption || "", permalink: p.permalink || null,
|
||||||
|
format: p.media_product_type === "REELS" ? "Reel" : p.media_type === "CAROUSEL_ALBUM" ? "Carousel" : p.media_type === "VIDEO" ? "Video" : "Image",
|
||||||
|
thumb: p.thumbnail_url || p.media_url || null,
|
||||||
|
metrics: { reach: num(i.reach), views: num(i.views), likes, comments, saves, shares },
|
||||||
|
interactions: num(i.total_interactions) ?? sum(likes, comments, saves, shares),
|
||||||
|
viaQuorla,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fbItem(client, p, viaQuorla) {
|
||||||
|
return {
|
||||||
|
key: `facebook:${p.id}`, client: client.slug, clientName: client.name, platform: "facebook",
|
||||||
|
id: p.id, at: p.created_time, caption: p.message || "", permalink: p.permalink_url || null, format: "Post",
|
||||||
|
thumb: p.picture || null,
|
||||||
|
metrics: { reach: null, views: null, likes: num(p.reactions), comments: num(p.comments), saves: null, shares: num(p.shares) },
|
||||||
|
interactions: sum(p.reactions, p.comments, p.shares),
|
||||||
|
viaQuorla,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All published posts, newest first. Filters: client slug, platform id, days back. */
|
||||||
|
export function feed({ client, platform, days = 30 } = {}) {
|
||||||
|
const since = Date.now() - Number(days || 30) * 86400000;
|
||||||
|
const all = clients.listClients().filter((c) => !client || c.slug === client);
|
||||||
|
const quorla = sched.listPosts({}).filter((p) => ["published", "partial"].includes(p.status));
|
||||||
|
const quorlaIds = new Set(quorla.flatMap((p) => Object.values(p.results || {}).map((r) => r?.id).filter(Boolean).map(String)));
|
||||||
|
const stats = readStats().posts;
|
||||||
|
const items = [];
|
||||||
|
|
||||||
|
for (const c of all) {
|
||||||
|
const snap = metrics.readSnapshot(c.slug);
|
||||||
|
for (const p of snap?.ig?.posts || []) items.push(igItem(c, p, quorlaIds.has(String(p.id))));
|
||||||
|
for (const p of snap?.fb?.posts || []) items.push(fbItem(c, p, quorlaIds.has(String(p.id))));
|
||||||
|
}
|
||||||
|
const seen = new Set(items.map((i) => i.key));
|
||||||
|
const byClient = Object.fromEntries(all.map((c) => [c.slug, c]));
|
||||||
|
|
||||||
|
// Platforms the snapshots don't cover, plus Quorla posts newer than the last snapshot.
|
||||||
|
for (const p of quorla) {
|
||||||
|
const c = byClient[p.client];
|
||||||
|
if (!c) continue;
|
||||||
|
for (const [pl, r] of Object.entries(p.results || {})) {
|
||||||
|
if (!r?.id || r.cancelled) continue;
|
||||||
|
const key = `${pl}:${r.id}`;
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
const s = stats[key] || {};
|
||||||
|
items.push({
|
||||||
|
key, client: c.slug, clientName: c.name, platform: pl, id: String(r.id),
|
||||||
|
at: p.publishedAt || p.publishAt, caption: p.options?.[pl]?.caption ?? p.caption ?? "", permalink: r.permalink || null,
|
||||||
|
format: p.type === "REELS" ? "Video" : p.type === "CAROUSEL" ? "Carousel" : p.type === "TEXT" ? "Text" : "Image",
|
||||||
|
thumb: s.thumb || null, media: p.media?.[0] || null,
|
||||||
|
metrics: { reach: null, views: num(s.views), likes: num(s.likes), comments: num(s.comments), saves: null, shares: null },
|
||||||
|
interactions: sum(s.likes, s.comments),
|
||||||
|
viaQuorla: true, note: r.inbox ? "Sent to the TikTok app to finish" : null,
|
||||||
|
});
|
||||||
|
seen.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = items
|
||||||
|
.filter((i) => (!platform || i.platform === platform) && i.at && new Date(i.at).getTime() >= since)
|
||||||
|
.sort((a, b) => new Date(b.at) - new Date(a.at));
|
||||||
|
const reachKnown = list.filter((i) => i.metrics.reach !== null);
|
||||||
|
return {
|
||||||
|
items: list,
|
||||||
|
totals: {
|
||||||
|
posts: list.length,
|
||||||
|
viaQuorla: list.filter((i) => i.viaQuorla).length,
|
||||||
|
reach: reachKnown.reduce((s, i) => s + i.metrics.reach, 0),
|
||||||
|
interactions: list.reduce((s, i) => s + (i.interactions || 0), 0),
|
||||||
|
avgInteractions: list.length ? Math.round(list.reduce((s, i) => s + (i.interactions || 0), 0) / list.length) : 0,
|
||||||
|
},
|
||||||
|
snapshotsAt: Object.fromEntries(all.map((c) => [c.slug, metrics.readSnapshot(c.slug)?.fetchedAt || null])),
|
||||||
|
statsAt: readStats().fetchedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch counts for Quorla-published posts on platforms with a stats reader (YouTube), last 90 days. */
|
||||||
|
export async function refreshStats() {
|
||||||
|
const since = Date.now() - 90 * 86400000;
|
||||||
|
const want = {};
|
||||||
|
for (const p of sched.listPosts({})) {
|
||||||
|
if (!["published", "partial"].includes(p.status) || new Date(p.publishedAt || p.publishAt).getTime() < since) continue;
|
||||||
|
for (const [pl, r] of Object.entries(p.results || {})) {
|
||||||
|
if (pl !== "youtube" || !r?.id) continue;
|
||||||
|
((want[p.client] ||= {})[pl] ||= []).push(String(r.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const out = readStats();
|
||||||
|
const errors = [];
|
||||||
|
for (const [client, byPl] of Object.entries(want)) {
|
||||||
|
for (const [pl, ids] of Object.entries(byPl)) {
|
||||||
|
try {
|
||||||
|
const s = await extra.postStats(pl, client, ids);
|
||||||
|
for (const [id, v] of Object.entries(s)) out.posts[`${pl}:${id}`] = v;
|
||||||
|
} catch (e) { errors.push(`${client} ${pl}: ${e.message}`); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.fetchedAt = new Date().toISOString();
|
||||||
|
fs.writeFileSync(STATS_FILE, JSON.stringify(out, null, 1));
|
||||||
|
return { fetchedAt: out.fetchedAt, errors };
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||||
import { BrowserRouter, NavLink, Navigate, Route, Routes } from "react-router-dom";
|
import { BrowserRouter, NavLink, Navigate, Route, Routes } from "react-router-dom";
|
||||||
import { LayoutGrid, Users2, Sparkles, Settings2, LogOut, CalendarClock, MessageCircle, FolderUp, Plug } from "lucide-react";
|
import { LayoutGrid, Users2, Sparkles, Settings2, LogOut, CalendarClock, MessageCircle, FolderUp, Plug, BarChart3 } from "lucide-react";
|
||||||
import { api } from "./api.js";
|
import { api } from "./api.js";
|
||||||
import { cx, Spinner } from "./ui.jsx";
|
import { cx, Spinner } from "./ui.jsx";
|
||||||
import Login from "./pages/Login.jsx";
|
import Login from "./pages/Login.jsx";
|
||||||
@ -10,6 +10,7 @@ import Team from "./pages/Team.jsx";
|
|||||||
import Runs from "./pages/Runs.jsx";
|
import Runs from "./pages/Runs.jsx";
|
||||||
import Settings from "./pages/Settings.jsx";
|
import Settings from "./pages/Settings.jsx";
|
||||||
import Publishing from "./pages/Publishing.jsx";
|
import Publishing from "./pages/Publishing.jsx";
|
||||||
|
import Published from "./pages/Published.jsx";
|
||||||
import Inbox from "./pages/Inbox.jsx";
|
import Inbox from "./pages/Inbox.jsx";
|
||||||
import Library from "./pages/Library.jsx";
|
import Library from "./pages/Library.jsx";
|
||||||
import Connections from "./pages/Connections.jsx";
|
import Connections from "./pages/Connections.jsx";
|
||||||
@ -21,6 +22,7 @@ const NAV = [
|
|||||||
{ to: "/", label: "Clients", icon: LayoutGrid, end: true },
|
{ to: "/", label: "Clients", icon: LayoutGrid, end: true },
|
||||||
{ to: "/library", label: "Content library", icon: FolderUp },
|
{ to: "/library", label: "Content library", icon: FolderUp },
|
||||||
{ to: "/publishing", label: "Publishing", icon: CalendarClock },
|
{ to: "/publishing", label: "Publishing", icon: CalendarClock },
|
||||||
|
{ to: "/published", label: "Published", icon: BarChart3 },
|
||||||
{ to: "/inbox", label: "Inbox", icon: MessageCircle },
|
{ to: "/inbox", label: "Inbox", icon: MessageCircle },
|
||||||
{ to: "/connections", label: "Connections", icon: Plug },
|
{ to: "/connections", label: "Connections", icon: Plug },
|
||||||
{ to: "/team", label: "Team", icon: Users2 },
|
{ to: "/team", label: "Team", icon: Users2 },
|
||||||
@ -116,6 +118,7 @@ export default function App() {
|
|||||||
<Route path="/clients/:slug" element={<Client />} />
|
<Route path="/clients/:slug" element={<Client />} />
|
||||||
<Route path="/library" element={<Library />} />
|
<Route path="/library" element={<Library />} />
|
||||||
<Route path="/publishing" element={<Publishing />} />
|
<Route path="/publishing" element={<Publishing />} />
|
||||||
|
<Route path="/published" element={<Published />} />
|
||||||
<Route path="/inbox" element={<Inbox />} />
|
<Route path="/inbox" element={<Inbox />} />
|
||||||
<Route path="/connections" element={<Connections />} />
|
<Route path="/connections" element={<Connections />} />
|
||||||
<Route path="/team" element={<Team />} />
|
<Route path="/team" element={<Team />} />
|
||||||
|
|||||||
182
dashboard/web/src/pages/Published.jsx
Normal file
182
dashboard/web/src/pages/Published.jsx
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { ExternalLink, ImageOff, RefreshCw, Send } from "lucide-react";
|
||||||
|
import { api } from "../api.js";
|
||||||
|
import { useAuth } from "../App.jsx";
|
||||||
|
import { Badge, Button, cx, Empty, ErrorNote, fmt, fmtDate, Spinner, Stat, StatCell, StatRow, timeAgo } from "../ui.jsx";
|
||||||
|
|
||||||
|
const PLATFORMS = {
|
||||||
|
instagram: { name: "Instagram", color: "#d62976" },
|
||||||
|
facebook: { name: "Facebook", color: "#1877f2" },
|
||||||
|
youtube: { name: "YouTube", color: "#ff0000" },
|
||||||
|
tiktok: { name: "TikTok", color: "#010101" },
|
||||||
|
gbp: { name: "Google Business", color: "#4285f4" },
|
||||||
|
linkedin: { name: "LinkedIn", color: "#0a66c2" },
|
||||||
|
pinterest: { name: "Pinterest", color: "#e60023" },
|
||||||
|
threads: { name: "Threads", color: "#000000" },
|
||||||
|
reddit: { name: "Reddit", color: "#ff4500" },
|
||||||
|
};
|
||||||
|
const COLS = [["reach", "Reach"], ["views", "Views"], ["likes", "Likes"], ["comments", "Comments"], ["saves", "Saves"], ["shares", "Shares"]];
|
||||||
|
const SORTS = { newest: "Newest first", interactions: "Most interactions", reach: "Most reach", views: "Most views" };
|
||||||
|
const PAGE = 50;
|
||||||
|
|
||||||
|
const isVideo = (m) => /\.(mp4|mov|m4v|webm)$/i.test(String(m || ""));
|
||||||
|
|
||||||
|
function Thumb({ item }) {
|
||||||
|
const [broken, setBroken] = useState(false);
|
||||||
|
const local = item.media ? `/api/schedule/media?path=${encodeURIComponent(item.media)}` : null;
|
||||||
|
const p = PLATFORMS[item.platform] || { name: item.platform, color: "#6b6e78" };
|
||||||
|
const box = "size-14 rounded-md shrink-0 overflow-hidden bg-canvas border border-line";
|
||||||
|
if (!broken && item.thumb) return <img src={item.thumb} alt="" loading="lazy" className={cx(box, "object-cover")} onError={() => setBroken(true)} />;
|
||||||
|
if (local && isVideo(item.media)) return <video src={local} muted preload="metadata" className={cx(box, "object-cover bg-black")} />;
|
||||||
|
if (local) return <img src={local} alt="" loading="lazy" className={cx(box, "object-cover")} />;
|
||||||
|
return (
|
||||||
|
<div className={cx(box, "grid place-items-center text-white font-bold text-[13px]")} style={{ background: p.color }} aria-hidden="true">
|
||||||
|
{broken ? <ImageOff className="size-4 opacity-80" /> : p.name[0]}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Published() {
|
||||||
|
const { isAdmin } = useAuth();
|
||||||
|
const [clients, setClients] = useState([]);
|
||||||
|
const [f, setF] = useState({ client: "", platform: "", days: 30, sort: "newest" });
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [shown, setShown] = useState(PAGE);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const set = (k, v) => { setF((s) => ({ ...s, [k]: v })); setShown(PAGE); };
|
||||||
|
|
||||||
|
useEffect(() => { api("/api/overview").then((r) => setClients(r.clients)).catch(() => {}); }, []);
|
||||||
|
const load = useCallback(() => {
|
||||||
|
const q = new URLSearchParams({ days: String(f.days) });
|
||||||
|
if (f.client) q.set("client", f.client);
|
||||||
|
if (f.platform) q.set("platform", f.platform);
|
||||||
|
setError(null);
|
||||||
|
return api(`/api/published?${q}`).then(setData).catch(setError);
|
||||||
|
}, [f.client, f.platform, f.days]);
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
const list = [...(data?.items || [])];
|
||||||
|
const key = f.sort === "interactions" ? (i) => i.interactions ?? -1 : f.sort === "reach" ? (i) => i.metrics.reach ?? -1 : f.sort === "views" ? (i) => i.metrics.views ?? -1 : null;
|
||||||
|
if (key) list.sort((a, b) => key(b) - key(a));
|
||||||
|
return list;
|
||||||
|
}, [data, f.sort]);
|
||||||
|
|
||||||
|
const refreshStats = async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
try { await api("/api/published/refresh", { method: "POST" }); await load(); } catch (e) { setError(e); }
|
||||||
|
setRefreshing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const t = data?.totals;
|
||||||
|
const platformsInData = useMemo(() => [...new Set((data?.items || []).map((i) => i.platform))], [data]);
|
||||||
|
const oldestSnap = data ? Object.values(data.snapshotsAt).filter(Boolean).sort()[0] : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<header className="flex items-end justify-between gap-4 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-[22px] font-extrabold">Published</h1>
|
||||||
|
<p className="text-muted mt-0.5">
|
||||||
|
Everything that went out on your clients' accounts, with its numbers
|
||||||
|
{oldestSnap ? ` · Instagram and Facebook as of ${timeAgo(oldestSnap)}` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{isAdmin && <Button icon={RefreshCw} loading={refreshing} onClick={refreshStats}>Refresh YouTube numbers</Button>}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 items-center">
|
||||||
|
<select id="pub-client" aria-label="Client" className="input h-9 w-auto" value={f.client} onChange={(e) => set("client", e.target.value)}>
|
||||||
|
<option value="">All clients</option>
|
||||||
|
{clients.map((c) => <option key={c.slug} value={c.slug}>{c.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<select id="pub-platform" aria-label="Platform" className="input h-9 w-auto" value={f.platform} onChange={(e) => set("platform", e.target.value)}>
|
||||||
|
<option value="">All platforms</option>
|
||||||
|
{Object.entries(PLATFORMS).filter(([id]) => !data || platformsInData.includes(id) || f.platform === id).map(([id, p]) => <option key={id} value={id}>{p.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<div className="inline-flex rounded-md border border-line-strong bg-white p-0.5" role="group" aria-label="Period">
|
||||||
|
{[7, 30, 90].map((d) => (
|
||||||
|
<button key={d} type="button" onClick={() => set("days", d)} aria-pressed={f.days === d}
|
||||||
|
className={cx("h-8 px-3 rounded text-[13px] font-medium", f.days === d ? "bg-brand-soft text-brand-deep" : "text-ink-2 hover:bg-canvas")}>
|
||||||
|
{d} days
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<select id="pub-sort" aria-label="Sort" className="input h-9 w-auto ml-auto" value={f.sort} onChange={(e) => set("sort", e.target.value)}>
|
||||||
|
{Object.entries(SORTS).map(([k, l]) => <option key={k} value={k}>{l}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ErrorNote error={error} />
|
||||||
|
|
||||||
|
{t && (
|
||||||
|
<StatRow className="grid-cols-2 md:grid-cols-4">
|
||||||
|
<StatCell><Stat label="Posts" value={fmt(t.posts)} note={`${fmt(t.viaQuorla)} sent through Quorla`} /></StatCell>
|
||||||
|
<StatCell><Stat label="Accounts reached" value={fmt(t.reach)} note="Instagram, summed per post" /></StatCell>
|
||||||
|
<StatCell><Stat label="Interactions" value={fmt(t.interactions)} note="likes, comments, saves, shares" /></StatCell>
|
||||||
|
<StatCell><Stat label="Per post" value={fmt(t.avgInteractions)} note="average interactions" /></StatCell>
|
||||||
|
</StatRow>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!data && !error ? <Spinner /> : data && !rows.length ? (
|
||||||
|
<div className="panel"><Empty icon={Send} title="Nothing published in this period">Try a longer period or another client. Instagram and Facebook posts appear after the next numbers refresh.</Empty></div>
|
||||||
|
) : data && (
|
||||||
|
<div className="panel overflow-x-auto">
|
||||||
|
<table className="tbl min-w-[960px]">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Post</th>
|
||||||
|
<th>Platform</th>
|
||||||
|
{COLS.map(([k, l]) => <th key={k} className="text-right">{l}</th>)}
|
||||||
|
<th className="text-right">Interactions</th>
|
||||||
|
<th><span className="sr-only">Open</span></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.slice(0, shown).map((i) => {
|
||||||
|
const p = PLATFORMS[i.platform] || { name: i.platform, color: "#6b6e78" };
|
||||||
|
return (
|
||||||
|
<tr key={i.key}>
|
||||||
|
<td className="max-w-[420px]">
|
||||||
|
<div className="flex gap-3 items-start">
|
||||||
|
<Thumb item={i} />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-[13px] text-ink line-clamp-2">{i.caption || <span className="text-faint">No caption</span>}</div>
|
||||||
|
<div className="text-[12px] text-muted mt-1 flex flex-wrap gap-x-2">
|
||||||
|
<span className="font-medium text-ink-2">{i.clientName}</span>
|
||||||
|
<span>{i.format}</span>
|
||||||
|
<span>{fmtDate(i.at, { month: "short", day: "numeric", year: "numeric" })}</span>
|
||||||
|
{i.viaQuorla && <Badge tone="brand">via Quorla</Badge>}
|
||||||
|
</div>
|
||||||
|
{i.note && <div className="text-[12px] text-muted mt-0.5">{i.note}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap">
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-[13px]"><span className="size-2 rounded-full" style={{ background: p.color }} aria-hidden="true" />{p.name}</span>
|
||||||
|
</td>
|
||||||
|
{COLS.map(([k]) => <td key={k} className="text-right num text-[13px]">{fmt(i.metrics[k])}</td>)}
|
||||||
|
<td className="text-right num font-semibold">{fmt(i.interactions)}</td>
|
||||||
|
<td className="text-right">
|
||||||
|
{i.permalink && <a href={i.permalink} target="_blank" rel="noreferrer" className="inline-flex p-1.5 rounded hover:bg-canvas text-muted" aria-label={`Open on ${p.name}`}><ExternalLink className="size-4" /></a>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{rows.length > shown && (
|
||||||
|
<div className="border-t border-line p-3 text-center">
|
||||||
|
<Button size="sm" onClick={() => setShown((n) => n + PAGE)}>Show {Math.min(PAGE, rows.length - shown)} more of {fmt(rows.length - shown)}</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-[12px] text-muted">
|
||||||
|
Instagram and Facebook numbers refresh nightly with each client's snapshot. Reach and saves aren't available for Facebook posts.
|
||||||
|
YouTube counts refresh nightly{data?.statsAt ? ` (last ${timeAgo(data.statsAt)})` : ""}. TikTok and Google Business Profile don't share per-post numbers with Quorla yet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user