- Last 120 days of posts, reach vs the client's median, by weekday and 3-hour window (client time zone) - Needs 8+ posts and 2+ per slot; shown on Published (client filter) and as the New post time hint - Falls back to general market times when there isn't enough data Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
195 lines
11 KiB
JavaScript
195 lines
11 KiB
JavaScript
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 [best, setBest] = useState(null);
|
|
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]);
|
|
useEffect(() => { setBest(null); if (f.client) api(`/api/schedule/suggest?client=${f.client}`).then(setBest).catch(() => {}); }, [f.client]);
|
|
|
|
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>
|
|
)}
|
|
|
|
{best && (
|
|
<div className="panel px-4 py-3 text-[13px] flex flex-wrap items-center gap-x-4 gap-y-1.5">
|
|
<span className="font-semibold">Best times to post</span>
|
|
{best.best?.slots?.length ? best.best.slots.map((s) => (
|
|
<span key={`${s.day}${s.window}`} className="inline-flex items-center gap-1.5"><b>{s.day} {s.time}</b><span className="text-ok">+{s.lift}% reach</span><span className="text-faint">({s.posts} posts)</span></span>
|
|
)) : <span className="text-muted">Not enough Instagram posts with reach yet (needs 8; has {best.best?.basedOn ?? 0}). Using general times: {best.times.join(", ")}.</span>}
|
|
{best.best?.slots?.length > 0 && <span className="text-[12px] text-muted ml-auto">From the last 4 months of Instagram posts, {best.timezone.replace("_", " ")} time. An early signal, not a rule.</span>}
|
|
</div>
|
|
)}
|
|
|
|
{!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>
|
|
);
|
|
}
|