- 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>
92 lines
6.1 KiB
JavaScript
92 lines
6.1 KiB
JavaScript
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>
|
||
);
|
||
}
|