metatroncubeswdev 82814f3757 White-label workspace and getting-started checklist
- Settings → Workspace: agency name, logo, brand colour, support email, website
- Logo replaces Quorla's in the app (with 'Powered by Quorla'), sign-in page, client approval page and PDF reports;
  brand colour drives buttons, links and highlights; emails and reports use the agency name
- Getting started checklist on the Clients page for admins, checked against the real state
  (workspace, Meta, first client, team, platforms, email, library, first post, approval link); dismissible

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:28:23 -04:00

168 lines
7.6 KiB
JavaScript

import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Plus, RefreshCw, AlertTriangle, CircleAlert, Info, Link2Off } from "lucide-react";
import { api } from "../api.js";
import { useAuth } from "../App.jsx";
import { Badge, Button, cx, Empty, ErrorNote, fmt, fmtDate, Panel, Spark, Spinner, Stat, StatCell, StatRow, timeAgo } from "../ui.jsx";
import AddClientDialog from "./AddClientDialog.jsx";
import GettingStarted from "../GettingStarted.jsx";
const LEVEL = {
bad: { icon: CircleAlert, cls: "text-bad" },
warn: { icon: AlertTriangle, cls: "text-warn" },
info: { icon: Info, cls: "text-brand" },
};
export default function Overview() {
const { isAdmin } = useAuth();
const nav = useNavigate();
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [refreshing, setRefreshing] = useState(false);
const [adding, setAdding] = useState(false);
const load = useCallback(() => api("/api/overview").then(setData).catch(setError), []);
useEffect(() => { load(); }, [load]);
// While any client is refreshing, poll so numbers fill in as they arrive.
const anyRefreshing = refreshing || data?.clients.some((c) => c.refreshing);
useEffect(() => {
if (!anyRefreshing) return;
const t = setInterval(load, 6000);
return () => clearInterval(t);
}, [anyRefreshing, load]);
const refreshAll = async () => {
setRefreshing(true);
await api("/api/refresh-all", { method: "POST" }).catch(setError);
setTimeout(load, 1500);
setTimeout(() => setRefreshing(false), 20000);
};
if (!data && !error) return <Spinner />;
const t = data?.totals;
const oldest = data?.clients.filter((c) => c.fetchedAt).map((c) => c.fetchedAt).sort()[0];
return (
<div className="space-y-5">
<header className="flex items-end justify-between gap-4">
<div>
<h1 className="text-[22px] font-extrabold">Clients</h1>
<p className="text-muted mt-0.5">
Instagram and Facebook, last 28 days{oldest ? ` · numbers as of ${timeAgo(oldest)}` : ""}
</p>
</div>
<div className="flex gap-2">
<Button icon={RefreshCw} onClick={refreshAll} loading={anyRefreshing}>
{anyRefreshing ? "Refreshing" : "Refresh numbers"}
</Button>
{isAdmin && (
<Button variant="primary" icon={Plus} onClick={() => setAdding(true)}>
Add business
</Button>
)}
</div>
</header>
<ErrorNote error={error} />
<GettingStarted />
{t && (
<StatRow className="grid-cols-5">
<StatCell><Stat label="Active clients" value={fmt(t.clients)} note={t.linked < t.clients ? `${t.clients - t.linked} not linked` : "all linked to Meta"} /></StatCell>
<StatCell><Stat label="IG followers" value={fmt(t.igFollowers)} note="across all clients" /></StatCell>
<StatCell><Stat label="IG accounts reached" value={fmt(t.igReach28)} note="28 days, summed" /></StatCell>
<StatCell><Stat label="IG posts" value={fmt(t.postsLast28)} note="published in 28 days" /></StatCell>
<StatCell><Stat label="FB followers" value={fmt(t.fbFollowers)} note="across all Pages" /></StatCell>
</StatRow>
)}
<div className="grid grid-cols-[minmax(0,1fr)_280px] gap-5 items-start">
<Panel bodyClass="overflow-x-auto">
{data?.clients.length ? (
<table className="tbl [&_td]:px-3 [&_th]:px-3">
<thead>
<tr>
<th>Client</th>
<th className="text-right">Followers</th>
<th className="text-right">Reached</th>
<th className="text-right">Posts</th>
<th>Last post</th>
<th>Reach per post</th>
<th>Last audit</th>
</tr>
</thead>
<tbody>
{data.clients.map((c) => (
<tr key={c.slug} className="clickable" onClick={() => nav(`/clients/${c.slug}`)}>
<td className="max-w-[220px]">
<div className="font-medium text-ink truncate">{c.name}</div>
<div className="text-[12px] text-muted truncate">
{c.linked ? (c.igUsername ? `@${c.igUsername}` : "Facebook only") : (
<span className="inline-flex items-center gap-1 text-warn"><Link2Off className="size-3" /> not linked</span>
)}
</div>
</td>
<td className="text-right num">{fmt(c.igFollowers)}</td>
<td className="text-right num">{fmt(c.igReach28)}</td>
<td className="text-right num">{fmt(c.postsLast28)}</td>
<td>
{c.refreshing ? (
<span className="text-muted">updating…</span>
) : c.daysSinceLastPost == null ? (
<span className="text-faint">—</span>
) : (
<span className={cx("num", c.daysSinceLastPost >= 14 ? "text-bad font-medium" : c.daysSinceLastPost >= 7 ? "text-warn font-medium" : "text-ink-2")}>
{c.daysSinceLastPost === 0 ? "today" : `${c.daysSinceLastPost}d ago`}
</span>
)}
</td>
<td><Spark values={c.reachSpark} width={84} /></td>
<td className="text-ink-2 whitespace-nowrap">
{c.lastAuditAt ? fmtDate(c.lastAuditAt) : <Badge>none</Badge>}
</td>
</tr>
))}
</tbody>
</table>
) : (
<Empty title="No clients yet" action={isAdmin && <Button variant="primary" icon={Plus} onClick={() => setAdding(true)}>Add business</Button>}>
Connect a Facebook Page to the agency's Meta account, then add it here.
</Empty>
)}
{data?.clients.some((c) => !c.fetchedAt && c.linked) && (
<div className="px-4 py-2.5 border-t border-line text-[12.5px] text-muted">
Some clients haven't been fetched yet. Use <b className="text-ink-2">Refresh numbers</b>; each account takes about a minute.
</div>
)}
</Panel>
<Panel title="Needs attention" subtitle={data ? `${data.attention.length} item${data.attention.length === 1 ? "" : "s"}` : null} bodyClass="max-h-[560px] overflow-y-auto">
{data?.attention.length ? (
<ul className="divide-y divide-line">
{data.attention.map((a, i) => {
const L = LEVEL[a.level] || LEVEL.info;
return (
<li key={i}>
<button onClick={() => nav(`/clients/${a.slug}`)} className="w-full text-left flex gap-2.5 px-4 py-3 hover:bg-[#f8fafc]">
<L.icon className={cx("size-4 mt-0.5 shrink-0", L.cls)} strokeWidth={2} />
<span className="min-w-0">
<span className="block text-[12.5px] font-medium text-ink truncate">{a.name}</span>
<span className="block text-[12.5px] text-muted">{a.text}</span>
</span>
</button>
</li>
);
})}
</ul>
) : (
<Empty title="All clear">Nothing needs attention right now.</Empty>
)}
</Panel>
</div>
<AddClientDialog open={adding} onClose={() => setAdding(false)} onAdded={(c) => nav(`/clients/${c.slug}`)} />
</div>
);
}