metatroncubeswdev 7e780549ea 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>
2026-10-23 10:19:04 -04:00

92 lines
6.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}