feat: rich character profiles — brand voice, target, rules, hashtag profiles
Backend: - Character model: add brand_voice, target_audience, business_goals, products_services, content_rules (JSON do/dont), hashtag_profiles (JSON) - Content generation: inject full character context into LLM system prompt (voice, audience, goals, products, rules) - Hashtag generation: merge always-on tags from profile with AI-generated tags - Schema: update CharacterBase and CharacterUpdate with new fields Frontend: - CharacterForm: new sections "Identità e Voce", "Regole Contenuti", "Profili Hashtag" with dedicated editors - RulesEditor: do/don't list with add/remove - HashtagProfileEditor: per-platform tabs, fixed hashtags + max generated count - All fields loaded on edit, saved on submit DB migration: 6 new columns added to characters table Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,10 +7,18 @@ const EMPTY_FORM = {
|
||||
niche: '',
|
||||
topics: [],
|
||||
tone: '',
|
||||
brand_voice: '',
|
||||
target_audience: '',
|
||||
business_goals: '',
|
||||
products_services: '',
|
||||
content_rules: { do: [], dont: [] },
|
||||
hashtag_profiles: {},
|
||||
visual_style: { primary_color: '#E85A4F', secondary_color: '#1A1A1A', font: '' },
|
||||
is_active: true,
|
||||
}
|
||||
|
||||
const HASHTAG_PLATFORMS = ['instagram', 'facebook', 'youtube', 'tiktok']
|
||||
|
||||
const NICHE_CHIPS = [
|
||||
'Food & Ricette', 'Fitness & Sport', 'Tech & AI', 'Beauty & Skincare',
|
||||
'Fashion & Style', 'Travel & Lifestyle', 'Finance & Investimenti', 'Salute & Wellness',
|
||||
@@ -45,6 +53,12 @@ export default function CharacterForm() {
|
||||
niche: data.niche || '',
|
||||
topics: data.topics || [],
|
||||
tone: data.tone || '',
|
||||
brand_voice: data.brand_voice || '',
|
||||
target_audience: data.target_audience || '',
|
||||
business_goals: data.business_goals || '',
|
||||
products_services: data.products_services || '',
|
||||
content_rules: data.content_rules || { do: [], dont: [] },
|
||||
hashtag_profiles: data.hashtag_profiles || {},
|
||||
visual_style: {
|
||||
primary_color: data.visual_style?.primary_color || '#E85A4F',
|
||||
secondary_color: data.visual_style?.secondary_color || '#1A1A1A',
|
||||
@@ -222,6 +236,64 @@ export default function CharacterForm() {
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* ── Identità e Voce ──────────────────────────────────── */}
|
||||
<Section title="Identità e Voce">
|
||||
<p style={{ fontSize: '0.82rem', color: 'var(--ink-muted)', margin: '0 0 0.5rem', lineHeight: 1.5 }}>
|
||||
Queste informazioni vengono usate automaticamente dall'AI ogni volta che genera contenuti. Compilale una volta sola con cura.
|
||||
</p>
|
||||
|
||||
<Field label="Brand Voice — Come comunica questo personaggio">
|
||||
<textarea value={form.brand_voice} onChange={e => handleChange('brand_voice', e.target.value)}
|
||||
placeholder="Descrivi il modo in cui parla: diretto e provocatorio, usa metafore pratiche, mai aziendalese. Parla come al bar con un amico imprenditore. Usa il tu, frasi brevi, battute secche."
|
||||
rows={4} style={{ ...inputStyle, resize: 'vertical', lineHeight: 1.6 }}
|
||||
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
|
||||
</Field>
|
||||
|
||||
<Field label="Pubblico Target — A chi si rivolge">
|
||||
<textarea value={form.target_audience} onChange={e => handleChange('target_audience', e.target.value)}
|
||||
placeholder="Es. Freelance IT italiani 30-45 anni che sanno di dover automatizzare ma procrastinano. Imprenditori con budget limitato che vogliono risultati concreti."
|
||||
rows={3} style={{ ...inputStyle, resize: 'vertical', lineHeight: 1.6 }}
|
||||
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
|
||||
</Field>
|
||||
|
||||
<Field label="Obiettivi Business — Perché crea contenuti">
|
||||
<textarea value={form.business_goals} onChange={e => handleChange('business_goals', e.target.value)}
|
||||
placeholder="Es. Vendere consulenze AI automation a 2.500€, posizionarsi come esperto nel settore, costruire una community di freelance tech."
|
||||
rows={3} style={{ ...inputStyle, resize: 'vertical', lineHeight: 1.6 }}
|
||||
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
|
||||
</Field>
|
||||
|
||||
<Field label="Prodotti / Servizi offerti">
|
||||
<textarea value={form.products_services} onChange={e => handleChange('products_services', e.target.value)}
|
||||
placeholder="Es. Consulenza AI automation (2.500€), Corso online 'AI per Freelance' (297€), Audit gratuito di 30 min. Link: micheleborraccia.it/consulenza"
|
||||
rows={3} style={{ ...inputStyle, resize: 'vertical', lineHeight: 1.6 }}
|
||||
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
|
||||
</Field>
|
||||
</Section>
|
||||
|
||||
{/* ── Regole Contenuti ────────────────────────────────── */}
|
||||
<Section title="Regole Contenuti">
|
||||
<p style={{ fontSize: '0.82rem', color: 'var(--ink-muted)', margin: '0 0 0.5rem', lineHeight: 1.5 }}>
|
||||
Istruzioni vincolanti: l'AI le seguirà sempre. Compila per evitare di ripeterti ad ogni generazione.
|
||||
</p>
|
||||
<RulesEditor
|
||||
doRules={form.content_rules?.do || []}
|
||||
dontRules={form.content_rules?.dont || []}
|
||||
onChange={(doR, dontR) => handleChange('content_rules', { do: doR, dont: dontR })}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* ── Hashtag Profiles ────────────────────────────────── */}
|
||||
<Section title="Profili Hashtag">
|
||||
<p style={{ fontSize: '0.82rem', color: 'var(--ink-muted)', margin: '0 0 0.5rem', lineHeight: 1.5 }}>
|
||||
Hashtag fissi per piattaforma: verranno sempre inclusi nei post generati. L'AI ne aggiungerà altri variabili.
|
||||
</p>
|
||||
<HashtagProfileEditor
|
||||
profiles={form.hashtag_profiles || {}}
|
||||
onChange={p => handleChange('hashtag_profiles', p)}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* ── Stile visivo ──────────────────────────────────────── */}
|
||||
<Section title="Stile visivo">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
||||
@@ -311,6 +383,133 @@ function Field({ label, children }) {
|
||||
)
|
||||
}
|
||||
|
||||
function RulesEditor({ doRules, dontRules, onChange }) {
|
||||
const [doInput, setDoInput] = useState('')
|
||||
const [dontInput, setDontInput] = useState('')
|
||||
|
||||
const addDo = () => { const v = doInput.trim(); if (v && !doRules.includes(v)) { onChange([...doRules, v], dontRules) }; setDoInput('') }
|
||||
const addDont = () => { const v = dontInput.trim(); if (v && !dontRules.includes(v)) { onChange(doRules, [...dontRules, v]) }; setDontInput('') }
|
||||
const removeDo = (i) => onChange(doRules.filter((_, idx) => idx !== i), dontRules)
|
||||
const removeDont = (i) => onChange(doRules, dontRules.filter((_, idx) => idx !== i))
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
||||
<div>
|
||||
<label style={miniLabelStyle}>FA SEMPRE</label>
|
||||
<div style={{ display: 'flex', gap: '0.35rem', marginBottom: '0.5rem' }}>
|
||||
<input type="text" value={doInput} onChange={e => setDoInput(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addDo() } }}
|
||||
placeholder="Es. Usa sempre il tu" style={{ ...inputStyle, flex: 1, fontSize: '0.82rem' }}
|
||||
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
|
||||
<button type="button" onClick={addDo} style={{ ...btnSmall, backgroundColor: 'var(--success-light)', color: 'var(--success)' }}>+</button>
|
||||
</div>
|
||||
{doRules.map((r, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.3rem', fontSize: '0.8rem', padding: '0.25rem 0', color: 'var(--success)' }}>
|
||||
<span style={{ flexShrink: 0 }}>✓</span>
|
||||
<span style={{ flex: 1, color: 'var(--ink)' }}>{r}</span>
|
||||
<button type="button" onClick={() => removeDo(i)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--ink-muted)', fontSize: '0.9rem', padding: 0 }}>×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<label style={miniLabelStyle}>NON FARE MAI</label>
|
||||
<div style={{ display: 'flex', gap: '0.35rem', marginBottom: '0.5rem' }}>
|
||||
<input type="text" value={dontInput} onChange={e => setDontInput(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addDont() } }}
|
||||
placeholder="Es. Non usare 'rivoluzionario'" style={{ ...inputStyle, flex: 1, fontSize: '0.82rem' }}
|
||||
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
|
||||
<button type="button" onClick={addDont} style={{ ...btnSmall, backgroundColor: 'var(--error-light)', color: 'var(--error)' }}>+</button>
|
||||
</div>
|
||||
{dontRules.map((r, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.3rem', fontSize: '0.8rem', padding: '0.25rem 0', color: 'var(--error)' }}>
|
||||
<span style={{ flexShrink: 0 }}>✗</span>
|
||||
<span style={{ flex: 1, color: 'var(--ink)' }}>{r}</span>
|
||||
<button type="button" onClick={() => removeDont(i)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--ink-muted)', fontSize: '0.9rem', padding: 0 }}>×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HashtagProfileEditor({ profiles, onChange }) {
|
||||
const [activeTab, setActiveTab] = useState('instagram')
|
||||
const [tagInput, setTagInput] = useState('')
|
||||
|
||||
const getProfile = (platform) => profiles[platform] || { always: [], max_generated: 12 }
|
||||
const setProfile = (platform, profile) => onChange({ ...profiles, [platform]: profile })
|
||||
|
||||
const addTag = (platform) => {
|
||||
let tag = tagInput.trim()
|
||||
if (!tag) return
|
||||
if (!tag.startsWith('#')) tag = `#${tag}`
|
||||
const p = getProfile(platform)
|
||||
if (!p.always.includes(tag)) {
|
||||
setProfile(platform, { ...p, always: [...p.always, tag] })
|
||||
}
|
||||
setTagInput('')
|
||||
}
|
||||
|
||||
const removeTag = (platform, idx) => {
|
||||
const p = getProfile(platform)
|
||||
setProfile(platform, { ...p, always: p.always.filter((_, i) => i !== idx) })
|
||||
}
|
||||
|
||||
const profile = getProfile(activeTab)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', gap: '0', borderBottom: '2px solid var(--border)', marginBottom: '1rem' }}>
|
||||
{HASHTAG_PLATFORMS.map(p => (
|
||||
<button key={p} type="button" onClick={() => { setActiveTab(p); setTagInput('') }} style={{
|
||||
padding: '0.4rem 0.85rem', fontSize: '0.78rem', fontWeight: activeTab === p ? 700 : 400,
|
||||
fontFamily: "'DM Sans', sans-serif", border: 'none', cursor: 'pointer',
|
||||
backgroundColor: activeTab === p ? 'var(--surface)' : 'transparent',
|
||||
color: activeTab === p ? 'var(--ink)' : 'var(--ink-muted)',
|
||||
borderBottom: activeTab === p ? '2px solid var(--accent)' : '2px solid transparent',
|
||||
marginBottom: '-2px', textTransform: 'capitalize',
|
||||
}}>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.35rem', marginBottom: '0.5rem' }}>
|
||||
<input type="text" value={tagInput} onChange={e => setTagInput(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addTag(activeTab) } }}
|
||||
placeholder={`Hashtag fisso per ${activeTab}…`} style={{ ...inputStyle, flex: 1, fontSize: '0.82rem' }}
|
||||
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
|
||||
<button type="button" onClick={() => addTag(activeTab)} style={btnSmall}>+</button>
|
||||
</div>
|
||||
|
||||
{profile.always.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem' }}>
|
||||
{profile.always.map((tag, i) => (
|
||||
<span key={i} style={{ fontSize: '0.78rem', padding: '0.15rem 0.5rem', backgroundColor: 'var(--accent-light)', color: 'var(--accent)', display: 'inline-flex', alignItems: 'center', gap: '0.3rem' }}>
|
||||
{tag}
|
||||
<button type="button" onClick={() => removeTag(activeTab, i)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--accent)', fontSize: '0.7rem', padding: 0, lineHeight: 1, opacity: 0.7 }}>✕</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ fontSize: '0.78rem', color: 'var(--ink-muted)', margin: '0.25rem 0 0', fontStyle: 'italic' }}>
|
||||
Nessun hashtag fisso per {activeTab}. L'AI genererà tutti gli hashtag automaticamente.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: '0.75rem' }}>
|
||||
<label style={miniLabelStyle}>Hashtag generati dall'AI (max)</label>
|
||||
<input type="number" min={0} max={30} value={profile.max_generated ?? 12}
|
||||
onChange={e => setProfile(activeTab, { ...profile, max_generated: parseInt(e.target.value) || 0 })}
|
||||
style={{ ...inputStyle, width: 80, fontSize: '0.82rem' }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const miniLabelStyle = { display: 'block', fontSize: '0.68rem', fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--ink-muted)', marginBottom: '0.35rem' }
|
||||
const btnSmall = { padding: '0.35rem 0.65rem', fontSize: '0.8rem', fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: "'DM Sans', sans-serif", backgroundColor: 'var(--cream-dark)', color: 'var(--ink-light)' }
|
||||
|
||||
const inputStyle = {
|
||||
width: '100%', padding: '0.625rem 0.875rem',
|
||||
border: '1px solid var(--border)', borderRadius: 0,
|
||||
|
||||
Reference in New Issue
Block a user