Explain why Fetch now fails for unlinked clients; let admins link a Page
- Refresh on a client with no Facebook Page now returns a clear 400 error instead of silently replying "started" - Client page shows "Not linked to a Facebook Page" with a Link a Page dialog (admins) and shows refresh errors - New POST /api/clients/:slug/link-page links an existing client folder to an unlinked Meta Page, keeping its name and files Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
e107c046a2
commit
7f7e41b617
@ -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();
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Button icon={RefreshCw} onClick={refresh} loading={data.refreshing}>
|
||||
{data.refreshing ? "Fetching from Meta" : "Refresh numbers"}
|
||||
</Button>
|
||||
{client.linked ? (
|
||||
<Button icon={RefreshCw} onClick={refresh} loading={data.refreshing}>
|
||||
{data.refreshing ? "Fetching from Meta" : "Refresh numbers"}
|
||||
</Button>
|
||||
) : isAdmin && (
|
||||
<Button icon={Link2} onClick={() => setLinkOpen(true)}>Link a Page</Button>
|
||||
)}
|
||||
<Button variant="primary" icon={Sparkles} onClick={() => setRunOpen(true)}>Run with Claude</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ErrorNote error={error} />
|
||||
<SetupHints client={client} />
|
||||
|
||||
<Tabs
|
||||
@ -91,15 +100,74 @@ export default function Client() {
|
||||
]}
|
||||
/>
|
||||
|
||||
{tab === "performance" && <Performance snapshot={snapshot} history={data.history} refreshing={data.refreshing} onRefresh={refresh} />}
|
||||
{tab === "performance" && (client.linked
|
||||
? <Performance snapshot={snapshot} history={data.history} refreshing={data.refreshing} onRefresh={refresh} />
|
||||
: (
|
||||
<Panel>
|
||||
<Empty icon={Link2} title="Not linked to a Facebook Page" action={isAdmin && <Button variant="primary" icon={Link2} onClick={() => setLinkOpen(true)}>Link a Page</Button>}>
|
||||
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.
|
||||
</Empty>
|
||||
</Panel>
|
||||
))}
|
||||
{tab === "docs" && <Documents slug={slug} docs={data.docs} reload={load} />}
|
||||
{tab === "runs" && <ClientRuns jobs={data.jobs} />}
|
||||
|
||||
<RunDialog open={runOpen} onClose={() => setRunOpen(false)} client={client} onStarted={(job) => nav(`/runs/${job.id}`)} />
|
||||
{linkOpen && <LinkPageDialog client={client} onClose={() => setLinkOpen(false)} onLinked={() => { setLinkOpen(false); load(); }} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<Dialog open onClose={onClose} title={`Link ${client.name} to a Page`}
|
||||
footer={<><Button variant="ghost" onClick={onClose}>Cancel</Button><Button variant="primary" disabled={!picked} loading={busy} onClick={link}>Link and fetch numbers</Button></>}>
|
||||
<p className="text-muted text-[13px] mb-3">Pages the agency's Meta account manages that no client is linked to yet. The client keeps its name and files.</p>
|
||||
<ErrorNote error={error} className="mb-3" />
|
||||
<div className="flex justify-end mb-2"><Button size="sm" variant="ghost" icon={RefreshCw} onClick={check}>Check Meta again</Button></div>
|
||||
{!accounts && !error ? <Spinner label="Asking Meta…" /> : accounts?.length === 0 ? (
|
||||
<Empty title="No unlinked Pages">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.</Empty>
|
||||
) : (
|
||||
<ul className="border border-line rounded-lg divide-y divide-line max-h-[260px] overflow-y-auto">
|
||||
{accounts?.map((a) => (
|
||||
<li key={a.page_id}>
|
||||
<button type="button" onClick={() => setPicked(a)} className={cx("w-full text-left px-3.5 py-2.5 flex items-center gap-3", picked?.page_id === a.page_id ? "bg-brand-soft" : "hover:bg-canvas")}>
|
||||
<span className={cx("size-4 rounded-full border-2 shrink-0", picked?.page_id === a.page_id ? "border-brand bg-brand shadow-[inset_0_0_0_2px_white]" : "border-line-strong")} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block font-medium truncate">{a.page_name}</span>
|
||||
<span className="flex gap-3 text-[12px] text-muted">
|
||||
<span className="inline-flex items-center gap-1"><Facebook className="size-3" />{a.category || "Page"}</span>
|
||||
{a.instagram ? <span className="inline-flex items-center gap-1"><Instagram className="size-3" />@{a.instagram.username}</span> : <span>no Instagram linked</span>}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function SetupHints({ client }) {
|
||||
const notes = [];
|
||||
if (client.setup.brand.draft) notes.push("brand.md is a DRAFT. Confirm it with the client.");
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user