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

{client.name}

{client.igUsername && ( @{client.igUsername} )} {client.pageId && ( Facebook Page )} {client.category && {client.category}} {client.odooProject && ( {client.odooProject.name} )}
{client.linked ? ( ) : isAdmin && ( )}
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 ( }>

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.

{!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) => (
  • ))}
)}
); } 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 ? ( {posts.map((p) => { const f = FORMAT[formatOf(p)]; return ( ); })}
PostFormatPublished ReachedLikesSavesShares
{(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) => ( ))}
{d.audience === "client" && For client} {d.audience === "internal" && Internal only} {d.pdf ? ( {d.pdf.stale ? "PDF out of date" : "PDF ready"} ) : no PDF}
{d.pdf && !d.pdf.stale ? ( ) : ( kind !== "setup" && )}
))} setPreview(null)} title={preview?.doc.title} wide footer={ preview && preview.doc.kind !== "setup" && ( <> ) } > {preview?.loading ? : preview?.page ? (