diff --git a/dashboard/server/clients.js b/dashboard/server/clients.js index c0bc438..75a497f 100644 --- a/dashboard/server/clients.js +++ b/dashboard/server/clients.js @@ -118,6 +118,17 @@ export async function unlinkedAccounts({ refresh = false } = {}) { return accounts.filter((a) => !linked.has(a.page_id)).map(meta.publicAccount); } +/** Link an existing client folder to a Meta Page the agency manages (keeps the folder's name and files). */ +export async function linkToAccount(slug, pageId) { + const client = getClient(slug); + if (!client) throw Object.assign(new Error("Client not found."), { status: 404 }); + const account = (await meta.listAccounts({ refresh: true })).find((a) => a.page_id === String(pageId)); + if (!account) throw Object.assign(new Error("That Page isn't connected to the agency's Meta account."), { status: 400 }); + const other = listClients().find((c) => c.pageId === account.page_id && c.slug !== slug); + if (other) throw Object.assign(new Error(`That Page is already linked to ${other.name}.`), { status: 409 }); + return saveLink(slug, { ...linkFields(account), name: client.name || account.page_name }); +} + /** Best-effort auto link for folders created before client.json existed. */ export async function autoLink() { const accounts = await meta.listAccounts(); diff --git a/dashboard/server/index.js b/dashboard/server/index.js index 91329dc..fc273f9 100644 --- a/dashboard/server/index.js +++ b/dashboard/server/index.js @@ -128,6 +128,7 @@ app.get("/api/clients/:slug", h(async (req) => { app.post("/api/clients/:slug/refresh", h(async (req) => { clients.safeSlug(req.params.slug); + if (!clients.getClient(req.params.slug)?.linked) throw Object.assign(new Error("This client isn't linked to a Facebook Page yet, so there are no numbers to fetch. Link a Page first."), { status: 400 }); const wait = req.query.wait === "1"; const p = metrics.refreshSnapshot(req.params.slug); if (wait) return { snapshot: await p }; @@ -141,6 +142,11 @@ app.patch("/api/clients/:slug/link", requireAdmin, h(async (req) => { return { link: clients.saveLink(req.params.slug, patch) }; })); +app.post("/api/clients/:slug/link-page", requireAdmin, h(async (req) => { + const link = await clients.linkToAccount(req.params.slug, req.body?.pageId); + metrics.refreshSnapshot(req.params.slug).catch((e) => console.error("first refresh", req.params.slug, e.message)); + return { link }; +})); app.get("/api/accounts/unlinked", h(async (req) => ({ accounts: await clients.unlinkedAccounts({ refresh: req.query.refresh === "1" }) }))); app.post("/api/clients", requireAdmin, h(async (req) => { diff --git a/dashboard/web/src/pages/Client.jsx b/dashboard/web/src/pages/Client.jsx index c999be7..de286f2 100644 --- a/dashboard/web/src/pages/Client.jsx +++ b/dashboard/web/src/pages/Client.jsx @@ -1,8 +1,9 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Link, useNavigate, useParams } 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 } from "lucide-react"; +import { ArrowLeft, ArrowUpRight, ClipboardCheck, CalendarRange, FileBarChart, FileDown, FileText, Eye, RefreshCw, Sparkles, Briefcase, Link2, Facebook, Instagram } from "lucide-react"; import { api } from "../api.js"; +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"; @@ -27,6 +28,8 @@ export default function Client() { const [error, setError] = useState(null); const [tab, setTab] = useState("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]); @@ -37,6 +40,7 @@ export default function Client() { }, [data?.refreshing, load]); const refresh = async () => { + setError(null); await api(`/api/clients/${slug}/refresh`, { method: "POST" }).catch(setError); load(); }; @@ -72,13 +76,18 @@ export default function Client() {
- + {client.linked ? ( + + ) : isAdmin && ( + + )}
+ - {tab === "performance" && } + {tab === "performance" && (client.linked + ? + : ( + + setLinkOpen(true)}>Link a Page}> + Numbers, publishing and the inbox need this client's Facebook Page (and its Instagram). If the Page 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" && } 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: { pageId: picked.page_id } }); onLinked(); } + catch (e) { setError(e); setBusy(false); } + }; + + return ( + }> +

Pages 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 and Instagram 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.");