Competitors page: track Instagram competitors per client
- Add/remove competitor handles (public Business/Creator accounts); refreshed nightly, one snapshot a day - Compare with the client: followers, 30-day change, trend, posts/week, typical post (median), engagement rate, format mix - Engagement-rate chart and each competitor's top recent posts - MCP tools competitors and competitor_add (audits can add the AI's suggestions) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
e304626986
commit
8b6e1cf835
158
connector/src/competitors.js
Normal file
158
connector/src/competitors.js
Normal file
@ -0,0 +1,158 @@
|
||||
// Competitors per client: Instagram Business/Creator accounts the team tracks (added by hand or suggested by the AI).
|
||||
// Each refresh stores one snapshot per account per day (followers, posts, recent media), so growth can be charted.
|
||||
// Uses Instagram business_discovery through the client's (or the agency's) connected Instagram account.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { PROJECT_ROOT } from "./env.js";
|
||||
import * as meta from "./meta.js";
|
||||
|
||||
const FILE = process.env.QUORLA_COMPETITORS_FILE || path.join(PROJECT_ROOT, "data", "competitors.json");
|
||||
const CLIENTS = path.join(PROJECT_ROOT, "clients");
|
||||
const KEEP_DAYS = 180;
|
||||
fs.mkdirSync(path.dirname(FILE), { recursive: true });
|
||||
|
||||
const read = () => { try { return JSON.parse(fs.readFileSync(FILE, "utf8")); } catch { return {}; } };
|
||||
const write = (db) => { const tmp = FILE + ".tmp"; fs.writeFileSync(tmp, JSON.stringify(db)); fs.renameSync(tmp, FILE); };
|
||||
const err = (msg, status = 400) => Object.assign(new Error(msg), { status });
|
||||
const today = () => new Date().toISOString().slice(0, 10);
|
||||
const handle = (u) => String(u || "").trim().replace(/^@/, "").replace(/^https?:\/\/(www\.)?instagram\.com\//i, "").replace(/[/?#].*$/, "").toLowerCase();
|
||||
|
||||
function checkClient(slug) {
|
||||
if (!/^[a-z0-9][a-z0-9-]*$/.test(String(slug || "")) || !fs.existsSync(path.join(CLIENTS, slug))) throw err("Unknown client.", 404);
|
||||
return slug;
|
||||
}
|
||||
function clientLink(slug) { try { return JSON.parse(fs.readFileSync(path.join(CLIENTS, slug, "client.json"), "utf8")); } catch { return {}; } }
|
||||
|
||||
/** An Instagram account id to run business_discovery from: the client's own, else any the agency manages. */
|
||||
async function viewerIgId(slug) {
|
||||
const link = clientLink(slug);
|
||||
if (link.igId) return link.igId;
|
||||
const any = (await meta.listAccounts()).find((a) => a.instagram?.id);
|
||||
if (!any) throw err("No Instagram account is connected to the agency's Meta account, so competitors can't be looked up.");
|
||||
return any.instagram.id;
|
||||
}
|
||||
|
||||
async function lookup(igId, username) {
|
||||
try {
|
||||
const b = await meta.igBusinessDiscovery(igId, username, { posts: 25 });
|
||||
if (!b?.username) throw new Error("not found");
|
||||
return b;
|
||||
} catch (e) {
|
||||
throw err(`Couldn't read @${username}. Instagram only shares public Business and Creator accounts; check the handle and that the account is public and professional. (${e.message})`);
|
||||
}
|
||||
}
|
||||
|
||||
function snapshotOf(b) {
|
||||
return {
|
||||
date: today(), followers: b.followers_count ?? null, following: b.follows_count ?? null, mediaCount: b.media_count ?? null,
|
||||
name: b.name || null, bio: (b.biography || "").slice(0, 300),
|
||||
media: (b.media || []).map((m) => ({ id: m.id, at: m.timestamp, likes: m.like_count ?? null, comments: m.comments_count ?? null, type: m.media_product_type === "REELS" ? "Reel" : m.media_type === "CAROUSEL_ALBUM" ? "Carousel" : m.media_type === "VIDEO" ? "Video" : "Image", permalink: m.permalink, caption: (m.caption || "").slice(0, 200) })),
|
||||
};
|
||||
}
|
||||
function addSnapshot(entry, snap) {
|
||||
entry.snapshots = (entry.snapshots || []).filter((s) => s.date !== snap.date);
|
||||
// Keep full media only on the latest snapshot; older ones keep the counts for the growth chart.
|
||||
entry.snapshots = entry.snapshots.map(({ media, ...rest }) => rest);
|
||||
entry.snapshots.push(snap);
|
||||
const cutoff = new Date(Date.now() - KEEP_DAYS * 86400000).toISOString().slice(0, 10);
|
||||
entry.snapshots = entry.snapshots.filter((s) => s.date >= cutoff);
|
||||
}
|
||||
|
||||
export function listTracked(client) {
|
||||
checkClient(client);
|
||||
return (read()[client]?.accounts || []).map(({ snapshots, ...a }) => ({ ...a, lastChecked: snapshots?.at(-1)?.date || null }));
|
||||
}
|
||||
|
||||
export async function add(client, username, { by = "team", source = "manual", note = "" } = {}) {
|
||||
checkClient(client);
|
||||
const u = handle(username);
|
||||
if (!/^[a-z0-9._]{1,30}$/.test(u)) throw err("Enter an Instagram handle, like @bakery_name.");
|
||||
const db = read();
|
||||
const c = (db[client] ||= { accounts: [] });
|
||||
if (c.accounts.some((a) => a.username === u)) throw err(`@${u} is already tracked for this client.`);
|
||||
if (c.accounts.length >= 10) throw err("Track up to 10 competitors per client. Remove one first.");
|
||||
const own = clientLink(client);
|
||||
const b = await lookup(await viewerIgId(client), u);
|
||||
if (own.igId && String(b.id) === String(own.igId)) throw err("That's the client's own account.");
|
||||
const entry = { username: u, name: b.name || null, source, note: String(note).slice(0, 300), addedBy: by, addedAt: new Date().toISOString(), snapshots: [] };
|
||||
addSnapshot(entry, snapshotOf(b));
|
||||
c.accounts.push(entry);
|
||||
write(db);
|
||||
return { username: u, name: entry.name };
|
||||
}
|
||||
|
||||
export function remove(client, username) {
|
||||
checkClient(client);
|
||||
const db = read();
|
||||
const u = handle(username);
|
||||
const before = db[client]?.accounts?.length || 0;
|
||||
if (db[client]) db[client].accounts = db[client].accounts.filter((a) => a.username !== u);
|
||||
if ((db[client]?.accounts?.length || 0) === before) throw err("Not tracked.", 404);
|
||||
write(db);
|
||||
}
|
||||
|
||||
/** Refresh every tracked account for one client (or all clients). One snapshot per account per day. */
|
||||
export async function refresh(client) {
|
||||
const db = read();
|
||||
const slugs = client ? [checkClient(client)] : Object.keys(db);
|
||||
const errors = [];
|
||||
for (const slug of slugs) {
|
||||
const c = db[slug];
|
||||
if (!c?.accounts?.length) continue;
|
||||
let igId;
|
||||
try { igId = await viewerIgId(slug); } catch (e) { errors.push(`${slug}: ${e.message}`); continue; }
|
||||
for (const a of c.accounts) {
|
||||
try { const b = await lookup(igId, a.username); a.name = b.name || a.name; addSnapshot(a, snapshotOf(b)); }
|
||||
catch (e) { errors.push(`${slug} @${a.username}: ${e.message}`); }
|
||||
}
|
||||
}
|
||||
write(db);
|
||||
return { refreshed: slugs.length, errors };
|
||||
}
|
||||
|
||||
const engagement = (m) => (m.likes ?? 0) + (m.comments ?? 0);
|
||||
/** Numbers for one account from its latest posts and snapshot history. */
|
||||
function stats({ followers, media, history }) {
|
||||
const now = Date.now();
|
||||
const recent = media.filter((m) => m.at && now - new Date(m.at) <= 28 * 86400000);
|
||||
const last12 = media.slice(0, 12);
|
||||
// Median, not mean: one viral post would otherwise make a typical post look many times better than it is.
|
||||
const sorted = last12.map(engagement).sort((x, y) => x - y);
|
||||
const avgEng = sorted.length ? Math.round(sorted.length % 2 ? sorted[(sorted.length - 1) / 2] : (sorted[sorted.length / 2 - 1] + sorted[sorted.length / 2]) / 2) : null;
|
||||
const mix = {};
|
||||
for (const m of media.slice(0, 25)) mix[m.type] = (mix[m.type] || 0) + 1;
|
||||
const monthAgo = new Date(now - 30 * 86400000).toISOString().slice(0, 10);
|
||||
const base = history.find((h) => h.date >= monthAgo && h.followers != null) || null;
|
||||
return {
|
||||
followers,
|
||||
growth30: base && followers != null && base.date !== today() ? followers - base.followers : null,
|
||||
growthSince: base && base.date !== today() ? base.date : null,
|
||||
postsPerWeek: media.length ? Math.round((recent.length / 4) * 10) / 10 : null,
|
||||
avgEngagement: avgEng,
|
||||
engagementRate: avgEng != null && followers ? Math.round((avgEng / followers) * 10000) / 100 : null,
|
||||
formatMix: mix,
|
||||
topPosts: [...media].sort((a, b) => engagement(b) - engagement(a)).slice(0, 3),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Comparison for a client: their own account (from the dashboard snapshot passed in) plus every tracked competitor.
|
||||
* own: { username, followers, posts:[{id,timestamp,like_count,comments_count,media_type,media_product_type,permalink,caption}], history:[{date, followers}] }
|
||||
*/
|
||||
export function compare(client, own) {
|
||||
checkClient(client);
|
||||
const accounts = (read()[client]?.accounts || []).map((a) => {
|
||||
const latest = a.snapshots?.at(-1) || {};
|
||||
return {
|
||||
username: a.username, name: a.name, source: a.source, note: a.note, addedBy: a.addedBy, addedAt: a.addedAt, lastChecked: latest.date || null,
|
||||
...stats({ followers: latest.followers ?? null, media: latest.media || [], history: a.snapshots || [] }),
|
||||
series: (a.snapshots || []).filter((s) => s.followers != null).map((s) => ({ date: s.date, followers: s.followers })),
|
||||
};
|
||||
});
|
||||
let you = null;
|
||||
if (own) {
|
||||
const media = (own.posts || []).map((p) => ({ id: p.id, at: p.timestamp, likes: p.like_count ?? null, comments: p.comments_count ?? null, type: p.media_product_type === "REELS" ? "Reel" : p.media_type === "CAROUSEL_ALBUM" ? "Carousel" : p.media_type === "VIDEO" ? "Video" : "Image", permalink: p.permalink, caption: (p.caption || "").slice(0, 200) }));
|
||||
you = { username: own.username, own: true, ...stats({ followers: own.followers ?? null, media, history: own.history || [] }), series: (own.history || []).filter((h) => h.followers != null) };
|
||||
}
|
||||
return { you, competitors: accounts };
|
||||
}
|
||||
@ -11,6 +11,7 @@ import * as meta from "./meta.js";
|
||||
import * as sched from "./schedule.js";
|
||||
import * as lib from "./library.js";
|
||||
import * as plan from "./plan.js";
|
||||
import * as competitors from "./competitors.js";
|
||||
import * as extra from "./platforms/index.js";
|
||||
|
||||
const server = new McpServer({ name: "social-manager", version: "0.1.0" });
|
||||
@ -205,4 +206,12 @@ server.tool("plan_update", "Update a content-plan item (status, date, brief, cap
|
||||
format: z.enum(["IMAGE", "REELS", "CAROUSEL", "STORY", "TEXT"]).optional(), platforms: z.array(z.string()).optional(), brief: z.string().optional(), caption: z.string().optional(), assignee: z.string().optional() }) },
|
||||
wrap(async ({ id, patch }) => plan.updateItem(id, patch, "agent")));
|
||||
|
||||
server.tool("competitors", "The Instagram competitors tracked for a client, with followers, growth, posts per week, average engagement, format mix and top posts (the dashboard's Competitors page). Use in audits instead of one-off lookups.",
|
||||
{ client: z.string() },
|
||||
wrap(async ({ client }) => competitors.compare(client, null)));
|
||||
|
||||
server.tool("competitor_add", "Start tracking an Instagram competitor for a client (public Business/Creator accounts only). Use for the 3–5 closest local competitors you identify in an audit; say why in note.",
|
||||
{ client: z.string(), username: z.string(), note: z.string().default("") },
|
||||
wrap(async ({ client, username, note }) => competitors.add(client, username, { by: "agent", source: "suggested", note })));
|
||||
|
||||
await server.connect(new StdioServerTransport());
|
||||
|
||||
33
connector/test/competitors.test.mjs
Normal file
33
connector/test/competitors.test.mjs
Normal file
@ -0,0 +1,33 @@
|
||||
// Competitor comparison maths, offline (no Instagram calls): median engagement, rate, growth, format mix, top posts.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "quorla-comp-"));
|
||||
process.env.QUORLA_COMPETITORS_FILE = path.join(TMP, "competitors.json");
|
||||
const { PROJECT_ROOT } = await import("../src/env.js");
|
||||
const client = fs.readdirSync(path.join(PROJECT_ROOT, "clients")).find((d) => !d.startsWith("_") && fs.existsSync(path.join(PROJECT_ROOT, "clients", d, "client.json")));
|
||||
|
||||
const day = (n) => new Date(Date.now() - n * 86400000);
|
||||
const media = [5, 10, 20, 40, 1000].map((likes, i) => ({ id: `m${i}`, at: day(i * 3).toISOString(), likes, comments: 0, type: i % 2 ? "Reel" : "Image", permalink: `https://x/${i}` }));
|
||||
|
||||
test("compare: median engagement ignores one viral post; growth from history", { skip: !client && "no client folder" }, async () => {
|
||||
fs.writeFileSync(process.env.QUORLA_COMPETITORS_FILE, JSON.stringify({
|
||||
[client]: { accounts: [{ username: "rival", name: "Rival", source: "manual", snapshots: [
|
||||
{ date: day(20).toISOString().slice(0, 10), followers: 900 },
|
||||
{ date: day(0).toISOString().slice(0, 10), followers: 1000, media },
|
||||
] }] },
|
||||
}));
|
||||
const c = await import("../src/competitors.js");
|
||||
const r = c.compare(client, { username: "me", followers: 500, posts: [], history: [] }).competitors[0];
|
||||
assert.equal(r.avgEngagement, 20, "median of 5,10,20,40,1000");
|
||||
assert.equal(r.engagementRate, 2);
|
||||
assert.equal(r.growth30, 100);
|
||||
assert.equal(r.postsPerWeek, 1.3, "5 posts in 28 days");
|
||||
assert.deepEqual(r.formatMix, { Image: 3, Reel: 2 });
|
||||
assert.equal(r.topPosts[0].likes, 1000);
|
||||
assert.equal(c.listTracked(client)[0].username, "rival");
|
||||
await assert.rejects(c.add(client, "not a handle!!"), /Instagram handle/);
|
||||
});
|
||||
@ -16,6 +16,7 @@ import * as meta from "../../connector/src/meta.js";
|
||||
import * as sched from "../../connector/src/schedule.js";
|
||||
import * as lib from "../../connector/src/library.js";
|
||||
import * as plan from "../../connector/src/plan.js";
|
||||
import * as competitors from "../../connector/src/competitors.js";
|
||||
import * as extra from "../../connector/src/platforms/index.js";
|
||||
import { publicBaseUrl, resolveMediaToken } from "../../connector/src/platforms/media.js";
|
||||
import { DATA_DIR as _DD } from "./config.js";
|
||||
@ -297,6 +298,20 @@ app.post("/api/plan", h(async (req) => ({ item: plan.createItem(req.body || {},
|
||||
app.patch("/api/plan/:id", h(async (req) => ({ item: plan.updateItem(req.params.id, req.body || {}, who(req)) })));
|
||||
app.delete("/api/plan/:id", h(async (req) => { plan.removeItem(req.params.id); return { ok: true }; }));
|
||||
|
||||
// ---------- competitors per client (Instagram) ----------
|
||||
function ownForCompare(slug) {
|
||||
const snap = metrics.readSnapshot(slug);
|
||||
if (!snap?.ig) return null;
|
||||
return {
|
||||
username: snap.ig.profile?.username, followers: snap.ig.profile?.followers_count ?? null, posts: snap.ig.posts || [],
|
||||
history: metrics.readHistory(slug).map((h) => ({ date: h.date, followers: h.igFollowers })),
|
||||
};
|
||||
}
|
||||
app.get("/api/competitors", h(async (req) => { const c = String(req.query.client || ""); clients.safeSlug(c); return competitors.compare(c, ownForCompare(c)); }));
|
||||
app.post("/api/competitors", h(async (req) => ({ added: await competitors.add(String(req.body?.client || ""), req.body?.username, { by: who(req), note: req.body?.note || "" }) })));
|
||||
app.delete("/api/competitors/:client/:username", h(async (req) => { competitors.remove(req.params.client, req.params.username); return { ok: true }; }));
|
||||
app.post("/api/competitors/refresh", h(async (req) => competitors.refresh(String(req.body?.client || "") || undefined)));
|
||||
|
||||
// ---------- published posts and their numbers ----------
|
||||
app.get("/api/published", h(async (req) => published.feed({ client: req.query.client ? (clients.safeSlug(req.query.client), String(req.query.client)) : undefined, platform: req.query.platform ? String(req.query.platform) : undefined, days: Number(req.query.days) || 30 })));
|
||||
app.post("/api/published/refresh", h(async () => published.refreshStats()));
|
||||
@ -383,6 +398,7 @@ function scheduleNightly() {
|
||||
setTimeout(async () => {
|
||||
try { console.log("Nightly refresh:", await metrics.refreshAll()); } catch (e) { console.error("Nightly refresh failed", e); }
|
||||
try { console.log("Post stats:", await published.refreshStats()); } catch (e) { console.error("Post stats failed", e); }
|
||||
try { console.log("Competitors:", await competitors.refresh()); } catch (e) { console.error("Competitors refresh failed", e); }
|
||||
scheduleNightly();
|
||||
}, next - new Date());
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||
import { BrowserRouter, NavLink, Navigate, Route, Routes } from "react-router-dom";
|
||||
import { LayoutGrid, Users2, Sparkles, Settings2, LogOut, CalendarClock, MessageCircle, FolderUp, Plug, BarChart3, CalendarDays } from "lucide-react";
|
||||
import { LayoutGrid, Users2, Sparkles, Settings2, LogOut, CalendarClock, MessageCircle, FolderUp, Plug, BarChart3, CalendarDays, Swords } from "lucide-react";
|
||||
import { api } from "./api.js";
|
||||
import { cx, Spinner } from "./ui.jsx";
|
||||
import Login from "./pages/Login.jsx";
|
||||
@ -12,6 +12,7 @@ import Settings from "./pages/Settings.jsx";
|
||||
import Publishing from "./pages/Publishing.jsx";
|
||||
import Published from "./pages/Published.jsx";
|
||||
import Calendar from "./pages/Calendar.jsx";
|
||||
import Competitors from "./pages/Competitors.jsx";
|
||||
import Inbox from "./pages/Inbox.jsx";
|
||||
import Library from "./pages/Library.jsx";
|
||||
import Connections from "./pages/Connections.jsx";
|
||||
@ -25,6 +26,7 @@ const NAV = [
|
||||
{ to: "/library", label: "Content library", icon: FolderUp },
|
||||
{ to: "/publishing", label: "Publishing", icon: CalendarClock },
|
||||
{ to: "/published", label: "Published", icon: BarChart3 },
|
||||
{ to: "/competitors", label: "Competitors", icon: Swords },
|
||||
{ to: "/inbox", label: "Inbox", icon: MessageCircle },
|
||||
{ to: "/connections", label: "Connections", icon: Plug },
|
||||
{ to: "/team", label: "Team", icon: Users2 },
|
||||
@ -122,6 +124,7 @@ export default function App() {
|
||||
<Route path="/publishing" element={<Publishing />} />
|
||||
<Route path="/published" element={<Published />} />
|
||||
<Route path="/calendar" element={<Calendar />} />
|
||||
<Route path="/competitors" element={<Competitors />} />
|
||||
<Route path="/inbox" element={<Inbox />} />
|
||||
<Route path="/connections" element={<Connections />} />
|
||||
<Route path="/team" element={<Team />} />
|
||||
|
||||
212
dashboard/web/src/pages/Competitors.jsx
Normal file
212
dashboard/web/src/pages/Competitors.jsx
Normal file
@ -0,0 +1,212 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { Bar, BarChart, CartesianGrid, Cell, LabelList, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
import { ExternalLink, Plus, RefreshCw, Swords, Trash2 } from "lucide-react";
|
||||
import { api } from "../api.js";
|
||||
import { Badge, Button, cx, Empty, ErrorNote, Field, fmt, fmtDate, Panel, Spinner, timeAgo } from "../ui.jsx";
|
||||
|
||||
const FORMAT_COLORS = { Reel: "#3b3778", Carousel: "#fc6d45", Image: "#a9afba", Video: "#6c63b5" };
|
||||
|
||||
/** Follower trend scaled to its own min/max, so small and large accounts both show their direction. */
|
||||
function Trend({ series }) {
|
||||
const vals = (series || []).map((s) => s.followers).filter((v) => v != null);
|
||||
if (vals.length < 2) return <span className="text-[12px] text-faint">builds daily</span>;
|
||||
const w = 96, h = 26, min = Math.min(...vals), max = Math.max(...vals), span = max - min || 1;
|
||||
const pts = vals.map((v, i) => `${(i / (vals.length - 1)) * (w - 2) + 1},${h - 1 - ((v - min) / span) * (h - 2)}`).join(" ");
|
||||
const up = vals.at(-1) >= vals[0];
|
||||
return (
|
||||
<svg width={w} height={h} className="block" role="img" aria-label={`Followers ${up ? "up" : "down"} from ${fmt(vals[0])} to ${fmt(vals.at(-1))}`}>
|
||||
<polyline points={pts} fill="none" stroke={up ? "#2f7d4f" : "#b4372b"} strokeWidth="1.75" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function FormatMix({ mix }) {
|
||||
const entries = Object.entries(mix || {});
|
||||
const total = entries.reduce((s, [, n]) => s + n, 0);
|
||||
if (!total) return <span className="text-faint">—</span>;
|
||||
return (
|
||||
<div className="min-w-[140px]">
|
||||
<div className="flex h-2 rounded-full overflow-hidden bg-canvas" aria-hidden="true">
|
||||
{entries.map(([k, n]) => <span key={k} style={{ width: `${(n / total) * 100}%`, background: FORMAT_COLORS[k] || "#9a9da6" }} />)}
|
||||
</div>
|
||||
<div className="text-[11.5px] text-muted mt-1">{entries.sort((a, b) => b[1] - a[1]).map(([k, n]) => `${k} ${Math.round((n / total) * 100)}%`).join(" · ")}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddDialog({ client, onClose, onAdded }) {
|
||||
const [username, setUsername] = useState("");
|
||||
const [note, setNote] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const add = async (e) => {
|
||||
e.preventDefault(); setBusy(true); setError(null);
|
||||
try { await api("/api/competitors", { method: "POST", body: { client, username, note } }); onAdded(); }
|
||||
catch (er) { setError(er); setBusy(false); }
|
||||
};
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 bg-ink/30 grid place-items-center p-4" role="dialog" aria-modal="true" aria-labelledby="add-comp-title" onClick={onClose}>
|
||||
<form onSubmit={add} onClick={(e) => e.stopPropagation()} className="panel w-full max-w-[460px] p-5 space-y-4 shadow-xl">
|
||||
<h2 id="add-comp-title" className="text-[16px] font-bold">Track a competitor</h2>
|
||||
<Field label="Instagram handle" hint="Public Business or Creator accounts only. Instagram doesn't share personal accounts.">
|
||||
<input id="comp-username" className="input" value={username} onChange={(e) => setUsername(e.target.value)} placeholder="@bakery_down_the_street" autoFocus required />
|
||||
</Field>
|
||||
<Field label="Why track them? (optional)">
|
||||
<input id="comp-note" className="input" value={note} onChange={(e) => setNote(e.target.value)} placeholder="Same neighbourhood, similar menu" />
|
||||
</Field>
|
||||
<ErrorNote error={error} />
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" onClick={onClose}>Cancel</Button>
|
||||
<Button type="submit" variant="primary" loading={busy}>Add</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Competitors() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const [clients, setClients] = useState(null);
|
||||
const [data, setData] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [busy, setBusy] = useState(null);
|
||||
const client = params.get("client") || "";
|
||||
|
||||
useEffect(() => { api("/api/overview").then((r) => setClients(r.clients)).catch(setError); }, []);
|
||||
useEffect(() => { if (!client && clients?.length) setParams({ client: clients[0].slug }, { replace: true }); }, [client, clients, setParams]);
|
||||
const load = useCallback(() => (client ? api(`/api/competitors?client=${encodeURIComponent(client)}`).then(setData).catch(setError) : Promise.resolve()), [client]);
|
||||
useEffect(() => { setData(null); setError(null); load(); }, [load]);
|
||||
|
||||
const run = async (what, fn) => { setBusy(what); setError(null); try { await fn(); await load(); } catch (e) { setError(e); } setBusy(null); };
|
||||
const refresh = () => run("refresh", async () => { const r = await api("/api/competitors/refresh", { method: "POST", body: { client } }); if (r.errors?.length) throw new Error(r.errors.join(" · ")); });
|
||||
const remove = (u) => run(`rm-${u}`, () => api(`/api/competitors/${client}/${encodeURIComponent(u)}`, { method: "DELETE" }));
|
||||
|
||||
if (!clients) return error ? <ErrorNote error={error} /> : <Spinner />;
|
||||
const current = clients.find((c) => c.slug === client);
|
||||
const rows = data ? [...(data.you ? [{ ...data.you, username: data.you.username || current?.name }] : []), ...data.competitors] : [];
|
||||
const chart = rows.filter((r) => r.engagementRate != null).map((r) => ({ name: `@${r.username}`, rate: r.engagementRate, own: Boolean(r.own) }));
|
||||
const lastChecked = data?.competitors.map((c) => c.lastChecked).filter(Boolean).sort()[0];
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<header className="flex items-end justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 className="text-[22px] font-extrabold">Competitors</h1>
|
||||
<p className="text-muted mt-0.5">How each client's Instagram compares with the accounts they compete with{lastChecked ? ` · checked ${fmtDate(lastChecked)}` : ""}. Numbers refresh nightly.</p>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center flex-wrap">
|
||||
<select id="comp-client" aria-label="Client" className="input h-9 w-auto" value={client} onChange={(e) => setParams({ client: e.target.value })}>
|
||||
{clients.map((c) => <option key={c.slug} value={c.slug}>{c.name}</option>)}
|
||||
</select>
|
||||
{data?.competitors.length > 0 && <Button icon={RefreshCw} loading={busy === "refresh"} onClick={refresh}>Refresh</Button>}
|
||||
<Button variant="primary" icon={Plus} onClick={() => setAdding(true)} disabled={data?.competitors.length >= 10}>Add competitor</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ErrorNote error={error} />
|
||||
|
||||
{!data ? (!error && <Spinner />) : !data.competitors.length ? (
|
||||
<div className="panel">
|
||||
<Empty icon={Swords} title="No competitors tracked yet" action={<Button variant="primary" icon={Plus} onClick={() => setAdding(true)}>Add competitor</Button>}>
|
||||
Add 3–5 accounts this client actually competes with: same area, same customers. Quorla then tracks their followers, posting rhythm, engagement and best posts every night.
|
||||
</Empty>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="panel overflow-x-auto">
|
||||
<table className="tbl min-w-[900px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Account</th>
|
||||
<th className="text-right">Followers</th>
|
||||
<th className="text-right">30-day change</th>
|
||||
<th>Trend</th>
|
||||
<th className="text-right">Posts / week</th>
|
||||
<th className="text-right" title="Median likes + comments over the last 12 posts">Typical post</th>
|
||||
<th className="text-right">Engagement rate</th>
|
||||
<th>Formats (last 25)</th>
|
||||
<th><span className="sr-only">Actions</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.username} className={cx(r.own && "bg-brand-soft/60")}>
|
||||
<td>
|
||||
<div className="font-medium text-ink flex items-center gap-2">
|
||||
@{r.username}{r.own && <Badge tone="brand">This client</Badge>}{r.source === "suggested" && <Badge tone="neutral">AI suggested</Badge>}
|
||||
</div>
|
||||
{r.name && !r.own && <div className="text-[12px] text-muted">{r.name}{r.note ? ` · ${r.note}` : ""}</div>}
|
||||
</td>
|
||||
<td className="text-right num">{fmt(r.followers)}</td>
|
||||
<td className={cx("text-right num", r.growth30 > 0 ? "text-ok" : r.growth30 < 0 ? "text-bad" : "")}>
|
||||
{r.growth30 == null ? <span className="text-faint" title="Needs at least a day of history">—</span> : `${r.growth30 > 0 ? "+" : ""}${fmt(r.growth30)}`}
|
||||
</td>
|
||||
<td><Trend series={r.series} /></td>
|
||||
<td className="text-right num">{r.postsPerWeek ?? "—"}</td>
|
||||
<td className="text-right num">{fmt(r.avgEngagement)}</td>
|
||||
<td className="text-right num font-semibold">{r.engagementRate == null ? "—" : `${r.engagementRate}%`}</td>
|
||||
<td><FormatMix mix={r.formatMix} /></td>
|
||||
<td className="text-right whitespace-nowrap">
|
||||
<a href={`https://www.instagram.com/${r.username}/`} target="_blank" rel="noreferrer" className="inline-flex p-1.5 rounded hover:bg-canvas text-muted" aria-label={`Open @${r.username} on Instagram`}><ExternalLink className="size-4" /></a>
|
||||
{!r.own && <button type="button" onClick={() => remove(r.username)} disabled={busy === `rm-${r.username}`} className="inline-flex p-1.5 rounded hover:bg-bad-soft text-muted hover:text-bad" aria-label={`Stop tracking @${r.username}`}><Trash2 className="size-4" /></button>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-[1fr_1.4fr] gap-5">
|
||||
<Panel title="Engagement rate" subtitle="Typical post's likes + comments ÷ followers. Compares accounts of different sizes fairly.">
|
||||
<div style={{ height: Math.max(140, chart.length * 44) }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chart} layout="vertical" margin={{ top: 4, right: 48, bottom: 4, left: 8 }}>
|
||||
<CartesianGrid horizontal={false} stroke="#e4e7ec" />
|
||||
<XAxis type="number" tickFormatter={(v) => `${v}%`} tick={{ fontSize: 11, fill: "#6b6e78" }} />
|
||||
<YAxis type="category" dataKey="name" width={170} tick={{ fontSize: 12, fill: "#1f2024" }} tickFormatter={(v) => (v.length > 22 ? `${v.slice(0, 21)}…` : v)} />
|
||||
<Tooltip formatter={(v) => [`${v}%`, "Engagement rate"]} />
|
||||
<Bar dataKey="rate" radius={[0, 3, 3, 0]} isAnimationActive={false}>
|
||||
{chart.map((d) => <Cell key={d.name} fill={d.own ? "#fc6d45" : "#3b3778"} />)}
|
||||
<LabelList dataKey="rate" position="right" formatter={(v) => `${v}%`} style={{ fontSize: 11, fill: "#1f2024" }} />
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<p className="text-[12px] text-muted mt-2">Coral is this client; navy are competitors.</p>
|
||||
</Panel>
|
||||
|
||||
<Panel title="What works for them" subtitle="Each competitor's three best recent posts by likes + comments.">
|
||||
<div className="space-y-4">
|
||||
{data.competitors.map((c) => (
|
||||
<div key={c.username}>
|
||||
<div className="text-[13px] font-semibold mb-1.5">@{c.username}</div>
|
||||
{!c.topPosts.length ? <div className="text-[12.5px] text-muted">No posts read yet.</div> : (
|
||||
<ul className="grid sm:grid-cols-3 gap-2">
|
||||
{c.topPosts.map((p) => (
|
||||
<li key={p.id} className="rounded-md border border-line p-2.5 text-[12.5px] min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Badge tone="neutral">{p.type}</Badge>
|
||||
{p.permalink && <a href={p.permalink} target="_blank" rel="noreferrer" className="text-muted hover:text-ink" aria-label="Open post on Instagram"><ExternalLink className="size-3.5" /></a>}
|
||||
</div>
|
||||
<div className="num font-semibold mt-1.5">{fmt(p.likes)} likes · {fmt(p.comments)} comments</div>
|
||||
<div className="text-muted mt-0.5">{fmtDate(p.at)}</div>
|
||||
{p.caption && <p className="text-ink-2 mt-1 line-clamp-2">{p.caption}</p>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
{!data.you && <p className="text-[12px] text-muted">This client's own Instagram isn't linked or hasn't been refreshed yet, so it isn't in the comparison.</p>}
|
||||
</>
|
||||
)}
|
||||
|
||||
{adding && <AddDialog client={client} onClose={() => setAdding(false)} onAdded={() => { setAdding(false); load(); }} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user