import { useCallback, useEffect, useMemo, useState } from "react";
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { Bar, BarChart, CartesianGrid, Cell, Area, AreaChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { ArrowLeft, ArrowUpRight, ClipboardCheck, CalendarRange, FileBarChart, FileDown, FileText, Eye, RefreshCw, Sparkles, Briefcase, Link2, Facebook, Instagram } from "lucide-react";
import { api } from "../api.js";
import ClientPortal from "./ClientPortal.jsx";
import MonthlyReport from "./MonthlyReport.jsx";
import BrandKit from "./BrandKit.jsx";
import LinkInBio from "./LinkInBio.jsx";
import Screenshots from "./Screenshots.jsx";
import { useAuth } from "../App.jsx";
import {
Badge, Button, cx, Dialog, Empty, ErrorNote, fmt, fmtDate, fmtDateTime, Panel, Spinner, Stat, StatCell, StatRow, STATUS_TONE, Tabs, timeAgo,
} from "../ui.jsx";
const FORMAT = {
reel: { label: "Reel", color: "#3b3778" },
carousel: { label: "Carousel", color: "#fc6d45" },
image: { label: "Image", color: "#a9afba" },
};
const formatOf = (p) => (p.media_product_type === "REELS" || p.media_type === "VIDEO" ? "reel" : p.media_type === "CAROUSEL_ALBUM" ? "carousel" : "image");
const RUNS = [
{ type: "account-audit", label: "Account audit", icon: ClipboardCheck, hint: "Internal + client audit, brand draft, PDFs" },
{ type: "weekly-plan", label: "Weekly content plan", icon: CalendarRange, hint: "Next week's posts and creative briefs" },
{ type: "weekly-report", label: "Weekly report", icon: FileBarChart, hint: "Last 7 days vs previous week, lessons" },
];
export default function Client() {
const { slug } = useParams();
const nav = useNavigate();
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [params] = useSearchParams();
const [tab, setTab] = useState(params.get("tab") || "performance");
const [runOpen, setRunOpen] = useState(false);
const [linkOpen, setLinkOpen] = useState(false);
const { isAdmin } = useAuth();
const load = useCallback(() => api(`/api/clients/${slug}`).then(setData).catch(setError), [slug]);
useEffect(() => { setData(null); load(); }, [load]);
useEffect(() => {
if (!data?.refreshing) return;
const t = setInterval(load, 5000);
return () => clearInterval(t);
}, [data?.refreshing, load]);
const refresh = async () => {
setError(null);
await api(`/api/clients/${slug}/refresh`, { method: "POST" }).catch(setError);
load();
};
if (error && !data) return ;
if (!data) return ;
const { client, snapshot } = data;
return (
Clients
d.kind !== "setup").length },
{ value: "runs", label: "AI runs", count: data.jobs.length },
{ value: "shots", label: "Screenshots" },
{ value: "brand", label: "Brand kit" },
{ value: "bio", label: "Link in bio" },
{ value: "portal", label: "Client approval" },
]}
/>
{tab === "performance" && (client.linked
?
: (
setLinkOpen(true)}>Link an account}>
Numbers, publishing and the inbox need this client's Facebook Page or Instagram account. If it isn't in the list,
assign it to the agency's system user in Meta Business Settings → System users → Assign assets, then check again.
))}
{tab === "docs" &&
}
{tab === "runs" && }
{tab === "shots" && }
{tab === "brand" && }
{tab === "bio" && }
{tab === "portal" && }
setRunOpen(false)} client={client} onStarted={(job) => nav(`/runs/${job.id}`)} />
{linkOpen && setLinkOpen(false)} onLinked={() => { setLinkOpen(false); load(); }} />}
);
}
/** Link an existing client folder to a Page the agency's Meta account manages. */
function LinkPageDialog({ client, onClose, onLinked }) {
const [accounts, setAccounts] = useState(null);
const [picked, setPicked] = useState(null);
const [error, setError] = useState(null);
const [busy, setBusy] = useState(false);
const check = useCallback(() => {
setAccounts(null); setError(null);
api("/api/accounts/unlinked?refresh=1").then((r) => setAccounts(r.accounts)).catch(setError);
}, []);
useEffect(() => { check(); }, [check]);
const link = async () => {
setBusy(true); setError(null);
try { await api(`/api/clients/${client.slug}/link-page`, { method: "POST", body: { accountId: picked.account_id } }); onLinked(); }
catch (e) { setError(e); setBusy(false); }
};
return (
Cancel Link and fetch numbers >}>
Facebook Pages and Instagram accounts the agency's Meta account manages that no client is linked to yet. The client keeps its name and files.
Check Meta again
{!accounts && !error ? : accounts?.length === 0 ? (
Assign {client.name}'s Page or Instagram account to the agency's system user in Meta Business Settings → System users → Assign assets, then press Check Meta again.
) : (
{accounts?.map((a) => (
setPicked(a)} className={cx("w-full text-left px-3.5 py-2.5 flex items-center gap-3", picked?.account_id === a.account_id ? "bg-brand-soft" : "hover:bg-canvas")}>
{a.page_name}
{a.page_id ? {a.category || "Page"} : Instagram only · no Facebook Page }
{a.instagram ? @{a.instagram.username} : no Instagram linked }
))}
)}
);
}
function SetupHints({ client }) {
const notes = [];
if (client.setup.brand.draft) notes.push("brand.md is a DRAFT. Confirm it with the client.");
if (client.setup.goals.placeholders > 3) notes.push("goals.md still has template gaps (approver, KPIs, retainer).");
if (client.setup.constraints.placeholders > 3) notes.push("constraints.md is mostly empty (approver, claims, regulated rules).");
if (!notes.length) return null;
return (
Setup gaps ·
{notes.join(" ")}
);
}
// ---------------------------------------------------------------- performance
function Performance({ snapshot, history, refreshing, onRefresh }) {
const ig = snapshot?.ig;
const fb = snapshot?.fb;
const posts = ig?.posts || [];
const perPost = useMemo(
() =>
[...posts]
.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp))
.map((p) => ({ date: fmtDate(p.timestamp), reach: p.insights?.reach ?? 0, format: formatOf(p), caption: (p.caption || "").split("\n")[0].slice(0, 70) })),
[posts]
);
if (!snapshot) {
return (
Fetch now}>
The first fetch pulls the profile, 28-day totals and insights for the last 20 posts. It takes about a minute.
);
}
const d = ig?.derived;
const t = ig?.totals || {};
const followersTrend = history.filter((h) => h.igFollowers != null);
const followerDelta = followersTrend.length > 1 ? followersTrend.at(-1).igFollowers - followersTrend[0].igFollowers : null;
return (
Fetched {timeAgo(snapshot.fetchedAt)} · totals cover the last 28 days
{snapshot.errors?.length > 0 && {snapshot.errors.join(" · ")} }
{ig && (
= 0 ? "+" : ""}${followerDelta} since ${fmtDate(followersTrend[0].date)}` : `follows ${fmt(ig.profile.follows_count)}`} />
)}
{perPost.length ? (
<>
(v >= 10000 ? `${Math.round(v / 1000)}k` : v)} />
} />
{perPost.map((p, i) => | )}
{Object.values(FORMAT).map((f) => (
{f.label}
))}
>
) : }
{d && (
<>
= 7 ? "bad" : null} note="ago" />
7 ? "warn" : null} note="in last 20" />
>
)}
{ig?.dailyReach?.length > 1 && (
({ ...x, label: fmtDate(x.date) }))} margin={{ top: 4, right: 8, bottom: 0, left: 0 }}>
(v >= 10000 ? `${Math.round(v / 1000)}k` : v)} />
[fmt(v), "Reached"]} />
)}
{fb && (
)}
{posts.length ? (
Post Format Published
Reached Likes Saves Shares
{posts.map((p) => {
const f = FORMAT[formatOf(p)];
return (
{(p.caption || "(no caption)").split("\n")[0]}
{f.label}
{fmtDate(p.timestamp)}
{fmt(p.insights?.reach)}
{fmt(p.like_count)}
{fmt(p.insights?.saved)}
{fmt(p.insights?.shares)}
);
})}
) : }
);
}
function PostTip({ active, payload }) {
if (!active || !payload?.length) return null;
const p = payload[0].payload;
return (
{fmt(p.reach)} reached · {FORMAT[p.format].label}
{p.date}
{p.caption &&
{p.caption}
}
);
}
function FormatBars({ d }) {
const rows = [
["reel", d.formatMix.reels, d.avgReach.reels],
["carousel", d.formatMix.carousels, d.avgReach.carousels],
["image", d.formatMix.images, d.avgReach.images],
];
const max = Math.max(...rows.map((r) => r[2] || 0), 1);
return (
{rows.map(([k, count, reach]) => (
{FORMAT[k].label}s · {count} posted
{reach == null ? "—" : fmt(reach)}
))}
);
}
// ---------------------------------------------------------------- documents
const KIND_LABEL = { monthly: "Monthly reports", audit: "Audits", competitors: "Competitor analysis", trends: "Trend scans", report: "Weekly reports", calendar: "Content calendars", "profile-check": "Profile checks", setup: "Client setup files" };
function Documents({ slug, docs, reload, openPath }) {
const [preview, setPreview] = useState(null);
const [busy, setBusy] = useState(null);
const [error, setError] = useState(null);
const open = async (doc) => {
setPreview({ doc, loading: true });
try {
setPreview({ doc, ...(await api(`/api/clients/${slug}/doc?path=${encodeURIComponent(doc.path)}`)) });
} catch (e) {
setPreview(null);
setError(e);
}
};
// Deep link from other pages (e.g. Competitors → "Open report"): ?tab=docs&doc=
useEffect(() => {
const doc = openPath && docs.find((d) => d.path === openPath);
if (doc) open(doc);
}, [openPath]); // eslint-disable-line react-hooks/exhaustive-deps
const exportPdf = async (doc) => {
setBusy(doc.path);
setError(null);
try {
const r = await api(`/api/clients/${slug}/doc/pdf`, { method: "POST", body: { path: doc.path } });
window.open(fileUrl(slug, r.pdf), "_blank");
reload();
} catch (e) {
setError(e);
} finally {
setBusy(null);
}
};
const groups = Object.keys(KIND_LABEL).map((k) => [k, docs.filter((d) => d.kind === k)]).filter(([, list]) => list.length);
if (!docs.length) return Run an account audit to create the first ones. ;
return (
{groups.map(([kind, list]) => (
{list.map((d) => (
open(d)} className="text-left">
{d.title}
Updated {fmtDateTime(d.updatedAt)}
{d.audience === "client" && For client }
{d.audience === "internal" && Internal only }
{d.pdf ? (
{d.pdf.stale ? "PDF out of date" : "PDF ready"}
) : no PDF }
open(d)}>Preview
{d.pdf && !d.pdf.stale ? (
Open PDF
) : (
kind !== "setup" &&
exportPdf(d)}>Export PDF
)}
))}
))}
setPreview(null)}
title={preview?.doc.title}
wide
footer={
preview && preview.doc.kind !== "setup" && (
<>
Download .md
exportPdf(preview.doc)}>Export branded PDF
>
)
}
>
{preview?.loading ? : preview?.page ? (
) : preview && (
<>
{preview.meta?.client && (
{preview.meta.report}
{preview.meta.client}
{[preview.meta.period, preview.meta.accounts].filter(Boolean).join(" · ")}
)}
{preview.meta?.kpis?.length > 0 && (
{preview.meta.kpis.map((k, i) => (
{k.value}
{k.label}
{k.note &&
{k.note}
}
))}
)}
>
)}
);
}
const fileUrl = (slug, path, download) => `/api/clients/${slug}/file?path=${encodeURIComponent(path)}${download ? "&download=1" : ""}`;
// ---------------------------------------------------------------- runs
function ClientRuns({ jobs }) {
const nav = useNavigate();
if (!jobs.length) return Use “Run with Claude” to start an audit, weekly plan or report. ;
return (
Run Status Started By Cost
{jobs.map((j) => (
nav(`/runs/${j.id}`)}>
{j.label}
{j.status}
{fmtDateTime(j.startedAt || j.createdAt)}
{j.createdBy}
{j.costUsd != null ? `$${j.costUsd.toFixed(2)}` : "—"}
))}
);
}
export function RunDialog({ open, onClose, client, onStarted }) {
const [type, setType] = useState("account-audit");
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
const start = async () => {
setBusy(true);
setError(null);
try {
const r = await api("/api/jobs", { method: "POST", body: { type, slug: client.slug } });
onClose();
onStarted(r.job);
} catch (e) {
setError(e);
} finally {
setBusy(false);
}
};
return (
Cancel Start run >}
>
{RUNS.map((r) => (
setType(r.type)}
className={cx("w-full flex items-start gap-3 text-left rounded-lg border px-3.5 py-3 transition-colors", type === r.type ? "border-brand bg-brand-soft" : "border-line hover:bg-[#f8fafc]")}
>
{r.label}
{r.hint}
))}
Claude reads the client's brand files, playbooks and live numbers, writes the documents into the client's folder and exports the PDFs.
It never publishes or changes Odoo. Runs go one at a time and usually take 3–10 minutes.
);
}