Brand kit and caption checks per client
- Client page → Brand kit: hashtag sets, approved phrases/CTAs, words to avoid, voice notes - New post: one-click insert of hashtag sets and phrases, live checks while typing - Checks: agency named in a client post (refused when saving; the agency's own account is exempt), avoid list, generic AI-sounding phrases, too many hashtags or emoji, platform length limits - Brand warnings also appear on drafts; MCP tools brand_kit and caption_check for the AI Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
9e263cd1f8
commit
7e780549ea
71
connector/src/brandkit.js
Normal file
71
connector/src/brandkit.js
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
// Brand kit per client: saved hashtag sets, approved phrases/CTAs, words to avoid.
|
||||||
|
// checkCaption() flags problems before a post goes for approval (shared by the dashboard, the queue and the MCP tools).
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { PROJECT_ROOT } from "./env.js";
|
||||||
|
|
||||||
|
const FILE = process.env.QUORLA_BRANDKIT_FILE || path.join(PROJECT_ROOT, "data", "brandkit.json");
|
||||||
|
const CLIENTS = path.join(PROJECT_ROOT, "clients");
|
||||||
|
fs.mkdirSync(path.dirname(FILE), { recursive: true });
|
||||||
|
const read = () => { try { return JSON.parse(fs.readFileSync(FILE, "utf8")); } catch { return {}; } };
|
||||||
|
const err = (msg, status = 400) => Object.assign(new Error(msg), { status });
|
||||||
|
|
||||||
|
// Phrases that read as AI-written or generic (project writing rules).
|
||||||
|
const GENERIC = ["elevate your", "unlock", "in today's fast-paced world", "look no further", "game-changer", "game changer", "take it to the next level", "dive into", "seamless", "revolutionize", "embark on"];
|
||||||
|
// White-label: the agency must never appear in a client's public post.
|
||||||
|
const AGENCY_WORDS = ["metatroncube", "metatron cube", "quorla"];
|
||||||
|
const LIMITS = { instagram: 2200, facebook: 63206, tiktok: 2200, youtube: 5000, gbp: 1500, linkedin: 3000, threads: 500, pinterest: 500, reddit: 40000 };
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
const tagList = (v) => [...new Set(String(Array.isArray(v) ? v.join(" ") : v || "").split(/[\s,]+/).map((t) => t.trim()).filter(Boolean).map((t) => `#${t.replace(/^#+/, "")}`).filter((t) => /^#[\p{L}\p{N}_]{1,60}$/u.test(t)))];
|
||||||
|
const lines = (v) => (Array.isArray(v) ? v : String(v || "").split(/\n/)).map((x) => String(x).trim()).filter(Boolean).slice(0, 200);
|
||||||
|
|
||||||
|
export function get(client) {
|
||||||
|
checkClient(client);
|
||||||
|
const k = read()[client] || {};
|
||||||
|
return { hashtagSets: k.hashtagSets || [], phrases: k.phrases || [], avoid: k.avoid || [], notes: k.notes || "", updatedAt: k.updatedAt || null, updatedBy: k.updatedBy || null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function save(client, patch, by = "team") {
|
||||||
|
checkClient(client);
|
||||||
|
const db = read();
|
||||||
|
const cur = get(client);
|
||||||
|
const next = {
|
||||||
|
hashtagSets: patch.hashtagSets !== undefined ? (patch.hashtagSets || []).slice(0, 30).map((s) => ({ name: String(s.name || "").trim().slice(0, 60) || "Set", tags: tagList(s.tags).slice(0, 30) })).filter((s) => s.tags.length) : cur.hashtagSets,
|
||||||
|
phrases: patch.phrases !== undefined ? lines(patch.phrases).map((x) => x.slice(0, 300)) : cur.phrases,
|
||||||
|
avoid: patch.avoid !== undefined ? lines(patch.avoid).map((x) => x.slice(0, 80)) : cur.avoid,
|
||||||
|
notes: patch.notes !== undefined ? String(patch.notes || "").slice(0, 3000) : cur.notes,
|
||||||
|
updatedAt: new Date().toISOString(), updatedBy: by,
|
||||||
|
};
|
||||||
|
db[client] = next;
|
||||||
|
const tmp = FILE + ".tmp"; fs.writeFileSync(tmp, JSON.stringify(db, null, 1)); fs.renameSync(tmp, FILE);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMOJI = /\p{Extended_Pictographic}/gu;
|
||||||
|
/**
|
||||||
|
* Problems in a caption for this client: { level: "bad" | "warn", text }.
|
||||||
|
* bad = must fix (agency named in a client post, over the platform limit); warn = worth a second look.
|
||||||
|
*/
|
||||||
|
export function checkCaption(client, caption, { platforms = [] } = {}) {
|
||||||
|
const text = String(caption || "");
|
||||||
|
const low = text.toLowerCase();
|
||||||
|
const out = [];
|
||||||
|
let kit = { avoid: [] };
|
||||||
|
try { kit = get(client); } catch { /* unknown client: generic checks only */ }
|
||||||
|
// The agency's own account (its client name contains the agency name) may of course mention itself.
|
||||||
|
let ownName = "";
|
||||||
|
try { ownName = JSON.parse(fs.readFileSync(path.join(CLIENTS, client, "client.json"), "utf8")).name || ""; } catch { /* no client.json */ }
|
||||||
|
const isAgencyItself = AGENCY_WORDS.some((w) => `${ownName} ${client}`.toLowerCase().replace(/[-_]/g, " ").includes(w));
|
||||||
|
if (!isAgencyItself) for (const w of AGENCY_WORDS) if (low.includes(w)) out.push({ level: "bad", text: `Mentions “${w}”. Client posts must never name the agency.` });
|
||||||
|
for (const p of platforms) if (LIMITS[p] && text.length > LIMITS[p]) out.push({ level: "bad", text: `Too long for ${p}: ${text.length} of ${LIMITS[p]} characters.` });
|
||||||
|
for (const w of kit.avoid) if (w && low.includes(w.toLowerCase())) out.push({ level: "warn", text: `Uses “${w}”, which is on this client's avoid list.` });
|
||||||
|
for (const g of GENERIC) if (low.includes(g)) out.push({ level: "warn", text: `“${g}” sounds generic. Say it the way the client would.` });
|
||||||
|
const tags = text.match(/#[\p{L}\p{N}_]+/gu) || [];
|
||||||
|
if (tags.length > 10) out.push({ level: "warn", text: `${tags.length} hashtags. 3–8 relevant ones usually do better.` });
|
||||||
|
if ((text.match(EMOJI) || []).length > 8) out.push({ level: "warn", text: "Lots of emoji. Keep a few that add meaning." });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@ -9,6 +9,7 @@ import { PROJECT_ROOT } from "./env.js";
|
|||||||
import * as meta from "./meta.js";
|
import * as meta from "./meta.js";
|
||||||
import * as extra from "./platforms/index.js";
|
import * as extra from "./platforms/index.js";
|
||||||
import * as quota from "./platforms/quota.js";
|
import * as quota from "./platforms/quota.js";
|
||||||
|
import * as brandkit from "./brandkit.js";
|
||||||
|
|
||||||
const META_PLATFORMS = ["instagram", "facebook"];
|
const META_PLATFORMS = ["instagram", "facebook"];
|
||||||
export const ALL_PLATFORMS = [...META_PLATFORMS, ...extra.PLATFORM_IDS];
|
export const ALL_PLATFORMS = [...META_PLATFORMS, ...extra.PLATFORM_IDS];
|
||||||
@ -90,6 +91,19 @@ function quotaAccount(client, platform) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Warnings (not errors) when a day's planned posts exceed a platform's limit; extra posts then wait for the reset. */
|
/** Warnings (not errors) when a day's planned posts exceed a platform's limit; extra posts then wait for the reset. */
|
||||||
|
/** Brand-kit and writing checks on every caption a post will use. Naming the agency is refused outright. */
|
||||||
|
function brandWarnings(post) {
|
||||||
|
const captions = [[null, post.caption], ["facebook", post.fbCaption], ...Object.entries(post.options || {}).map(([pl, o]) => [pl, o?.caption])].filter(([, c]) => c);
|
||||||
|
const out = [];
|
||||||
|
for (const [pl, c] of captions) {
|
||||||
|
for (const w of brandkit.checkCaption(post.client, c, { platforms: pl ? [pl] : post.platforms })) {
|
||||||
|
if (/never name the agency/.test(w.text)) throw err(`${pl ? `${pl} caption: ` : ""}${w.text}`);
|
||||||
|
out.push(`${pl ? `${pl} caption: ` : ""}${w.text}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...new Set(out)];
|
||||||
|
}
|
||||||
|
|
||||||
function quotaWarnings(post, items) {
|
function quotaWarnings(post, items) {
|
||||||
const at = new Date(post.publishAt).getTime();
|
const at = new Date(post.publishAt).getTime();
|
||||||
const out = [];
|
const out = [];
|
||||||
@ -137,7 +151,7 @@ export function createPost({ client, platforms = ["instagram"], type = "IMAGE",
|
|||||||
results: {}, errors: {},
|
results: {}, errors: {},
|
||||||
};
|
};
|
||||||
const items = read();
|
const items = read();
|
||||||
post.warnings = quotaWarnings(post, items);
|
post.warnings = [...quotaWarnings(post, items), ...brandWarnings(post)];
|
||||||
items.push(post); write(items);
|
items.push(post); write(items);
|
||||||
emit("draft_created", post);
|
emit("draft_created", post);
|
||||||
return post;
|
return post;
|
||||||
@ -151,7 +165,7 @@ export function updatePost(id, patch, by = "team") {
|
|||||||
for (const pl of p.platforms) if (!META_PLATFORMS.includes(pl)) extra.validate(pl, p.client, p);
|
for (const pl of p.platforms) if (!META_PLATFORMS.includes(pl)) extra.validate(pl, p.client, p);
|
||||||
if (patch.localTime) { const t = zonedToUtc(patch.localTime, p.timezone); p.publishAt = t.toISOString(); p.localTime = formatInTz(t, p.timezone); }
|
if (patch.localTime) { const t = zonedToUtc(patch.localTime, p.timezone); p.publishAt = t.toISOString(); p.localTime = formatInTz(t, p.timezone); }
|
||||||
p.status = "draft"; p.updatedBy = by; p.updatedAt = new Date().toISOString();
|
p.status = "draft"; p.updatedBy = by; p.updatedAt = new Date().toISOString();
|
||||||
p.warnings = quotaWarnings(p, items);
|
p.warnings = [...quotaWarnings(p, items), ...brandWarnings(p)];
|
||||||
// Changed after the client looked at it: ask them again.
|
// Changed after the client looked at it: ask them again.
|
||||||
if (p.clientReview && p.clientReview.status !== "pending") p.clientReview = { ...p.clientReview, status: "pending", previous: p.clientReview.status, requestedAt: p.updatedAt, note: "", by: null };
|
if (p.clientReview && p.clientReview.status !== "pending") p.clientReview = { ...p.clientReview, status: "pending", previous: p.clientReview.status, requestedAt: p.updatedAt, note: "", by: null };
|
||||||
write(items); return p;
|
write(items); return p;
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import * as sched from "./schedule.js";
|
|||||||
import * as lib from "./library.js";
|
import * as lib from "./library.js";
|
||||||
import * as plan from "./plan.js";
|
import * as plan from "./plan.js";
|
||||||
import * as competitors from "./competitors.js";
|
import * as competitors from "./competitors.js";
|
||||||
|
import * as brandkit from "./brandkit.js";
|
||||||
import * as extra from "./platforms/index.js";
|
import * as extra from "./platforms/index.js";
|
||||||
|
|
||||||
const server = new McpServer({ name: "social-manager", version: "0.1.0" });
|
const server = new McpServer({ name: "social-manager", version: "0.1.0" });
|
||||||
@ -214,4 +215,11 @@ server.tool("competitor_add", "Start tracking an Instagram competitor for a clie
|
|||||||
{ client: z.string(), username: z.string(), note: z.string().default("") },
|
{ client: z.string(), username: z.string(), note: z.string().default("") },
|
||||||
wrap(async ({ client, username, note }) => competitors.add(client, username, { by: "agent", source: "suggested", note })));
|
wrap(async ({ client, username, note }) => competitors.add(client, username, { by: "agent", source: "suggested", note })));
|
||||||
|
|
||||||
|
server.tool("brand_kit", "A client's saved hashtag sets, approved phrases/CTAs and words to avoid. Read it before writing captions for that client.",
|
||||||
|
{ client: z.string() }, wrap(async ({ client }) => brandkit.get(client)));
|
||||||
|
|
||||||
|
server.tool("caption_check", "Check a caption against the client's brand kit and our writing rules before scheduling (agency name, avoid list, generic phrases, hashtag count, platform length).",
|
||||||
|
{ client: z.string(), caption: z.string(), platforms: z.array(z.string()).default([]) },
|
||||||
|
wrap(async ({ client, caption, platforms }) => brandkit.checkCaption(client, caption, { platforms })));
|
||||||
|
|
||||||
await server.connect(new StdioServerTransport());
|
await server.connect(new StdioServerTransport());
|
||||||
|
|||||||
24
connector/test/brandkit.test.mjs
Normal file
24
connector/test/brandkit.test.mjs
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
// Brand kit and caption checks (offline, temp file).
|
||||||
|
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";
|
||||||
|
|
||||||
|
process.env.QUORLA_BRANDKIT_FILE = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "quorla-bk-")), "bk.json");
|
||||||
|
const bk = await import("../src/brandkit.js");
|
||||||
|
const { PROJECT_ROOT } = await import("../src/env.js");
|
||||||
|
const client = fs.readdirSync(path.join(PROJECT_ROOT, "clients")).find((d) => !d.startsWith("_") && !/metatron/i.test(d) && fs.existsSync(path.join(PROJECT_ROOT, "clients", d, "client.json")));
|
||||||
|
|
||||||
|
test("brand kit: saves clean hashtag sets and lists; caption checks", { skip: !client && "no client folder" }, () => {
|
||||||
|
const k = bk.save(client, { hashtagSets: [{ name: "Local", tags: "waterloo, #kitchener #KW bad-tag!" }], phrases: "Order online\n\nBook a table", avoid: "cheap\nbest in town" });
|
||||||
|
assert.deepEqual(k.hashtagSets[0].tags, ["#waterloo", "#kitchener", "#KW"]);
|
||||||
|
assert.deepEqual(k.phrases, ["Order online", "Book a table"]);
|
||||||
|
const w = bk.checkCaption(client, "The best in town, made by Quorla. Unlock flavour! " + "#a ".repeat(12), { platforms: ["threads"] }).map((x) => x.level + ":" + x.text.slice(0, 25));
|
||||||
|
assert.ok(w.some((x) => x.startsWith("bad:Mentions “quorla”")));
|
||||||
|
assert.ok(w.some((x) => x.startsWith("warn:Uses “best in town”")));
|
||||||
|
assert.ok(w.some((x) => x.startsWith("warn:“unlock”")));
|
||||||
|
assert.ok(w.some((x) => x.startsWith("warn:12 hashtags")));
|
||||||
|
assert.equal(bk.checkCaption(client, "x".repeat(600), { platforms: ["threads"] })[0].level, "bad");
|
||||||
|
assert.deepEqual(bk.checkCaption(client, "Fresh focaccia at 8. First 20 people get a free cortado.", { platforms: ["instagram"] }), []);
|
||||||
|
});
|
||||||
@ -21,6 +21,7 @@ import * as sched from "../../connector/src/schedule.js";
|
|||||||
import * as lib from "../../connector/src/library.js";
|
import * as lib from "../../connector/src/library.js";
|
||||||
import * as plan from "../../connector/src/plan.js";
|
import * as plan from "../../connector/src/plan.js";
|
||||||
import * as competitors from "../../connector/src/competitors.js";
|
import * as competitors from "../../connector/src/competitors.js";
|
||||||
|
import * as brandkit from "../../connector/src/brandkit.js";
|
||||||
import * as extra from "../../connector/src/platforms/index.js";
|
import * as extra from "../../connector/src/platforms/index.js";
|
||||||
import { publicBaseUrl, resolveMediaToken } from "../../connector/src/platforms/media.js";
|
import { publicBaseUrl, resolveMediaToken } from "../../connector/src/platforms/media.js";
|
||||||
import { DATA_DIR as _DD } from "./config.js";
|
import { DATA_DIR as _DD } from "./config.js";
|
||||||
@ -333,6 +334,11 @@ app.put("/api/clients/:slug/monthly", requireAdmin, h(async (req) => ({ settings
|
|||||||
app.post("/api/clients/:slug/monthly/build", h(async (req) => monthly.buildWithPdf(req.params.slug, String(req.body?.month || monthly.lastMonth()))));
|
app.post("/api/clients/:slug/monthly/build", h(async (req) => monthly.buildWithPdf(req.params.slug, String(req.body?.month || monthly.lastMonth()))));
|
||||||
app.post("/api/clients/:slug/monthly/email", requireAdmin, h(async (req) => monthly.emailReport(req.params.slug, String(req.body?.month || monthly.lastMonth()))));
|
app.post("/api/clients/:slug/monthly/email", requireAdmin, h(async (req) => monthly.emailReport(req.params.slug, String(req.body?.month || monthly.lastMonth()))));
|
||||||
|
|
||||||
|
// ---------- brand kit: hashtag sets, phrases, words to avoid; caption checks ----------
|
||||||
|
app.get("/api/brandkit/:client", h(async (req) => brandkit.get(req.params.client)));
|
||||||
|
app.put("/api/brandkit/:client", h(async (req) => brandkit.save(req.params.client, req.body || {}, who(req))));
|
||||||
|
app.post("/api/brandkit/:client/check", h(async (req) => ({ warnings: brandkit.checkCaption(req.params.client, req.body?.caption, { platforms: req.body?.platforms || [] }) })));
|
||||||
|
|
||||||
// ---------- notifications ----------
|
// ---------- notifications ----------
|
||||||
app.get("/api/notifications", h(async (req) => notify.listFor(req.user.id)));
|
app.get("/api/notifications", h(async (req) => notify.listFor(req.user.id)));
|
||||||
app.post("/api/notifications/read", h(async (req) => { notify.markRead(req.user.id, Array.isArray(req.body?.ids) ? req.body.ids : null); return notify.listFor(req.user.id); }));
|
app.post("/api/notifications/read", h(async (req) => { notify.markRead(req.user.id, Array.isArray(req.body?.ids) ? req.body.ids : null); return notify.listFor(req.user.id); }));
|
||||||
|
|||||||
91
dashboard/web/src/pages/BrandKit.jsx
Normal file
91
dashboard/web/src/pages/BrandKit.jsx
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Plus, Save, Trash2 } from "lucide-react";
|
||||||
|
import { api } from "../api.js";
|
||||||
|
import { Button, ErrorNote, Field, Panel, Spinner, timeAgo } from "../ui.jsx";
|
||||||
|
|
||||||
|
/** Client page tab: hashtag sets, approved phrases/CTAs, words to avoid, voice notes. */
|
||||||
|
export default function BrandKit({ slug }) {
|
||||||
|
const [kit, setKit] = useState(null);
|
||||||
|
const [sets, setSets] = useState([]);
|
||||||
|
const [phrases, setPhrases] = useState("");
|
||||||
|
const [avoid, setAvoid] = useState("");
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
const apply = (k) => { setKit(k); setSets(k.hashtagSets.map((s) => ({ name: s.name, tags: s.tags.join(" ") }))); setPhrases(k.phrases.join("\n")); setAvoid(k.avoid.join("\n")); setNotes(k.notes); };
|
||||||
|
useEffect(() => { api(`/api/brandkit/${slug}`).then(apply).catch(setError); }, [slug]);
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setBusy(true); setError(null); setSaved(false);
|
||||||
|
try { apply(await api(`/api/brandkit/${slug}`, { method: "PUT", body: { hashtagSets: sets, phrases, avoid, notes } })); setSaved(true); } catch (e) { setError(e); }
|
||||||
|
setBusy(false);
|
||||||
|
};
|
||||||
|
if (!kit) return error ? <ErrorNote error={error} /> : <Spinner />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Panel title="Hashtag sets" subtitle="Saved groups the team can drop into a caption with one click. 3–8 relevant tags per post work best.">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{sets.map((s, i) => (
|
||||||
|
<div key={i} className="grid sm:grid-cols-[180px_1fr_auto] gap-2 items-start">
|
||||||
|
<label htmlFor={`bk-n-${i}`} className="sr-only">Set name</label>
|
||||||
|
<input id={`bk-n-${i}`} className="input" value={s.name} placeholder="e.g. Local" onChange={(e) => setSets(sets.map((x, j) => (j === i ? { ...x, name: e.target.value } : x)))} />
|
||||||
|
<label htmlFor={`bk-t-${i}`} className="sr-only">Hashtags</label>
|
||||||
|
<input id={`bk-t-${i}`} className="input" value={s.tags} placeholder="#waterloo #kitchenerwaterloo #kwbakery" onChange={(e) => setSets(sets.map((x, j) => (j === i ? { ...x, tags: e.target.value } : x)))} />
|
||||||
|
<button type="button" onClick={() => setSets(sets.filter((_, j) => j !== i))} className="p-2 rounded hover:bg-bad-soft text-muted hover:text-bad" aria-label={`Remove set ${s.name || i + 1}`}><Trash2 className="size-4" /></button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button size="sm" icon={Plus} onClick={() => setSets([...sets, { name: "", tags: "" }])}>Add a set</Button>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
<div className="grid lg:grid-cols-2 gap-4">
|
||||||
|
<Panel title="Approved phrases and calls to action" subtitle="One per line. Offered while writing captions.">
|
||||||
|
<label htmlFor="bk-phrases" className="sr-only">Phrases</label>
|
||||||
|
<textarea id="bk-phrases" className="input h-40 py-2" value={phrases} onChange={(e) => setPhrases(e.target.value)} placeholder={"Order online at the link in bio\nBook your table: 519-…\nFresh every morning from 8 a.m."} />
|
||||||
|
</Panel>
|
||||||
|
<Panel title="Words to avoid" subtitle="One per line. Captions using them get a warning before approval.">
|
||||||
|
<label htmlFor="bk-avoid" className="sr-only">Words to avoid</label>
|
||||||
|
<textarea id="bk-avoid" className="input h-40 py-2" value={avoid} onChange={(e) => setAvoid(e.target.value)} placeholder={"cheap\nbest in town\ndiscount (say 'offer')"} />
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
<Panel title="Voice notes" subtitle="Anything else about how this client sounds. The AI reads this with brand.md.">
|
||||||
|
<label htmlFor="bk-notes" className="sr-only">Voice notes</label>
|
||||||
|
<textarea id="bk-notes" className="input h-24 py-2" value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="Warm, a little playful. Always sign off with the owner's first name on Fridays." />
|
||||||
|
</Panel>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="primary" icon={Save} loading={busy} onClick={save}>Save brand kit</Button>
|
||||||
|
{saved && <span className="text-[12.5px] text-ok">Saved.</span>}
|
||||||
|
{kit.updatedAt && !saved && <span className="text-[12px] text-muted">Last changed {timeAgo(kit.updatedAt)} by {kit.updatedBy}</span>}
|
||||||
|
</div>
|
||||||
|
<ErrorNote error={error} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Under a caption box: insert hashtag sets/phrases and show live brand checks. */
|
||||||
|
export function CaptionHelper({ client, caption, platforms, onInsert }) {
|
||||||
|
const [kit, setKit] = useState(null);
|
||||||
|
const [warnings, setWarnings] = useState([]);
|
||||||
|
const timer = useRef(null);
|
||||||
|
useEffect(() => { setKit(null); if (client) api(`/api/brandkit/${client}`).then(setKit).catch(() => {}); }, [client]);
|
||||||
|
useEffect(() => {
|
||||||
|
clearTimeout(timer.current);
|
||||||
|
if (!client || !caption?.trim()) { setWarnings([]); return; }
|
||||||
|
timer.current = setTimeout(() => api(`/api/brandkit/${client}/check`, { method: "POST", body: { caption, platforms } }).then((r) => setWarnings(r.warnings)).catch(() => {}), 500);
|
||||||
|
return () => clearTimeout(timer.current);
|
||||||
|
}, [client, caption, platforms.join(",")]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5 mt-1.5">
|
||||||
|
{kit && (kit.hashtagSets.length > 0 || kit.phrases.length > 0) && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 items-center text-[12px]">
|
||||||
|
<span className="text-muted">Insert:</span>
|
||||||
|
{kit.hashtagSets.map((s) => <button key={`h-${s.name}`} type="button" onClick={() => onInsert(`\n\n${s.tags.join(" ")}`)} className="h-7 px-2 rounded border border-line hover:bg-canvas" title={s.tags.join(" ")}># {s.name}</button>)}
|
||||||
|
{kit.phrases.slice(0, 6).map((p) => <button key={`p-${p}`} type="button" onClick={() => onInsert(`\n${p}`)} className="h-7 px-2 rounded border border-line hover:bg-canvas max-w-[220px] truncate" title={p}>{p}</button>)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{warnings.map((w) => <div key={w.text} className={w.level === "bad" ? "text-[12px] text-bad" : "text-[12px] text-warn"}>{w.level === "bad" ? "Fix: " : ""}{w.text}</div>)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -5,6 +5,7 @@ import { ArrowLeft, ArrowUpRight, ClipboardCheck, CalendarRange, FileBarChart, F
|
|||||||
import { api } from "../api.js";
|
import { api } from "../api.js";
|
||||||
import ClientPortal from "./ClientPortal.jsx";
|
import ClientPortal from "./ClientPortal.jsx";
|
||||||
import MonthlyReport from "./MonthlyReport.jsx";
|
import MonthlyReport from "./MonthlyReport.jsx";
|
||||||
|
import BrandKit from "./BrandKit.jsx";
|
||||||
import { useAuth } from "../App.jsx";
|
import { useAuth } from "../App.jsx";
|
||||||
import {
|
import {
|
||||||
Badge, Button, cx, Dialog, Empty, ErrorNote, fmt, fmtDate, fmtDateTime, Panel, Spinner, Stat, StatCell, StatRow, STATUS_TONE, Tabs, timeAgo,
|
Badge, Button, cx, Dialog, Empty, ErrorNote, fmt, fmtDate, fmtDateTime, Panel, Spinner, Stat, StatCell, StatRow, STATUS_TONE, Tabs, timeAgo,
|
||||||
@ -99,6 +100,7 @@ export default function Client() {
|
|||||||
{ value: "performance", label: "Performance" },
|
{ value: "performance", label: "Performance" },
|
||||||
{ value: "docs", label: "Documents", count: data.docs.filter((d) => d.kind !== "setup").length },
|
{ value: "docs", label: "Documents", count: data.docs.filter((d) => d.kind !== "setup").length },
|
||||||
{ value: "runs", label: "AI runs", count: data.jobs.length },
|
{ value: "runs", label: "AI runs", count: data.jobs.length },
|
||||||
|
{ value: "brand", label: "Brand kit" },
|
||||||
{ value: "portal", label: "Client approval" },
|
{ value: "portal", label: "Client approval" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@ -115,6 +117,7 @@ export default function Client() {
|
|||||||
))}
|
))}
|
||||||
{tab === "docs" && <div className="space-y-5"><MonthlyReport slug={slug} onBuilt={load} /><Documents slug={slug} docs={data.docs} reload={load} /></div>}
|
{tab === "docs" && <div className="space-y-5"><MonthlyReport slug={slug} onBuilt={load} /><Documents slug={slug} docs={data.docs} reload={load} /></div>}
|
||||||
{tab === "runs" && <ClientRuns jobs={data.jobs} />}
|
{tab === "runs" && <ClientRuns jobs={data.jobs} />}
|
||||||
|
{tab === "brand" && <BrandKit slug={slug} />}
|
||||||
{tab === "portal" && <ClientPortal slug={slug} clientName={client.name} />}
|
{tab === "portal" && <ClientPortal slug={slug} clientName={client.name} />}
|
||||||
|
|
||||||
<RunDialog open={runOpen} onClose={() => setRunOpen(false)} client={client} onStarted={(job) => nav(`/runs/${job.id}`)} />
|
<RunDialog open={runOpen} onClose={() => setRunOpen(false)} client={client} onStarted={(job) => nav(`/runs/${job.id}`)} />
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { CalendarClock, Check, Image as ImageIcon, Plus, RotateCcw, Upload, X, S
|
|||||||
import { api } from "../api.js";
|
import { api } from "../api.js";
|
||||||
import { useAuth } from "../App.jsx";
|
import { useAuth } from "../App.jsx";
|
||||||
import { Badge, Button, cx, Dialog, Empty, ErrorNote, Field, Panel, Spinner } from "../ui.jsx";
|
import { Badge, Button, cx, Dialog, Empty, ErrorNote, Field, Panel, Spinner } from "../ui.jsx";
|
||||||
|
import { CaptionHelper } from "./BrandKit.jsx";
|
||||||
import PlatformPicker, { defaultPlatforms, selectionFor, useClientPlatforms } from "../PlatformPicker.jsx";
|
import PlatformPicker, { defaultPlatforms, selectionFor, useClientPlatforms } from "../PlatformPicker.jsx";
|
||||||
|
|
||||||
const REVIEW = { pending: { tone: "warn", label: "With client" }, approved: { tone: "ok", label: "Client approved" }, changes: { tone: "bad", label: "Client wants changes" } };
|
const REVIEW = { pending: { tone: "warn", label: "With client" }, approved: { tone: "ok", label: "Client approved" }, changes: { tone: "bad", label: "Client wants changes" } };
|
||||||
@ -194,6 +195,7 @@ function Compose({ clients, onClose, onSaved }) {
|
|||||||
)}
|
)}
|
||||||
<Field label="Caption" hint="Plain text works best for search. Hashtags at the end.">
|
<Field label="Caption" hint="Plain text works best for search. Hashtags at the end.">
|
||||||
<textarea id="c-caption" className="input w-full h-28 py-2" value={f.caption} onChange={(e) => set("caption", e.target.value)} />
|
<textarea id="c-caption" className="input w-full h-28 py-2" value={f.caption} onChange={(e) => set("caption", e.target.value)} />
|
||||||
|
<CaptionHelper client={f.client} caption={f.caption} platforms={sel.platforms} onInsert={(t) => set("caption", `${f.caption.trimEnd()}${t}`)} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Post to"><PlatformPicker client={f.client} type={f.type} caption={f.caption} value={sel} onChange={setSel} available={available} media={f.media} /></Field>
|
<Field label="Post to"><PlatformPicker client={f.client} type={f.type} caption={f.caption} value={sel} onChange={setSel} available={available} media={f.media} /></Field>
|
||||||
{sel.platforms.includes("facebook") && (
|
{sel.platforms.includes("facebook") && (
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user