feat: sync all BRAIN mobile changes - onboarding, cookies, legal, mobile UX, settings

- Add OnboardingWizard, BetaBanner, CookieBanner components
- Add legal pages (Privacy, Terms, Cookies)
- Update Layout with mobile topbar, sidebar drawer, plan banner
- Update SettingsPage with profile, API config, security
- Update CharacterForm with topic suggestions, niche chips
- Update EditorialCalendar with shared strategy card
- Update ContentPage with narrative technique + brief
- Update SocialAccounts with 4 platforms and token guides
- Fix CSS button color inheritance, mobile responsive
- Add backup script
- Update .gitignore for pgdata and backups

Co-Authored-By: Claude (BRAIN/StackOS) <noreply@anthropic.com>
This commit is contained in:
Michele Borraccia
2026-04-03 14:59:14 +00:00
parent 8b77f1b86b
commit 2ca8b957e9
29 changed files with 4074 additions and 2803 deletions

View File

@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { api } from '../api'
const SUGGESTED_NETWORKS = ['Amazon', 'ClickBank', 'ShareASale', 'CJ', 'Impact']
const SUGGESTED_NETWORKS = ['Amazon', 'ClickBank', 'ShareASale', 'CJ Affiliate', 'Impact', 'Awin', 'Tradedoubler']
const EMPTY_FORM = {
character_id: '',
@@ -26,11 +26,7 @@ export default function AffiliateForm() {
const [error, setError] = useState('')
const [loading, setLoading] = useState(isEdit)
useEffect(() => {
api.get('/characters/')
.then(setCharacters)
.catch(() => {})
}, [])
useEffect(() => { api.get('/characters/').then(setCharacters).catch(() => {}) }, [])
useEffect(() => {
if (isEdit) {
@@ -51,30 +47,18 @@ export default function AffiliateForm() {
}
}, [id, isEdit])
const handleChange = (field, value) => {
setForm((prev) => ({ ...prev, [field]: value }))
}
const handleChange = (field, value) => setForm((prev) => ({ ...prev, [field]: value }))
const addTopic = () => {
const topic = topicInput.trim()
if (topic && !form.topics.includes(topic)) {
setForm((prev) => ({ ...prev, topics: [...prev.topics, topic] }))
}
if (topic && !form.topics.includes(topic)) setForm((prev) => ({ ...prev, topics: [...prev.topics, topic] }))
setTopicInput('')
}
const removeTopic = (topic) => {
setForm((prev) => ({
...prev,
topics: prev.topics.filter((t) => t !== topic),
}))
}
const removeTopic = (topic) => setForm((prev) => ({ ...prev, topics: prev.topics.filter((t) => t !== topic) }))
const handleTopicKeyDown = (e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault()
addTopic()
}
if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); addTopic() }
}
const handleSubmit = async (e) => {
@@ -82,225 +66,185 @@ export default function AffiliateForm() {
setError('')
setSaving(true)
try {
const payload = {
...form,
character_id: form.character_id ? parseInt(form.character_id) : null,
}
if (isEdit) {
await api.put(`/affiliates/${id}`, payload)
} else {
await api.post('/affiliates/', payload)
}
const payload = { ...form, character_id: form.character_id ? parseInt(form.character_id) : null }
if (isEdit) await api.put(`/affiliates/${id}`, payload)
else await api.post('/affiliates/', payload)
navigate('/affiliates')
} catch (err) {
setError(err.message || 'Errore nel salvataggio')
} finally {
setSaving(false)
}
} finally { setSaving(false) }
}
if (loading) {
return (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-brand-500 border-t-transparent" />
</div>
)
}
if (loading) return (
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem 0' }}>
<div style={{ width: 28, height: 28, border: '2px solid var(--border)', borderTopColor: 'var(--accent)', borderRadius: '50%', animation: 'spin 0.8s linear infinite' }} />
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
</div>
)
return (
<div>
<div className="mb-6">
<h2 className="text-2xl font-bold text-slate-800">
<div style={{ animation: 'fade-up 0.5s ease-out both' }}>
{/* Header */}
<div style={{ marginBottom: '2rem' }}>
<span className="editorial-tag">{isEdit ? 'Modifica' : 'Nuovo Link'}</span>
<div className="editorial-line" />
<h2 style={{ fontFamily: "'Fraunces', serif", fontSize: '1.75rem', fontWeight: 600, letterSpacing: '-0.02em', color: 'var(--ink)', margin: '0.5rem 0 0.3rem' }}>
{isEdit ? 'Modifica link affiliato' : 'Nuovo link affiliato'}
</h2>
<p className="text-slate-500 mt-1 text-sm">
{isEdit ? 'Aggiorna le informazioni del link' : 'Aggiungi un nuovo link affiliato'}
<p style={{ fontSize: '0.875rem', color: 'var(--ink-muted)', margin: 0 }}>
{isEdit ? 'Aggiorna le informazioni del link' : 'Aggiungi un nuovo link affiliato al tuo catalogo'}
</p>
</div>
<form onSubmit={handleSubmit} className="max-w-2xl space-y-6">
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">
{error}
</div>
)}
{/* Main info */}
<div className="bg-white rounded-xl border border-slate-200 p-6 space-y-4">
<h3 className="font-semibold text-slate-700 text-sm uppercase tracking-wider">
Informazioni link
</h3>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Personaggio
<span className="text-slate-400 font-normal ml-1">(lascia vuoto per globale)</span>
</label>
<select
value={form.character_id}
onChange={(e) => handleChange('character_id', e.target.value)}
className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm bg-white"
>
<option value="">Globale (tutti i personaggi)</option>
{characters.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Network
</label>
<div className="space-y-2">
<input
type="text"
value={form.network}
onChange={(e) => handleChange('network', e.target.value)}
placeholder="Es. Amazon, ClickBank..."
className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm"
required
/>
<div className="flex flex-wrap gap-1.5">
{SUGGESTED_NETWORKS.map((net) => (
<button
key={net}
type="button"
onClick={() => handleChange('network', net)}
className={`text-xs px-2 py-1 rounded-lg transition-colors ${
form.network === net
? 'bg-brand-100 text-brand-700 border border-brand-200'
: 'bg-slate-100 text-slate-500 hover:bg-slate-200 border border-transparent'
}`}
>
{net}
</button>
))}
</div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Nome
</label>
<input
type="text"
value={form.name}
onChange={(e) => handleChange('name', e.target.value)}
placeholder="Es. Corso Python, Hosting Premium..."
className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
URL completo
</label>
<input
type="url"
value={form.url}
onChange={(e) => handleChange('url', e.target.value)}
placeholder="https://example.com/ref/..."
className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm font-mono"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Tag di tracciamento
</label>
<input
type="text"
value={form.tag}
onChange={(e) => handleChange('tag', e.target.value)}
placeholder="Es. ref-luigi, tag-2026..."
className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm font-mono"
/>
</div>
<div className="flex items-center gap-3">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={form.is_active}
onChange={(e) => handleChange('is_active', e.target.checked)}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-slate-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-brand-500"></div>
</label>
<span className="text-sm text-slate-700">Attivo</span>
</div>
{error && (
<div style={{ padding: '0.75rem 1rem', backgroundColor: 'var(--error-light)', border: '1px solid #FED7D7', color: 'var(--error)', fontSize: '0.875rem', marginBottom: '1.25rem' }}>
{error}
</div>
)}
{/* Topics */}
<div className="bg-white rounded-xl border border-slate-200 p-6 space-y-4">
<h3 className="font-semibold text-slate-700 text-sm uppercase tracking-wider">
Topic correlati
</h3>
<p className="text-xs text-slate-400 -mt-2">
I topic aiutano l'AI a scegliere il link giusto per ogni contenuto
<form onSubmit={handleSubmit} style={{ maxWidth: 680, display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
{/* ── Informazioni link ────────────────────────────────── */}
<Section title="Informazioni link">
<Field label="Personaggio (opzionale)">
<select value={form.character_id} onChange={(e) => handleChange('character_id', e.target.value)} style={selectStyle}>
<option value="">Globale (tutti i personaggi)</option>
{characters.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</Field>
<Field label="Network">
<input type="text" value={form.network} onChange={(e) => handleChange('network', e.target.value)}
placeholder="Es. Amazon, ClickBank…" style={inputStyle} required
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem', marginTop: '0.5rem' }}>
{SUGGESTED_NETWORKS.map((net) => (
<button key={net} type="button" onClick={() => handleChange('network', net)} style={{
padding: '0.25rem 0.65rem', fontSize: '0.78rem', fontFamily: "'DM Sans', sans-serif",
border: 'none', cursor: 'pointer',
backgroundColor: form.network === net ? 'var(--ink)' : 'var(--cream-dark)',
color: form.network === net ? 'white' : 'var(--ink-muted)',
transition: 'background-color 0.15s',
}}>
{net}
</button>
))}
</div>
</Field>
<Field label="Nome">
<input type="text" value={form.name} onChange={(e) => handleChange('name', e.target.value)}
placeholder="Es. Corso Python, Hosting Premium, Libro XYZ…" style={inputStyle} required
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
</Field>
<Field label="URL completo">
<input type="url" value={form.url} onChange={(e) => handleChange('url', e.target.value)}
placeholder="https://example.com/ref/..." style={{ ...inputStyle, fontFamily: 'monospace', fontSize: '0.82rem' }} required
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
</Field>
<Field label="Tag di tracciamento">
<input type="text" value={form.tag} onChange={(e) => handleChange('tag', e.target.value)}
placeholder="Es. ref-luigi, campagna-maggio…" style={{ ...inputStyle, fontFamily: 'monospace', fontSize: '0.82rem' }}
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
</Field>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<button type="button" onClick={() => handleChange('is_active', !form.is_active)} style={{
width: 40, height: 22, borderRadius: 11, border: 'none', cursor: 'pointer',
backgroundColor: form.is_active ? 'var(--accent)' : 'var(--border-strong)',
position: 'relative', transition: 'background-color 0.2s', flexShrink: 0,
}}>
<span style={{
position: 'absolute', top: 2, left: form.is_active ? 20 : 2,
width: 18, height: 18, borderRadius: '50%', backgroundColor: 'white', transition: 'left 0.2s',
}} />
</button>
<span style={{ fontSize: '0.875rem', color: 'var(--ink)' }}>Attivo</span>
</div>
</Section>
{/* ── Topic correlati ──────────────────────────────────── */}
<Section title="Topic correlati">
<p style={{ fontSize: '0.82rem', color: 'var(--ink-muted)', margin: 0, lineHeight: 1.5 }}>
I topic aiutano l'AI a scegliere il link giusto per ogni contenuto generato.
</p>
<div className="flex gap-2">
<input
type="text"
value={topicInput}
onChange={(e) => setTopicInput(e.target.value)}
onKeyDown={handleTopicKeyDown}
placeholder="Scrivi un topic e premi Invio"
className="flex-1 px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm"
/>
<button
type="button"
onClick={addTopic}
className="px-4 py-2.5 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-lg text-sm font-medium transition-colors"
>
Aggiungi
</button>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<input type="text" value={topicInput} onChange={(e) => setTopicInput(e.target.value)}
onKeyDown={handleTopicKeyDown} placeholder="Scrivi un topic e premi Invio"
style={{ ...inputStyle, flex: 1 }}
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
<button type="button" onClick={addTopic} style={btnSecondary}>Aggiungi</button>
</div>
{form.topics.length > 0 && (
<div className="flex flex-wrap gap-2">
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem' }}>
{form.topics.map((topic) => (
<span
key={topic}
className="inline-flex items-center gap-1.5 px-3 py-1 bg-brand-50 text-brand-700 rounded-lg text-sm"
>
<span key={topic} style={{
display: 'inline-flex', alignItems: 'center', gap: '0.4rem',
padding: '0.25rem 0.7rem', fontSize: '0.82rem',
backgroundColor: 'var(--ink)', color: 'white',
}}>
{topic}
<button
type="button"
onClick={() => removeTopic(topic)}
className="text-brand-400 hover:text-brand-600"
>
×
</button>
<button type="button" onClick={() => removeTopic(topic)} style={{
background: 'none', border: 'none', color: 'rgba(255,255,255,0.7)', cursor: 'pointer',
padding: 0, fontSize: '1rem', lineHeight: 1, display: 'flex', alignItems: 'center',
}}>×</button>
</span>
))}
</div>
)}
</div>
</Section>
{/* Actions */}
<div className="flex items-center gap-3">
<button
type="submit"
disabled={saving}
className="px-6 py-2.5 bg-brand-600 hover:bg-brand-700 disabled:bg-brand-300 text-white font-medium rounded-lg transition-colors text-sm"
>
{saving ? 'Salvataggio...' : isEdit ? 'Salva modifiche' : 'Crea link'}
</button>
<button
type="button"
onClick={() => navigate('/affiliates')}
className="px-6 py-2.5 bg-white hover:bg-slate-50 text-slate-600 font-medium rounded-lg border border-slate-200 transition-colors text-sm"
>
Annulla
{/* ── Actions ───────────────────────────────────────────── */}
<div style={{ display: 'flex', gap: '0.75rem' }}>
<button type="submit" disabled={saving} style={{ ...btnPrimary, opacity: saving ? 0.6 : 1 }}>
{saving ? 'Salvataggio' : isEdit ? 'Salva modifiche' : 'Crea link'}
</button>
<button type="button" onClick={() => navigate('/affiliates')} style={btnSecondary}>Annulla</button>
</div>
</form>
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
</div>
)
}
function Section({ title, children }) {
return (
<div style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)', borderTop: '3px solid var(--accent)', padding: '1.5rem' }}>
<p style={{ fontSize: '0.7rem', fontWeight: 700, letterSpacing: '0.1em', textTransform: 'uppercase', color: 'var(--ink-muted)', margin: '0 0 1.25rem' }}>{title}</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
{children}
</div>
</div>
)
}
function Field({ label, children }) {
return (
<div>
<label style={{ display: 'block', fontSize: '0.72rem', fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--ink)', marginBottom: '0.4rem' }}>{label}</label>
{children}
</div>
)
}
const inputStyle = {
width: '100%', padding: '0.625rem 0.875rem',
border: '1px solid var(--border)', borderRadius: 0,
fontSize: '0.875rem', color: 'var(--ink)',
backgroundColor: 'var(--surface)', outline: 'none',
boxSizing: 'border-box', transition: 'border-color 0.15s',
fontFamily: "'DM Sans', sans-serif",
}
const selectStyle = { ...inputStyle, cursor: 'pointer' }
const btnPrimary = {
display: 'inline-block', padding: '0.65rem 1.5rem',
backgroundColor: 'var(--ink)', color: 'white',
fontFamily: "'DM Sans', sans-serif", fontWeight: 600,
fontSize: '0.875rem', border: 'none', cursor: 'pointer',
}
const btnSecondary = { ...btnPrimary, backgroundColor: 'var(--cream-dark)', color: '#1A1A1A', border: '1px solid #C8C0B4' }

View File

@@ -2,12 +2,12 @@ import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../api'
const networkColors = {
Amazon: 'bg-amber-50 text-amber-700',
ClickBank: 'bg-emerald-50 text-emerald-700',
ShareASale: 'bg-blue-50 text-blue-700',
CJ: 'bg-violet-50 text-violet-700',
Impact: 'bg-rose-50 text-rose-700',
const NETWORK_COLORS = {
Amazon: { bg: '#FFF8E1', color: '#B45309' },
ClickBank: { bg: '#ECFDF5', color: '#065F46' },
ShareASale:{ bg: '#EFF6FF', color: '#1D4ED8' },
CJ: { bg: '#F5F3FF', color: '#6D28D9' },
Impact: { bg: '#FFF1F2', color: '#BE123C' },
}
export default function AffiliateList() {
@@ -16,203 +16,166 @@ export default function AffiliateList() {
const [loading, setLoading] = useState(true)
const [filterCharacter, setFilterCharacter] = useState('')
useEffect(() => {
loadData()
}, [])
useEffect(() => { loadData() }, [])
const loadData = async () => {
setLoading(true)
try {
const [linksData, charsData] = await Promise.all([
api.get('/affiliates/'),
api.get('/characters/'),
api.get('/affiliates/'), api.get('/characters/'),
])
setLinks(linksData)
setCharacters(charsData)
} catch {
// silent
} finally {
setLoading(false)
}
} catch {} finally { setLoading(false) }
}
const getCharacterName = (id) => {
if (!id) return 'Globale'
const c = characters.find((ch) => ch.id === id)
return c ? c.name : '—'
}
const getNetworkColor = (network) => {
return networkColors[network] || 'bg-slate-100 text-slate-600'
return characters.find(c => c.id === id)?.name || '—'
}
const handleToggle = async (link) => {
try {
await api.put(`/affiliates/${link.id}`, { is_active: !link.is_active })
loadData()
} catch {
// silent
}
await api.put(`/affiliates/${link.id}`, { is_active: !link.is_active }).catch(() => {})
loadData()
}
const handleDelete = async (id, name) => {
if (!confirm(`Eliminare "${name}"?`)) return
try {
await api.delete(`/affiliates/${id}`)
loadData()
} catch {
// silent
}
await api.delete(`/affiliates/${id}`).catch(() => {})
loadData()
}
const truncateUrl = (url) => {
if (!url) return '—'
if (url.length <= 50) return url
return url.substring(0, 50) + '...'
}
const filtered = links.filter((l) => {
const filtered = links.filter(l => {
if (filterCharacter === '') return true
if (filterCharacter === 'global') return !l.character_id
return String(l.character_id) === filterCharacter
})
return (
<div>
<div className="flex items-center justify-between mb-6">
<div style={{ animation: 'fade-up 0.5s ease-out both' }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: '2rem' }}>
<div>
<h2 className="text-2xl font-bold text-slate-800">Link Affiliati</h2>
<p className="text-slate-500 mt-1 text-sm">
Gestisci i link affiliati per la monetizzazione
<span className="editorial-tag">Link Affiliati</span>
<div className="editorial-line" />
<h2 style={{ fontFamily: "'Fraunces', serif", fontSize: '1.75rem', fontWeight: 600, letterSpacing: '-0.02em', color: 'var(--ink)', margin: '0.5rem 0 0.3rem' }}>
Monetizzazione
</h2>
<p style={{ fontSize: '0.875rem', color: 'var(--ink-muted)', margin: 0 }}>
Gestisci i link affiliati: Leopost li inserisce automaticamente nei contenuti generati.
</p>
</div>
<Link
to="/affiliates/new"
className="px-4 py-2 bg-brand-600 hover:bg-brand-700 text-white text-sm font-medium rounded-lg transition-colors"
>
+ Nuovo Link
</Link>
<Link to="/affiliates/new" style={btnPrimary}>+ Nuovo Link</Link>
</div>
{/* Filters */}
<div className="flex flex-wrap gap-3 mb-6">
<select
value={filterCharacter}
onChange={(e) => setFilterCharacter(e.target.value)}
className="px-3 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm bg-white"
>
<option value="">Tutti</option>
<option value="global">Globale</option>
{characters.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
<span className="flex items-center text-xs text-slate-400 ml-auto">
{filtered.length} link
</span>
</div>
{/* Filter */}
{characters.length > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '1.5rem' }}>
<label style={{ fontSize: '0.78rem', fontWeight: 600, color: 'var(--ink-muted)', textTransform: 'uppercase', letterSpacing: '0.08em' }}>Filtra per</label>
<select value={filterCharacter} onChange={e => setFilterCharacter(e.target.value)} style={selectStyle}>
<option value="">Tutti</option>
<option value="global">Globale</option>
{characters.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
<span style={{ fontSize: '0.78rem', color: 'var(--ink-muted)', marginLeft: 'auto' }}>{filtered.length} link</span>
</div>
)}
{loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-brand-500 border-t-transparent" />
</div>
<Spinner />
) : filtered.length === 0 ? (
<div className="text-center py-16 bg-white rounded-xl border border-slate-200">
<p className="text-4xl mb-3"></p>
<p className="text-slate-500 font-medium">Nessun link affiliato</p>
<p className="text-slate-400 text-sm mt-1">
Aggiungi i tuoi primi link affiliati per monetizzare i contenuti
</p>
<Link
to="/affiliates/new"
className="inline-block mt-4 px-4 py-2 bg-brand-600 hover:bg-brand-700 text-white text-sm font-medium rounded-lg transition-colors"
>
+ Crea link affiliato
</Link>
</div>
<EmptyState
icon="⟁"
title="Nessun link affiliato"
description="Aggiungi i link affiliati dei tuoi programmi (Amazon, ClickBank, ecc.) e Leopost li inserirà automaticamente nei contenuti pertinenti."
cta="+ Aggiungi primo link"
to="/affiliates/new"
/>
) : (
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-100">
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wider">Network</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wider">Nome</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wider hidden md:table-cell">URL</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wider hidden lg:table-cell">Tag</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wider hidden lg:table-cell">Topic</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wider">Personaggio</th>
<th className="text-center px-4 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wider">Stato</th>
<th className="text-center px-4 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wider">Click</th>
<th className="text-right px-4 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wider">Azioni</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
{filtered.map((link) => (
<tr key={link.id} className="hover:bg-slate-50 transition-colors">
<td className="px-4 py-3">
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${getNetworkColor(link.network)}`}>
{link.network || '—'}
</span>
<div style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)', overflowX: 'auto' }}>
<table style={{ width: '100%', fontSize: '0.85rem', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--border)' }}>
{['Network','Nome','URL','Personaggio','Stato','Click',''].map(h => (
<th key={h} style={{ padding: '0.75rem 1rem', textAlign: 'left', fontSize: '0.68rem', fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--ink-muted)', backgroundColor: 'var(--cream-dark)' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{filtered.map(link => {
const nc = NETWORK_COLORS[link.network] || { bg: 'var(--cream-dark)', color: 'var(--ink-muted)' }
return (
<tr key={link.id} style={{ borderBottom: '1px solid var(--border)' }}
onMouseEnter={e => e.currentTarget.style.backgroundColor = 'var(--cream)'}
onMouseLeave={e => e.currentTarget.style.backgroundColor = 'transparent'}
>
<td style={{ padding: '0.75rem 1rem' }}>
<span style={{ fontSize: '0.72rem', fontWeight: 600, padding: '0.2rem 0.5rem', backgroundColor: nc.bg, color: nc.color }}>{link.network || '—'}</span>
</td>
<td className="px-4 py-3 font-medium text-slate-700">{link.name}</td>
<td className="px-4 py-3 text-slate-500 hidden md:table-cell">
<span className="font-mono text-xs">{truncateUrl(link.url)}</span>
<td style={{ padding: '0.75rem 1rem', fontWeight: 600, color: 'var(--ink)' }}>{link.name}</td>
<td style={{ padding: '0.75rem 1rem', color: 'var(--ink-muted)', fontFamily: 'monospace', fontSize: '0.78rem', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{link.url?.substring(0, 45)}{link.url?.length > 45 ? '…' : ''}
</td>
<td className="px-4 py-3 text-slate-500 hidden lg:table-cell">
<span className="font-mono text-xs">{link.tag || '—'}</span>
<td style={{ padding: '0.75rem 1rem', color: 'var(--ink-muted)', fontSize: '0.82rem' }}>{getCharacterName(link.character_id)}</td>
<td style={{ padding: '0.75rem 1rem' }}>
<span style={{ display: 'inline-block', width: 8, height: 8, borderRadius: '50%', backgroundColor: link.is_active ? 'var(--success)' : 'var(--border-strong)' }} />
</td>
<td className="px-4 py-3 hidden lg:table-cell">
<div className="flex flex-wrap gap-1">
{link.topics && link.topics.slice(0, 2).map((t, i) => (
<span key={i} className="text-xs px-1.5 py-0.5 bg-slate-100 text-slate-600 rounded">
{t}
</span>
))}
{link.topics && link.topics.length > 2 && (
<span className="text-xs text-slate-400">+{link.topics.length - 2}</span>
)}
</div>
</td>
<td className="px-4 py-3 text-slate-500 text-xs">
{getCharacterName(link.character_id)}
</td>
<td className="px-4 py-3 text-center">
<span className={`inline-block w-2 h-2 rounded-full ${link.is_active ? 'bg-emerald-500' : 'bg-slate-300'}`} />
</td>
<td className="px-4 py-3 text-center text-slate-500">
{link.click_count ?? 0}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
<Link
to={`/affiliates/${link.id}/edit`}
className="text-xs px-2 py-1 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded transition-colors"
>
Modifica
</Link>
<button
onClick={() => handleToggle(link)}
className="text-xs px-2 py-1 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded transition-colors"
>
{link.is_active ? 'Disattiva' : 'Attiva'}
</button>
<button
onClick={() => handleDelete(link.id, link.name)}
className="text-xs px-2 py-1 text-red-500 hover:bg-red-50 rounded transition-colors"
>
Elimina
</button>
<td style={{ padding: '0.75rem 1rem', color: 'var(--ink-muted)' }}>{link.click_count ?? 0}</td>
<td style={{ padding: '0.75rem 1rem' }}>
<div style={{ display: 'flex', gap: '0.4rem', justifyContent: 'flex-end' }}>
<Link to={`/affiliates/${link.id}/edit`} style={btnSmall}>Modifica</Link>
<button onClick={() => handleToggle(link)} style={btnSmall}>{link.is_active ? 'Disattiva' : 'Attiva'}</button>
<button onClick={() => handleDelete(link.id, link.name)} style={{ ...btnSmall, color: 'var(--error)' }}>Elimina</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
})}
</tbody>
</table>
</div>
)}
</div>
)
}
function EmptyState({ icon, title, description, cta, to }) {
return (
<div style={{ textAlign: 'center', padding: '4rem 2rem', backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}>
<div style={{ fontSize: '2.5rem', marginBottom: '1rem', color: 'var(--accent)' }}>{icon}</div>
<h3 style={{ fontFamily: "'Fraunces', serif", fontSize: '1.2rem', fontWeight: 600, color: 'var(--ink)', margin: '0 0 0.75rem' }}>{title}</h3>
<p style={{ fontSize: '0.875rem', color: 'var(--ink-muted)', maxWidth: 400, margin: '0 auto 1.5rem', lineHeight: 1.6 }}>{description}</p>
<Link to={to} style={btnPrimary}>{cta}</Link>
</div>
)
}
function Spinner() {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem 0' }}>
<div style={{ width: 28, height: 28, border: '2px solid var(--border)', borderTopColor: 'var(--accent)', borderRadius: '50%', animation: 'spin 0.8s linear infinite' }} />
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
</div>
)
}
const btnPrimary = {
display: 'inline-block', padding: '0.6rem 1.25rem',
backgroundColor: 'var(--ink)', color: 'white',
fontFamily: "'DM Sans', sans-serif", fontWeight: 600,
fontSize: '0.875rem', textDecoration: 'none',
border: 'none', cursor: 'pointer', whiteSpace: 'nowrap',
}
const btnSmall = {
display: 'inline-block', padding: '0.35rem 0.75rem',
backgroundColor: 'var(--cream-dark)', color: 'var(--ink-light)',
fontFamily: "'DM Sans', sans-serif", fontWeight: 500,
fontSize: '0.78rem', textDecoration: 'none',
border: 'none', cursor: 'pointer',
}
const selectStyle = {
padding: '0.45rem 0.75rem', border: '1px solid var(--border)',
backgroundColor: 'var(--surface)', color: 'var(--ink)',
fontSize: '0.85rem', fontFamily: "'DM Sans', sans-serif",
outline: 'none', cursor: 'pointer',
}

View File

@@ -2,6 +2,16 @@ import { useEffect } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useAuth } from '../AuthContext'
const ERROR_MESSAGES = {
non_configurato: 'Google OAuth non è ancora configurato su questo server.',
token_exchange: 'Errore durante lo scambio del token Google. Riprova.',
userinfo: 'Impossibile recuperare il profilo Google. Riprova.',
missing_data: 'Il tuo account Google non ha fornito i dati necessari.',
server_error: 'Errore interno durante il login con Google. Riprova tra qualche momento.',
access_denied: 'Hai annullato il login con Google.',
default: 'Errore di accesso con Google. Riprova.',
}
export default function AuthCallback() {
const [params] = useSearchParams()
const navigate = useNavigate()
@@ -9,27 +19,31 @@ export default function AuthCallback() {
useEffect(() => {
const token = params.get('token')
const oauthError = params.get('oauth_error')
if (token) {
loginWithToken(token)
navigate('/', { replace: true })
} else if (oauthError) {
const msg = ERROR_MESSAGES[oauthError] || ERROR_MESSAGES.default
navigate(`/login?error=${encodeURIComponent(msg)}`, { replace: true })
} else {
navigate('/login', { replace: true })
}
}, [])
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100dvh' }}>
<div style={{ textAlign: 'center' }}>
<div style={{
width: 40,
height: 40,
border: '3px solid #FF6B4A',
width: 40, height: 40,
border: '3px solid #E85A4F',
borderTopColor: 'transparent',
borderRadius: '50%',
animation: 'spin 0.8s linear infinite',
margin: '0 auto 1rem',
}} />
<p style={{ color: '#666' }}>Accesso in corso...</p>
<p style={{ color: '#666', fontFamily: "'DM Sans', sans-serif" }}>Accesso in corso</p>
</div>
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</div>

View File

@@ -0,0 +1,69 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
const DISMISSED_KEY = 'leopost_beta_banner_dismissed'
export default function BetaBanner() {
const [dismissed, setDismissed] = useState(() => {
try { return !!localStorage.getItem(DISMISSED_KEY) } catch { return false }
})
if (dismissed) return null
const dismiss = () => {
try { localStorage.setItem(DISMISSED_KEY, '1') } catch {}
setDismissed(true)
}
return (
<div style={{
backgroundColor: '#1A1A1A',
borderBottom: '2px solid #E85A4F',
padding: '0.5rem 1.5rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '1rem',
flexWrap: 'wrap',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
<span style={{
fontSize: '0.65rem',
fontWeight: 700,
letterSpacing: '0.12em',
textTransform: 'uppercase',
backgroundColor: '#E85A4F',
color: 'white',
padding: '0.15rem 0.5rem',
flexShrink: 0,
}}>
Beta
</span>
<p style={{ margin: 0, fontSize: '0.8rem', color: 'rgba(255,251,245,0.85)', lineHeight: 1.4 }}>
Sei un <strong style={{ color: '#FFFBF5' }}>Early Adopter</strong> grazie per testare Leopost in anteprima.
Puoi riscattare il tuo codice Pro da{' '}
<Link to="/settings" style={{ color: '#E85A4F', textDecoration: 'underline', textUnderlineOffset: '3px' }}>
Impostazioni
</Link>.
</p>
</div>
<button
onClick={dismiss}
aria-label="Chiudi banner beta"
style={{
background: 'none',
border: 'none',
color: 'rgba(255,251,245,0.4)',
cursor: 'pointer',
fontSize: '1rem',
lineHeight: 1,
padding: '0.25rem',
flexShrink: 0,
fontFamily: "'DM Sans', sans-serif",
}}
>
</button>
</div>
)
}

View File

@@ -1,98 +1,41 @@
import { useState, useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { api } from '../api'
import { useAuth } from '../AuthContext'
const EMPTY_FORM = {
name: '',
niche: '',
topics: [],
tone: '',
visual_style: { primary_color: '#f97316', secondary_color: '#1e293b', font: '' },
visual_style: { primary_color: '#E85A4F', secondary_color: '#1A1A1A', font: '' },
is_active: true,
}
const PLATFORMS = [
{
id: 'facebook',
name: 'Facebook',
icon: '📘',
color: '#1877F2',
guide: [
'Vai su developers.facebook.com e accedi con il tuo account.',
'Crea una nuova App → scegli "Business".',
'Aggiungi il prodotto "Facebook Login" e "Pages API".',
'In "Graph API Explorer", seleziona la tua app e la tua Pagina.',
'Genera un Page Access Token con permessi: pages_manage_posts, pages_read_engagement.',
'Copia il Page ID dalla pagina Facebook (Info → ID pagina).',
],
proOnly: false,
},
{
id: 'instagram',
name: 'Instagram',
icon: '📸',
color: '#E1306C',
guide: [
'Instagram usa le API di Facebook (Meta).',
'Nella stessa app Meta, aggiungi il prodotto "Instagram Graph API".',
'Collega un profilo Instagram Business alla tua pagina Facebook.',
'In Graph API Explorer, genera un token con scope: instagram_basic, instagram_content_publish.',
'Trova l\'Instagram User ID tramite: GET /{page-id}?fields=instagram_business_account.',
'Inserisci il token e l\'IG User ID nei campi sottostanti.',
],
proOnly: false,
},
{
id: 'youtube',
name: 'YouTube',
icon: '▶️',
color: '#FF0000',
guide: [
'Vai su console.cloud.google.com e crea un progetto.',
'Abilita "YouTube Data API v3" nella sezione API & Services.',
'Crea credenziali OAuth 2.0 (tipo: Web application).',
'Autorizza l\'accesso al tuo canale YouTube seguendo il flusso OAuth.',
'Copia l\'Access Token e il Channel ID (visibile in YouTube Studio → Personalizzazione → Informazioni).',
],
proOnly: true,
},
{
id: 'tiktok',
name: 'TikTok',
icon: '🎵',
color: '#000000',
guide: [
'Vai su developers.tiktok.com e registra un account sviluppatore.',
'Crea una nuova app → seleziona "Content Posting API".',
'Richiedi i permessi: video.publish, video.upload.',
'Completa il processo di verifica app (può richiedere alcuni giorni).',
'Una volta approvata, genera un access token seguendo la documentazione OAuth 2.0.',
],
proOnly: true,
},
const NICHE_CHIPS = [
'Food & Ricette', 'Fitness & Sport', 'Tech & AI', 'Beauty & Skincare',
'Fashion & Style', 'Travel & Lifestyle', 'Finance & Investimenti', 'Salute & Wellness',
'Gaming', 'Business & Marketing', 'Ambiente & Sostenibilità', 'Arte & Design',
'Musica', 'Educazione', 'Cucina Italiana', 'Automotive',
]
const TOPIC_CHIPS = [
'Tutorial', 'Before/After', 'Tips & Tricks', 'Dietro le quinte',
'Recensione prodotto', 'Unboxing', 'Trend del momento', 'FAQ',
'Motivazione', 'Case study', 'Confronto', 'Sfida / Challenge',
'News del settore', 'Ispirazione', 'Lista consigli', 'Storia personale',
]
export default function CharacterForm() {
const { id } = useParams()
const isEdit = Boolean(id)
const navigate = useNavigate()
const { isPro } = useAuth()
const [activeTab, setActiveTab] = useState('profile')
const [form, setForm] = useState(EMPTY_FORM)
const [topicInput, setTopicInput] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(isEdit)
// Social accounts state
const [socialAccounts, setSocialAccounts] = useState({})
const [expandedGuide, setExpandedGuide] = useState(null)
const [savingToken, setSavingToken] = useState({})
const [tokenInputs, setTokenInputs] = useState({})
const [pageIdInputs, setPageIdInputs] = useState({})
useEffect(() => {
if (isEdit) {
api.get(`/characters/${id}`)
@@ -103,8 +46,8 @@ export default function CharacterForm() {
topics: data.topics || [],
tone: data.tone || '',
visual_style: {
primary_color: data.visual_style?.primary_color || '#f97316',
secondary_color: data.visual_style?.secondary_color || '#1e293b',
primary_color: data.visual_style?.primary_color || '#E85A4F',
secondary_color: data.visual_style?.secondary_color || '#1A1A1A',
font: data.visual_style?.font || '',
},
is_active: data.is_active ?? true,
@@ -112,49 +55,24 @@ export default function CharacterForm() {
})
.catch(() => setError('Personaggio non trovato'))
.finally(() => setLoading(false))
// Load social accounts for this character
api.get(`/social/accounts?character_id=${id}`)
.then((accounts) => {
const map = {}
accounts.forEach((acc) => { map[acc.platform] = acc })
setSocialAccounts(map)
})
.catch(() => {})
}
}, [id, isEdit])
const handleChange = (field, value) => {
setForm((prev) => ({ ...prev, [field]: value }))
}
const handleChange = (field, value) => setForm((prev) => ({ ...prev, [field]: value }))
const handleStyleChange = (field, value) => setForm((prev) => ({ ...prev, visual_style: { ...prev.visual_style, [field]: value } }))
const handleStyleChange = (field, value) => {
setForm((prev) => ({
...prev,
visual_style: { ...prev.visual_style, [field]: value },
}))
}
const addTopic = () => {
const topic = topicInput.trim()
const addTopic = (t) => {
const topic = (t || topicInput).trim()
if (topic && !form.topics.includes(topic)) {
setForm((prev) => ({ ...prev, topics: [...prev.topics, topic] }))
}
setTopicInput('')
if (!t) setTopicInput('')
}
const removeTopic = (topic) => {
setForm((prev) => ({
...prev,
topics: prev.topics.filter((t) => t !== topic),
}))
}
const removeTopic = (topic) => setForm((prev) => ({ ...prev, topics: prev.topics.filter((t) => t !== topic) }))
const handleTopicKeyDown = (e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault()
addTopic()
}
if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); addTopic() }
}
const handleSubmit = async (e) => {
@@ -162,471 +80,252 @@ export default function CharacterForm() {
setError('')
setSaving(true)
try {
if (isEdit) {
await api.put(`/characters/${id}`, form)
} else {
await api.post('/characters/', form)
}
if (isEdit) { await api.put(`/characters/${id}`, form) }
else { await api.post('/characters/', form) }
navigate('/characters')
} catch (err) {
if (err.data?.upgrade_required) {
setError(err.data.message || 'Limite piano raggiunto. Passa a Pro.')
} else {
setError(err.message || 'Errore nel salvataggio')
}
} finally {
setSaving(false)
}
if (err.data?.upgrade_required) setError(err.data.message || 'Limite piano raggiunto. Passa a Pro.')
else setError(err.message || 'Errore nel salvataggio')
} finally { setSaving(false) }
}
const handleSaveToken = async (platform) => {
if (!isEdit) return
const token = tokenInputs[platform] || ''
const pageId = pageIdInputs[platform] || ''
if (!token.trim()) return
setSavingToken((prev) => ({ ...prev, [platform]: true }))
try {
const existing = socialAccounts[platform]
if (existing) {
await api.put(`/social/accounts/${existing.id}`, {
access_token: token,
page_id: pageId || undefined,
})
} else {
await api.post('/social/accounts', {
character_id: Number(id),
platform,
access_token: token,
page_id: pageId || undefined,
account_name: platform,
})
}
// Reload
const accounts = await api.get(`/social/accounts?character_id=${id}`)
const map = {}
accounts.forEach((acc) => { map[acc.platform] = acc })
setSocialAccounts(map)
setTokenInputs((prev) => ({ ...prev, [platform]: '' }))
setPageIdInputs((prev) => ({ ...prev, [platform]: '' }))
} catch (err) {
alert(err.message || 'Errore nel salvataggio del token.')
} finally {
setSavingToken((prev) => ({ ...prev, [platform]: false }))
}
}
const handleDisconnect = async (platform) => {
const acc = socialAccounts[platform]
if (!acc) return
if (!window.confirm(`Disconnetti ${platform}?`)) return
try {
await api.delete(`/social/accounts/${acc.id}`)
setSocialAccounts((prev) => {
const next = { ...prev }
delete next[platform]
return next
})
} catch (err) {
alert(err.message || 'Errore nella disconnessione.')
}
}
if (loading) {
return (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-brand-500 border-t-transparent" />
</div>
)
}
if (loading) return (
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem 0' }}>
<div style={{ width: 28, height: 28, border: '2px solid var(--border)', borderTopColor: 'var(--accent)', borderRadius: '50%', animation: 'spin 0.8s linear infinite' }} />
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
</div>
)
return (
<div>
<div className="mb-6">
<h2 className="text-2xl font-bold text-slate-800">
{isEdit ? 'Modifica personaggio' : 'Nuovo personaggio'}
<div style={{ animation: 'fade-up 0.5s ease-out both' }}>
{/* Header */}
<div style={{ marginBottom: '2rem' }}>
<span className="editorial-tag">{isEdit ? 'Modifica' : 'Nuovo Personaggio'}</span>
<div className="editorial-line" />
<h2 style={{ fontFamily: "'Fraunces', serif", fontSize: '1.75rem', fontWeight: 600, letterSpacing: '-0.02em', color: 'var(--ink)', margin: '0.5rem 0 0.3rem' }}>
{isEdit ? 'Modifica personaggio' : 'Crea un Personaggio'}
</h2>
<p className="text-slate-500 mt-1 text-sm">
{isEdit ? 'Aggiorna il profilo editoriale' : 'Crea un nuovo profilo editoriale'}
<p style={{ fontSize: '0.875rem', color: 'var(--ink-muted)', margin: 0 }}>
Il personaggio è la voce editoriale. Definisci nicchia, tono e topic ricorrenti: l'AI li userà ogni volta che genera contenuti.
</p>
</div>
{/* Tabs */}
<div className="flex gap-1 mb-6 p-1 rounded-lg inline-flex" style={{ backgroundColor: '#F1F5F9', border: '1px solid #E2E8F0' }}>
{[
{ id: 'profile', label: 'Profilo' },
{ id: 'social', label: 'Account Social', disabled: !isEdit },
].map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => !tab.disabled && setActiveTab(tab.id)}
disabled={tab.disabled}
className="px-4 py-2 rounded-md text-sm font-medium transition-all"
style={{
backgroundColor: activeTab === tab.id ? 'white' : 'transparent',
color: activeTab === tab.id ? '#1E293B' : tab.disabled ? '#CBD5E1' : '#64748B',
boxShadow: activeTab === tab.id ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
cursor: tab.disabled ? 'not-allowed' : 'pointer',
}}
>
{tab.label}
{tab.disabled && <span className="ml-1 text-xs">(salva prima)</span>}
</button>
))}
</div>
{activeTab === 'profile' && (
<form onSubmit={handleSubmit} className="max-w-2xl space-y-6">
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">
{error}
</div>
)}
{/* Basic info */}
<div className="bg-white rounded-xl border border-slate-200 p-6 space-y-4">
<h3 className="font-semibold text-slate-700 text-sm uppercase tracking-wider">
Informazioni base
</h3>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Nome personaggio
</label>
<input
type="text"
value={form.name}
onChange={(e) => handleChange('name', e.target.value)}
placeholder="Es. TechGuru, FoodBlogger..."
className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Niche / Settore
</label>
<input
type="text"
value={form.niche}
onChange={(e) => handleChange('niche', e.target.value)}
placeholder="Es. Tecnologia, Food, Fitness..."
className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Tono di comunicazione
</label>
<textarea
value={form.tone}
onChange={(e) => handleChange('tone', e.target.value)}
placeholder="Descrivi lo stile di comunicazione: informale, professionale, ironico, motivazionale..."
rows={3}
className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm resize-none"
/>
</div>
<div className="flex items-center gap-3">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={form.is_active}
onChange={(e) => handleChange('is_active', e.target.checked)}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-slate-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-brand-500"></div>
</label>
<span className="text-sm text-slate-700">Attivo</span>
</div>
</div>
{/* Topics */}
<div className="bg-white rounded-xl border border-slate-200 p-6 space-y-4">
<h3 className="font-semibold text-slate-700 text-sm uppercase tracking-wider">
Topic ricorrenti
</h3>
<div className="flex gap-2">
<input
type="text"
value={topicInput}
onChange={(e) => setTopicInput(e.target.value)}
onKeyDown={handleTopicKeyDown}
placeholder="Scrivi un topic e premi Invio"
className="flex-1 px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm"
/>
<button
type="button"
onClick={addTopic}
className="px-4 py-2.5 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-lg text-sm font-medium transition-colors"
>
Aggiungi
</button>
</div>
{form.topics.length > 0 && (
<div className="flex flex-wrap gap-2">
{form.topics.map((topic) => (
<span
key={topic}
className="inline-flex items-center gap-1.5 px-3 py-1 bg-brand-50 text-brand-700 rounded-lg text-sm"
>
{topic}
<button
type="button"
onClick={() => removeTopic(topic)}
className="text-brand-400 hover:text-brand-600"
>
×
</button>
</span>
))}
</div>
)}
</div>
{/* Visual style */}
<div className="bg-white rounded-xl border border-slate-200 p-6 space-y-4">
<h3 className="font-semibold text-slate-700 text-sm uppercase tracking-wider">
Stile visivo
</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Colore primario
</label>
<div className="flex items-center gap-2">
<input
type="color"
value={form.visual_style.primary_color}
onChange={(e) => handleStyleChange('primary_color', e.target.value)}
className="w-10 h-10 rounded border border-slate-200 cursor-pointer"
/>
<input
type="text"
value={form.visual_style.primary_color}
onChange={(e) => handleStyleChange('primary_color', e.target.value)}
className="flex-1 px-3 py-2 border border-slate-200 rounded-lg text-sm font-mono"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Colore secondario
</label>
<div className="flex items-center gap-2">
<input
type="color"
value={form.visual_style.secondary_color}
onChange={(e) => handleStyleChange('secondary_color', e.target.value)}
className="w-10 h-10 rounded border border-slate-200 cursor-pointer"
/>
<input
type="text"
value={form.visual_style.secondary_color}
onChange={(e) => handleStyleChange('secondary_color', e.target.value)}
className="flex-1 px-3 py-2 border border-slate-200 rounded-lg text-sm font-mono"
/>
</div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Font preferito
</label>
<input
type="text"
value={form.visual_style.font}
onChange={(e) => handleStyleChange('font', e.target.value)}
placeholder="Es. Montserrat, Poppins, Inter..."
className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm"
/>
</div>
{/* Preview */}
<div className="mt-4 p-4 rounded-lg border border-dashed border-slate-300">
<p className="text-xs text-slate-400 mb-2">Anteprima</p>
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold"
style={{ backgroundColor: form.visual_style.primary_color }}
>
{form.name?.charAt(0)?.toUpperCase() || '?'}
</div>
<div>
<p className="font-semibold text-sm" style={{ color: form.visual_style.secondary_color }}>
{form.name || 'Nome personaggio'}
</p>
<p className="text-xs text-slate-400">{form.niche || 'Niche'}</p>
</div>
</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-3">
<button
type="submit"
disabled={saving}
className="px-6 py-2.5 bg-brand-600 hover:bg-brand-700 disabled:bg-brand-300 text-white font-medium rounded-lg transition-colors text-sm"
>
{saving ? 'Salvataggio...' : isEdit ? 'Salva modifiche' : 'Crea personaggio'}
</button>
<button
type="button"
onClick={() => navigate('/characters')}
className="px-6 py-2.5 bg-white hover:bg-slate-50 text-slate-600 font-medium rounded-lg border border-slate-200 transition-colors text-sm"
>
Annulla
</button>
</div>
</form>
)}
{activeTab === 'social' && isEdit && (
<div className="max-w-2xl space-y-4">
{PLATFORMS.map((platform) => {
const account = socialAccounts[platform.id]
const isConnected = Boolean(account?.access_token)
const locked = platform.proOnly && !isPro
const guideOpen = expandedGuide === platform.id
return (
<div
key={platform.id}
className="bg-white rounded-xl border border-slate-200 p-5"
>
{/* Platform header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<span style={{ fontSize: '1.5rem' }}>{platform.icon}</span>
<div>
<div className="flex items-center gap-2">
<span className="font-semibold text-slate-800">{platform.name}</span>
{locked && (
<span className="text-xs px-2 py-0.5 rounded-full font-semibold" style={{ backgroundColor: '#FFF5F3', color: '#FF6B4A' }}>
🔒 Piano Pro
</span>
)}
{!locked && (
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${isConnected ? 'bg-emerald-50 text-emerald-700' : 'bg-slate-100 text-slate-400'}`}>
{isConnected ? '● Connesso' : '○ Non connesso'}
</span>
)}
</div>
{account?.account_name && (
<p className="text-xs text-slate-400">{account.account_name}</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
{locked ? (
<button
disabled
className="px-3 py-1.5 text-xs font-medium rounded-lg opacity-40 cursor-not-allowed"
style={{ backgroundColor: '#F1F5F9', color: '#64748B' }}
>
Disponibile con Pro
</button>
) : isConnected ? (
<button
onClick={() => handleDisconnect(platform.id)}
className="px-3 py-1.5 text-xs font-medium rounded-lg text-red-600 hover:bg-red-50 border border-red-200"
>
Disconnetti
</button>
) : null}
{!locked && (
<button
type="button"
onClick={() => setExpandedGuide(guideOpen ? null : platform.id)}
className="px-3 py-1.5 text-xs font-medium rounded-lg text-slate-500 hover:bg-slate-100 border border-slate-200"
>
{guideOpen ? '▲ Nascondi guida' : '▼ Guida setup'}
</button>
)}
</div>
</div>
{/* Guide accordion */}
{guideOpen && !locked && (
<div className="mb-4 p-4 rounded-lg" style={{ backgroundColor: '#F8FAFC', border: '1px solid #E2E8F0' }}>
<h4 className="text-xs font-bold text-slate-600 uppercase tracking-wide mb-2">
Come connettere {platform.name}
</h4>
<ol className="space-y-1.5">
{platform.guide.map((step, i) => (
<li key={i} className="text-xs text-slate-600 flex gap-2">
<span className="font-bold text-slate-400 flex-shrink-0">{i + 1}.</span>
<span>{step}</span>
</li>
))}
</ol>
<div className="mt-3 p-2.5 rounded" style={{ backgroundColor: '#FFF8E1', border: '1px solid #FFE082' }}>
<p className="text-xs text-amber-700">
<strong>Nota:</strong> L'integrazione OAuth diretta è in arrivo. Per ora, copia manualmente il token nei campi sottostanti.
</p>
</div>
</div>
)}
{/* Manual token input */}
{!locked && (
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">
Access Token{platform.id === 'facebook' || platform.id === 'instagram' ? ' (Page/User Access Token)' : ''}
</label>
<input
type="password"
value={tokenInputs[platform.id] || ''}
onChange={(e) => setTokenInputs((prev) => ({ ...prev, [platform.id]: e.target.value }))}
placeholder={isConnected ? ' (token già salvato)' : 'Incolla il token qui...'}
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-xs font-mono focus:outline-none"
/>
</div>
{(platform.id === 'facebook' || platform.id === 'youtube') && (
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">
{platform.id === 'facebook' ? 'Page ID' : 'Channel ID'}
</label>
<input
type="text"
value={pageIdInputs[platform.id] || ''}
onChange={(e) => setPageIdInputs((prev) => ({ ...prev, [platform.id]: e.target.value }))}
placeholder={isConnected && account?.page_id ? account.page_id : 'Es. 123456789'}
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-xs font-mono focus:outline-none"
/>
</div>
)}
<button
type="button"
onClick={() => handleSaveToken(platform.id)}
disabled={savingToken[platform.id] || !tokenInputs[platform.id]?.trim()}
className="px-4 py-2 text-xs font-medium rounded-lg text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
style={{ backgroundColor: '#FF6B4A' }}
>
{savingToken[platform.id] ? 'Salvataggio...' : 'Salva Token'}
</button>
</div>
)}
</div>
)
})}
{error && (
<div style={{ padding: '0.75rem 1rem', backgroundColor: 'var(--error-light)', border: '1px solid #FED7D7', color: 'var(--error)', fontSize: '0.875rem', marginBottom: '1.25rem' }}>
{error}
</div>
)}
<form onSubmit={handleSubmit} style={{ maxWidth: 680, display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
{/* ── Informazioni base ─────────────────────────────────── */}
<Section title="Informazioni base">
<Field label="Nome personaggio">
<input type="text" value={form.name} onChange={(e) => handleChange('name', e.target.value)}
placeholder="Es. TechGuru, FoodBlogger, FitCoach…" style={inputStyle} required
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
</Field>
<Field label="Nicchia / Settore">
<input type="text" value={form.niche} onChange={(e) => handleChange('niche', e.target.value)}
placeholder="Es. Food & Ricette, Tech & AI, Fitness…" style={inputStyle} required
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem', marginTop: '0.6rem' }}>
{NICHE_CHIPS.map(n => (
<button key={n} type="button" onClick={() => handleChange('niche', n)} style={{
padding: '0.25rem 0.7rem', fontSize: '0.78rem', fontFamily: "'DM Sans', sans-serif",
border: 'none', cursor: 'pointer',
backgroundColor: form.niche === n ? 'var(--ink)' : 'var(--cream-dark)',
color: form.niche === n ? 'white' : 'var(--ink-muted)',
transition: 'background-color 0.15s',
}}>
{n}
</button>
))}
</div>
</Field>
<Field label="Tono di comunicazione">
<textarea value={form.tone} onChange={(e) => handleChange('tone', e.target.value)}
placeholder="Descrivi lo stile: informale e diretto, professionale e autorevole, ironico e leggero, motivazionale…" 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>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<button type="button" onClick={() => handleChange('is_active', !form.is_active)} style={{
width: 40, height: 22, borderRadius: 11, border: 'none', cursor: 'pointer',
backgroundColor: form.is_active ? 'var(--accent)' : 'var(--border-strong)',
position: 'relative', transition: 'background-color 0.2s', flexShrink: 0,
}}>
<span style={{
position: 'absolute', top: 2, left: form.is_active ? 20 : 2,
width: 18, height: 18, borderRadius: '50%', backgroundColor: 'white', transition: 'left 0.2s',
}} />
</button>
<span style={{ fontSize: '0.875rem', color: 'var(--ink)' }}>Attivo</span>
</div>
</Section>
{/* ── Topic ricorrenti ─────────────────────────────────── */}
<Section title="Topic ricorrenti">
<p style={{ fontSize: '0.82rem', color: 'var(--ink-muted)', margin: '0 0 0.875rem', lineHeight: 1.5 }}>
I topic guidano l'AI nella scelta degli argomenti. Seleziona dai suggerimenti o scrivi i tuoi.
</p>
{/* Suggestion chips */}
<div style={{ marginBottom: '0.875rem' }}>
<p style={{ fontSize: '0.7rem', fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--ink-muted)', margin: '0 0 0.4rem' }}>Suggerimenti</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem' }}>
{TOPIC_CHIPS.map(t => {
const added = form.topics.includes(t)
return (
<button key={t} type="button" onClick={() => added ? removeTopic(t) : addTopic(t)} style={{
padding: '0.25rem 0.7rem', fontSize: '0.78rem', fontFamily: "'DM Sans', sans-serif",
border: 'none', cursor: 'pointer',
backgroundColor: added ? 'var(--accent)' : 'var(--cream-dark)',
color: added ? 'white' : 'var(--ink-muted)',
transition: 'background-color 0.15s',
}}>
{added ? '✓ ' : ''}{t}
</button>
)
})}
</div>
</div>
{/* Manual input */}
<div style={{ display: 'flex', gap: '0.5rem' }}>
<input type="text" value={topicInput} onChange={(e) => setTopicInput(e.target.value)}
onKeyDown={handleTopicKeyDown} placeholder="Aggiungi topic personalizzato…"
style={{ ...inputStyle, flex: 1 }}
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
<button type="button" onClick={() => addTopic()} style={btnSecondary}>Aggiungi</button>
</div>
{form.topics.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem', marginTop: '0.75rem' }}>
{form.topics.map((topic) => (
<span key={topic} style={{
display: 'inline-flex', alignItems: 'center', gap: '0.4rem',
padding: '0.25rem 0.7rem', fontSize: '0.82rem',
backgroundColor: 'var(--ink)', color: 'white',
}}>
{topic}
<button type="button" onClick={() => removeTopic(topic)} style={{
background: 'none', border: 'none', color: 'rgba(255,255,255,0.7)', cursor: 'pointer',
padding: 0, fontSize: '1rem', lineHeight: 1, display: 'flex', alignItems: 'center',
}}>×</button>
</span>
))}
</div>
)}
</Section>
{/* ── Stile visivo ──────────────────────────────────────── */}
<Section title="Stile visivo">
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
<Field label="Colore primario">
<div style={{ display: 'flex', alignItems: 'center', gap: '0.625rem' }}>
<input type="color" value={form.visual_style.primary_color} onChange={(e) => handleStyleChange('primary_color', e.target.value)}
style={{ width: 40, height: 36, border: '1px solid var(--border)', cursor: 'pointer', padding: 0 }} />
<input type="text" value={form.visual_style.primary_color} onChange={(e) => handleStyleChange('primary_color', e.target.value)}
style={{ ...inputStyle, flex: 1, fontFamily: 'monospace' }}
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
</div>
</Field>
<Field label="Colore secondario">
<div style={{ display: 'flex', alignItems: 'center', gap: '0.625rem' }}>
<input type="color" value={form.visual_style.secondary_color} onChange={(e) => handleStyleChange('secondary_color', e.target.value)}
style={{ width: 40, height: 36, border: '1px solid var(--border)', cursor: 'pointer', padding: 0 }} />
<input type="text" value={form.visual_style.secondary_color} onChange={(e) => handleStyleChange('secondary_color', e.target.value)}
style={{ ...inputStyle, flex: 1, fontFamily: 'monospace' }}
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
</div>
</Field>
</div>
<Field label="Font preferito">
<input type="text" value={form.visual_style.font} onChange={(e) => handleStyleChange('font', e.target.value)}
placeholder="Es. Montserrat, Poppins, Inter…" style={inputStyle}
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
</Field>
{/* Anteprima */}
<div style={{ padding: '1rem', border: '1px dashed var(--border)', backgroundColor: 'var(--cream)' }}>
<p style={{ fontSize: '0.7rem', color: 'var(--ink-muted)', margin: '0 0 0.5rem' }}>Anteprima</p>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<div style={{
width: 38, height: 38, borderRadius: '50%', flexShrink: 0,
backgroundColor: form.visual_style.primary_color,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'white', fontWeight: 700, fontSize: '1rem',
}}>
{form.name?.charAt(0)?.toUpperCase() || '?'}
</div>
<div>
<p style={{ fontWeight: 600, fontSize: '0.875rem', color: form.visual_style.secondary_color, margin: '0 0 0.1rem' }}>
{form.name || 'Nome personaggio'}
</p>
<p style={{ fontSize: '0.75rem', color: 'var(--ink-muted)', margin: 0 }}>{form.niche || 'Nicchia'}</p>
</div>
</div>
</div>
</Section>
{/* ── Actions ───────────────────────────────────────────── */}
<div style={{ display: 'flex', gap: '0.75rem' }}>
<button type="submit" disabled={saving} style={{ ...btnPrimary, opacity: saving ? 0.6 : 1 }}>
{saving ? 'Salvataggio…' : isEdit ? 'Salva modifiche' : 'Crea personaggio'}
</button>
<button type="button" onClick={() => navigate('/characters')} style={btnSecondary}>
Annulla
</button>
</div>
</form>
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
</div>
)
}
// ── Shared sub-components ─────────────────────────────────────────────────────
function Section({ title, children }) {
return (
<div style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)', borderTop: '3px solid var(--accent)', padding: '1.5rem' }}>
<p style={{ fontSize: '0.7rem', fontWeight: 700, letterSpacing: '0.1em', textTransform: 'uppercase', color: 'var(--ink-muted)', margin: '0 0 1.25rem' }}>{title}</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
{children}
</div>
</div>
)
}
function Field({ label, children }) {
return (
<div>
<label style={{ display: 'block', fontSize: '0.72rem', fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--ink)', marginBottom: '0.4rem' }}>{label}</label>
{children}
</div>
)
}
const inputStyle = {
width: '100%', padding: '0.625rem 0.875rem',
border: '1px solid var(--border)', borderRadius: 0,
fontSize: '0.875rem', color: 'var(--ink)',
backgroundColor: 'var(--surface)', outline: 'none',
boxSizing: 'border-box', transition: 'border-color 0.15s',
fontFamily: "'DM Sans', sans-serif",
}
const btnPrimary = {
display: 'inline-block', padding: '0.65rem 1.5rem',
backgroundColor: 'var(--ink)', color: 'white',
fontFamily: "'DM Sans', sans-serif", fontWeight: 600,
fontSize: '0.875rem', border: 'none', cursor: 'pointer',
}
const btnSecondary = {
...btnPrimary,
backgroundColor: 'var(--cream-dark)', color: 'var(--ink-light)',
}

View File

@@ -6,16 +6,11 @@ export default function CharacterList() {
const [characters, setCharacters] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
loadCharacters()
}, [])
useEffect(() => { loadCharacters() }, [])
const loadCharacters = () => {
setLoading(true)
api.get('/characters/')
.then(setCharacters)
.catch(() => {})
.finally(() => setLoading(false))
api.get('/characters/').then(setCharacters).catch(() => {}).finally(() => setLoading(false))
}
const handleDelete = async (id, name) => {
@@ -30,132 +25,134 @@ export default function CharacterList() {
}
return (
<div>
<div className="flex items-center justify-between mb-6">
<div style={{ animation: 'fade-up 0.5s ease-out both' }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: '2rem' }}>
<div>
<h2 className="text-2xl font-bold text-slate-800">Personaggi</h2>
<p className="text-slate-500 mt-1 text-sm">
Gestisci i tuoi profili editoriali
<span className="editorial-tag">Personaggi</span>
<div className="editorial-line" />
<h2 style={{ fontFamily: "'Fraunces', serif", fontSize: '1.75rem', fontWeight: 600, letterSpacing: '-0.02em', color: 'var(--ink)', margin: '0.5rem 0 0.3rem' }}>
I tuoi Personaggi
</h2>
<p style={{ fontSize: '0.875rem', color: 'var(--ink-muted)', margin: 0 }}>
Ogni personaggio è una voce editoriale distinta. Definisci tono, pubblico e stile per ogni profilo.
</p>
</div>
<Link
to="/characters/new"
className="px-4 py-2 bg-brand-600 hover:bg-brand-700 text-white text-sm font-medium rounded-lg transition-colors"
>
+ Nuovo
</Link>
<Link to="/characters/new" style={btnPrimary}>+ Crea Personaggio</Link>
</div>
{loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-brand-500 border-t-transparent" />
</div>
<Spinner />
) : characters.length === 0 ? (
<div className="text-center py-16 bg-white rounded-xl border border-slate-200">
<p className="text-4xl mb-3"></p>
<p className="text-slate-500 font-medium">Nessun personaggio</p>
<p className="text-slate-400 text-sm mt-1">
Crea il tuo primo profilo editoriale
</p>
<Link
to="/characters/new"
className="inline-block mt-4 px-4 py-2 bg-brand-600 hover:bg-brand-700 text-white text-sm font-medium rounded-lg transition-colors"
>
+ Crea personaggio
</Link>
</div>
<EmptyState
icon="◎"
title="Nessun personaggio ancora"
description="Il personaggio è il cuore di Leopost: definisce chi parla, come parla e a chi. Creane uno per iniziare a generare contenuti."
cta="+ Crea il tuo primo Personaggio"
to="/characters/new"
/>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '1rem' }}>
{characters.map((c) => (
<div
key={c.id}
className="bg-white rounded-xl border border-slate-200 hover:border-brand-300 transition-all overflow-hidden"
>
{/* Card header with color */}
<div
className="h-2"
style={{
backgroundColor: c.visual_style?.primary_color || '#f97316',
}}
/>
<div className="p-5">
<div className="flex items-start gap-3">
<div
className="w-12 h-12 rounded-full flex items-center justify-center text-white font-bold text-lg shrink-0"
style={{
backgroundColor: c.visual_style?.primary_color || '#f97316',
}}
>
{c.name?.charAt(0).toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<h3 className="font-semibold text-slate-800 truncate">{c.name}</h3>
<p className="text-sm text-slate-500 truncate">{c.niche}</p>
</div>
<span
className={`text-xs px-2 py-0.5 rounded-full shrink-0 ${
c.is_active
? 'bg-emerald-50 text-emerald-600'
: 'bg-slate-100 text-slate-400'
}`}
>
{c.is_active ? 'Attivo' : 'Off'}
</span>
</div>
{/* Topics */}
{c.topics?.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-3">
{c.topics.slice(0, 4).map((t) => (
<span
key={t}
className="text-xs px-2 py-0.5 bg-slate-100 text-slate-600 rounded"
>
{t}
</span>
))}
{c.topics.length > 4 && (
<span className="text-xs text-slate-400">
+{c.topics.length - 4}
</span>
)}
</div>
)}
{/* Tone preview */}
{c.tone && (
<p className="text-xs text-slate-400 mt-3 line-clamp-2 italic">
"{c.tone}"
</p>
)}
{/* Actions */}
<div className="flex items-center gap-2 mt-4 pt-3 border-t border-slate-100">
<Link
to={`/characters/${c.id}/edit`}
className="text-xs px-3 py-1.5 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-lg transition-colors"
>
Modifica
</Link>
<button
onClick={() => handleToggle(c)}
className="text-xs px-3 py-1.5 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-lg transition-colors"
>
{c.is_active ? 'Disattiva' : 'Attiva'}
</button>
<button
onClick={() => handleDelete(c.id, c.name)}
className="text-xs px-3 py-1.5 text-red-500 hover:bg-red-50 rounded-lg transition-colors ml-auto"
>
Elimina
</button>
</div>
</div>
</div>
<CharacterCard key={c.id} character={c} onDelete={handleDelete} onToggle={handleToggle} />
))}
</div>
)}
</div>
)
}
function CharacterCard({ character: c, onDelete, onToggle }) {
const color = c.visual_style?.primary_color || 'var(--accent)'
return (
<div style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)', overflow: 'hidden', transition: 'border-color 0.15s' }}
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--accent)'}
onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border)'}
>
<div style={{ height: 4, backgroundColor: color }} />
<div style={{ padding: '1.25rem' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.75rem', marginBottom: '0.875rem' }}>
<div style={{ width: 44, height: 44, borderRadius: '50%', backgroundColor: color, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'white', fontWeight: 700, fontSize: '1.1rem', flexShrink: 0 }}>
{c.name?.charAt(0).toUpperCase()}
</div>
<div style={{ minWidth: 0, flex: 1 }}>
<h3 style={{ fontWeight: 600, color: 'var(--ink)', margin: '0 0 0.15rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{c.name}</h3>
<p style={{ fontSize: '0.8rem', color: 'var(--ink-muted)', margin: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{c.niche || 'Nessuna nicchia'}</p>
</div>
<span style={{ fontSize: '0.68rem', fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', padding: '0.15rem 0.5rem', backgroundColor: c.is_active ? 'var(--success-light)' : 'var(--border)', color: c.is_active ? 'var(--success)' : 'var(--ink-muted)', flexShrink: 0 }}>
{c.is_active ? 'Attivo' : 'Off'}
</span>
</div>
{c.topics?.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem', marginBottom: '0.75rem' }}>
{c.topics.slice(0, 4).map(t => (
<span key={t} style={{ fontSize: '0.72rem', padding: '0.15rem 0.5rem', backgroundColor: 'var(--cream-dark)', color: 'var(--ink-light)' }}>{t}</span>
))}
{c.topics.length > 4 && <span style={{ fontSize: '0.72rem', color: 'var(--ink-muted)' }}>+{c.topics.length - 4}</span>}
</div>
)}
{c.tone && (
<p style={{ fontSize: '0.78rem', color: 'var(--ink-muted)', fontStyle: 'italic', margin: '0 0 0.875rem', lineHeight: 1.4, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
"{c.tone}"
</p>
)}
<div style={{ display: 'flex', gap: '0.5rem', paddingTop: '0.875rem', borderTop: '1px solid var(--border)' }}>
<Link to={`/characters/${c.id}/edit`} style={btnSmall}>Modifica</Link>
<button onClick={() => onToggle(c)} style={btnSmall}>{c.is_active ? 'Disattiva' : 'Attiva'}</button>
<button onClick={() => onDelete(c.id, c.name)} style={{ ...btnSmall, marginLeft: 'auto', color: 'var(--error)' }}>Elimina</button>
</div>
</div>
</div>
)
}
function EmptyState({ icon, title, description, cta, to }) {
return (
<div style={{ textAlign: 'center', padding: '4rem 2rem', backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}>
<div style={{ fontSize: '2.5rem', marginBottom: '1rem', color: 'var(--accent)' }}>{icon}</div>
<h3 style={{ fontFamily: "'Fraunces', serif", fontSize: '1.2rem', fontWeight: 600, color: 'var(--ink)', margin: '0 0 0.75rem' }}>{title}</h3>
<p style={{ fontSize: '0.875rem', color: 'var(--ink-muted)', maxWidth: 400, margin: '0 auto 1.5rem', lineHeight: 1.6 }}>{description}</p>
<Link to={to} style={btnPrimary}>{cta}</Link>
</div>
)
}
function Spinner() {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem 0' }}>
<div style={{ width: 28, height: 28, border: '2px solid var(--border)', borderTopColor: 'var(--accent)', borderRadius: '50%', animation: 'spin 0.8s linear infinite' }} />
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
</div>
)
}
const btnPrimary = {
display: 'inline-block',
padding: '0.6rem 1.25rem',
backgroundColor: 'var(--ink)',
color: 'white',
fontFamily: "'DM Sans', sans-serif",
fontWeight: 600,
fontSize: '0.875rem',
textDecoration: 'none',
border: 'none',
cursor: 'pointer',
whiteSpace: 'nowrap',
letterSpacing: '0.01em',
}
const btnSmall = {
display: 'inline-block',
padding: '0.35rem 0.75rem',
backgroundColor: 'var(--cream-dark)',
color: 'var(--ink-light)',
fontFamily: "'DM Sans', sans-serif",
fontWeight: 500,
fontSize: '0.78rem',
textDecoration: 'none',
border: 'none',
cursor: 'pointer',
}

View File

@@ -1,27 +1,43 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../api'
const cardStyle = {
backgroundColor: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: '0.75rem',
padding: '1.5rem',
}
const PLATFORMS = [
{ value: 'instagram', label: 'Instagram' },
{ value: 'facebook', label: 'Facebook' },
{ value: 'youtube', label: 'YouTube' },
{ value: 'tiktok', label: 'TikTok' },
]
const inputStyle = {
width: '100%',
padding: '0.625rem 1rem',
border: '1px solid var(--border)',
borderRadius: '0.5rem',
fontSize: '0.875rem',
color: 'var(--ink)',
backgroundColor: 'var(--cream)',
outline: 'none',
const CONTENT_TYPES = [
{ value: 'text', label: 'Testo' },
{ value: 'image', label: 'Immagine' },
{ value: 'video', label: 'Video' },
{ value: 'reel', label: 'Reel/Short' },
{ value: 'story', label: 'Story' },
]
const TECNICHE_NARRATIVE = [
{ value: 'PAS', label: 'PAS', desc: 'Problema → Agitazione → Soluzione' },
{ value: 'AIDA', label: 'AIDA', desc: 'Attenzione → Interesse → Desiderio → Azione' },
{ value: 'Storytelling', label: 'Storytelling', desc: 'Narrazione con arco emotivo' },
{ value: 'Tutorial', label: 'Tutorial', desc: 'Step-by-step educativo' },
{ value: 'Listicle', label: 'Listicle', desc: 'Lista di consigli/esempi' },
{ value: 'Social_proof', label: 'Social Proof', desc: 'Risultati, testimonianze, numeri' },
{ value: 'Hook_domanda', label: 'Hook + Domanda', desc: 'Apertura con domanda provocatoria' },
]
const STATUS_LABELS = { approved: 'Approvato', published: 'Pubblicato', draft: 'Bozza' }
const STATUS_COLORS = {
approved: { bg: 'var(--success-light)', color: 'var(--success)' },
published: { bg: '#EFF6FF', color: '#1D4ED8' },
draft: { bg: '#FFFBEB', color: '#B45309' },
}
export default function ContentPage() {
const [characters, setCharacters] = useState([])
const [loading, setLoading] = useState(false)
const [charsLoading, setCharsLoading] = useState(true)
const [error, setError] = useState('')
const [generated, setGenerated] = useState(null)
const [editing, setEditing] = useState(false)
@@ -29,35 +45,41 @@ export default function ContentPage() {
const [form, setForm] = useState({
character_id: '',
platform: 'instagram',
content_type: 'text',
brief: '',
platforms: ['instagram'],
content_types: ['text'],
topic_hint: '',
tecnica: '',
include_affiliates: false,
})
useEffect(() => {
api.get('/characters/').then(setCharacters).catch(() => {})
api.get('/characters/').then(d => { setCharacters(d); setCharsLoading(false) }).catch(() => setCharsLoading(false))
}, [])
const handleChange = (field, value) => {
setForm((prev) => ({ ...prev, [field]: value }))
const toggleChip = (field, value) => {
setForm(prev => {
const arr = prev[field]
return { ...prev, [field]: arr.includes(value) ? (arr.length > 1 ? arr.filter(v => v !== value) : arr) : [...arr, value] }
})
}
const handleGenerate = async (e) => {
e.preventDefault()
if (!form.character_id) {
setError('Seleziona un personaggio')
return
}
if (!form.character_id) { setError('Seleziona un personaggio'); return }
setError('')
setLoading(true)
setGenerated(null)
try {
const data = await api.post('/content/generate', {
character_id: parseInt(form.character_id),
platform: form.platform,
content_type: form.content_type,
platforms: form.platforms,
content_types: form.content_types,
topic_hint: form.topic_hint || null,
brief: [
form.tecnica ? `Tecnica narrativa: ${form.tecnica}` : '',
form.brief,
].filter(Boolean).join('. ') || null,
include_affiliates: form.include_affiliates,
})
setGenerated(data)
@@ -70,222 +92,247 @@ export default function ContentPage() {
}
const handleApprove = async () => {
if (!generated) return
try {
await api.post(`/content/posts/${generated.id}/approve`)
setGenerated((prev) => ({ ...prev, status: 'approved' }))
} catch (err) {
setError(err.message || 'Errore approvazione')
}
setGenerated(prev => ({ ...prev, status: 'approved' }))
} catch (err) { setError(err.message || 'Errore approvazione') }
}
const handleSaveEdit = async () => {
if (!generated) return
try {
await api.put(`/content/posts/${generated.id}`, { text_content: editText })
setGenerated((prev) => ({ ...prev, text_content: editText }))
setGenerated(prev => ({ ...prev, text_content: editText }))
setEditing(false)
} catch (err) {
setError(err.message || 'Errore salvataggio')
}
} catch (err) { setError(err.message || 'Errore salvataggio') }
}
const handleDelete = async () => {
if (!generated) return
if (!confirm('Eliminare questo contenuto?')) return
try {
await api.delete(`/content/posts/${generated.id}`)
setGenerated(null)
} catch (err) {
setError(err.message || 'Errore eliminazione')
}
}
const platformLabels = {
instagram: 'Instagram',
facebook: 'Facebook',
youtube: 'YouTube',
tiktok: 'TikTok',
}
const contentTypeLabels = {
text: 'Testo',
image: 'Immagine',
video: 'Video',
} catch (err) { setError(err.message || 'Errore eliminazione') }
}
return (
<div>
<div className="mb-6">
<h2 className="text-2xl font-bold" style={{ color: 'var(--ink)' }}>Contenuti</h2>
<p className="mt-1 text-sm" style={{ color: 'var(--muted)' }}>
Genera e gestisci contenuti per i tuoi personaggi
<div style={{ animation: 'fade-up 0.5s ease-out both' }}>
{/* Header */}
<div style={{ marginBottom: '2rem' }}>
<span className="editorial-tag">Contenuti</span>
<div className="editorial-line" />
<h2 style={{ fontFamily: "'Fraunces', serif", fontSize: '1.75rem', fontWeight: 600, letterSpacing: '-0.02em', color: 'var(--ink)', margin: '0.5rem 0 0.3rem' }}>
Genera Contenuti
</h2>
<p style={{ fontSize: '0.875rem', color: 'var(--ink-muted)', margin: 0 }}>
Definisci un brief editoriale, scegli piattaforma e tipo, poi genera. L'AI terrà conto del tono e dei topic del personaggio selezionato.
</p>
</div>
{/* No characters → gate */}
{!charsLoading && characters.length === 0 && (
<div style={{ padding: '3rem 2rem', textAlign: 'center', backgroundColor: 'var(--surface)', border: '1px solid var(--border)', borderTop: '4px solid var(--accent)', marginBottom: '1.5rem' }}>
<div style={{ fontSize: '2rem', color: 'var(--accent)', marginBottom: '1rem' }}>◎</div>
<h3 style={{ fontFamily: "'Fraunces', serif", fontSize: '1.1rem', fontWeight: 600, color: 'var(--ink)', margin: '0 0 0.5rem' }}>Prima crea un Personaggio</h3>
<p style={{ fontSize: '0.875rem', color: 'var(--ink-muted)', maxWidth: 360, margin: '0 auto 1.25rem', lineHeight: 1.6 }}>
Per generare contenuti serve almeno un personaggio che definisca la voce editoriale (tono, nicchia, pubblico).
</p>
<Link to="/characters/new" style={btnPrimary}>Crea il tuo primo Personaggio →</Link>
</div>
)}
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">
<div style={{ padding: '0.75rem 1rem', backgroundColor: 'var(--error-light)', border: '1px solid #FED7D7', color: 'var(--error)', fontSize: '0.875rem', marginBottom: '1rem' }}>
{error}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: '1.25rem' }}>
{/* Generation form */}
<div style={cardStyle}>
<h3 className="font-semibold text-sm uppercase tracking-wider mb-4" style={{ color: 'var(--muted)' }}>
Genera Contenuto
</h3>
<div style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)', borderTop: '4px solid var(--accent)', padding: '1.5rem' }}>
<div style={{ marginBottom: '1.25rem' }}>
<span style={labelStyle}>Genera Contenuto</span>
</div>
<form onSubmit={handleGenerate} className="space-y-4">
<form onSubmit={handleGenerate} style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
{/* Character select */}
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>Personaggio</label>
<select
value={form.character_id}
onChange={(e) => handleChange('character_id', e.target.value)}
style={inputStyle}
required
>
<option value="">Seleziona personaggio...</option>
{characters.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
<label style={labelStyle}>Personaggio</label>
<select value={form.character_id} onChange={e => setForm(p => ({ ...p, character_id: e.target.value }))} style={inputStyle} required>
<option value="">Seleziona personaggio…</option>
{characters.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
{/* Prompt / Brief strategico */}
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>Piattaforma</label>
<select
value={form.platform}
onChange={(e) => handleChange('platform', e.target.value)}
style={inputStyle}
>
{Object.entries(platformLabels).map(([val, label]) => (
<option key={val} value={val}>{label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>Tipo contenuto</label>
<select
value={form.content_type}
onChange={(e) => handleChange('content_type', e.target.value)}
style={inputStyle}
>
{Object.entries(contentTypeLabels).map(([val, label]) => (
<option key={val} value={val}>{label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>
Suggerimento tema <span className="font-normal" style={{ color: 'var(--muted)' }}>(opzionale)</span>
<label style={{ ...labelStyle, marginBottom: '0.5rem' }}>
Prompt & Strategia del Post
</label>
<input
type="text"
value={form.topic_hint}
onChange={(e) => handleChange('topic_hint', e.target.value)}
placeholder="Es. ultimi trend, tutorial..."
style={inputStyle}
/>
{/* Tecnica narrativa chips */}
<div style={{ marginBottom: '0.75rem' }}>
<p style={{ fontSize: '0.7rem', fontWeight: 600, letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--ink-muted)', margin: '0 0 0.4rem' }}>Tecnica narrativa</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem' }}>
{TECNICHE_NARRATIVE.map(t => (
<button key={t.value} type="button" onClick={() => setForm(p => ({ ...p, tecnica: p.tecnica === t.value ? '' : t.value }))} title={t.desc} style={{
padding: '0.3rem 0.75rem', fontSize: '0.78rem', fontFamily: "'DM Sans', sans-serif",
border: 'none', cursor: 'pointer',
backgroundColor: form.tecnica === t.value ? '#1A1A1A' : 'var(--cream-dark)',
color: form.tecnica === t.value ? 'white' : 'var(--ink-light)',
fontWeight: form.tecnica === t.value ? 600 : 400, transition: 'background-color 0.15s',
}}>
{t.label}
</button>
))}
</div>
{form.tecnica && (
<p style={{ fontSize: '0.7rem', color: 'var(--ink-muted)', margin: '0.3rem 0 0' }}>
{TECNICHE_NARRATIVE.find(t => t.value === form.tecnica)?.desc}
</p>
)}
</div>
{/* Brief textarea */}
<textarea value={form.brief} onChange={e => setForm(p => ({ ...p, brief: e.target.value }))}
placeholder="Descrivi in dettaglio cosa vuoi comunicare: obiettivo del post, a chi ti rivolgi, angolo narrativo, call to action, cosa deve provare chi legge.&#10;&#10;Es: 'Tutorial per principianti su sourdough tono incoraggiante, mostra che è più facile del previsto, CTA: salva la ricetta e taggami nel risultato'"
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)'} />
<p style={{ fontSize: '0.72rem', color: 'var(--ink-muted)', margin: '0.3rem 0 0', lineHeight: 1.5 }}>
Più sei specifico, più il contenuto sarà preciso e pubblicabile senza revisioni.
</p>
</div>
<div className="flex items-center gap-3">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={form.include_affiliates}
onChange={(e) => handleChange('include_affiliates', e.target.checked)}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-slate-200 rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-coral"></div>
</label>
<span className="text-sm" style={{ color: 'var(--ink)' }}>Includi link affiliati</span>
{/* Platform chips */}
<div>
<label style={labelStyle}>Piattaforme <span style={{ fontWeight: 400, textTransform: 'none', letterSpacing: 0, color: 'var(--ink-muted)', fontSize: '0.75rem' }}>(seleziona una o più)</span></label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem', marginTop: '0.4rem' }}>
{PLATFORMS.map(p => {
const active = form.platforms.includes(p.value)
return (
<button key={p.value} type="button" onClick={() => toggleChip('platforms', p.value)} style={{
padding: '0.35rem 0.875rem', fontSize: '0.82rem', fontWeight: active ? 600 : 400,
fontFamily: "'DM Sans', sans-serif", border: 'none', cursor: 'pointer',
backgroundColor: active ? 'var(--ink)' : 'var(--cream-dark)',
color: active ? 'white' : 'var(--ink-light)',
transition: 'background-color 0.15s, color 0.15s',
}}>
{p.label}
</button>
)
})}
</div>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-2.5 text-white font-medium rounded-lg transition-opacity text-sm"
style={{ backgroundColor: 'var(--coral)', opacity: loading ? 0.7 : 1 }}
>
{/* Content type chips */}
<div>
<label style={labelStyle}>Tipo di contenuto <span style={{ fontWeight: 400, textTransform: 'none', letterSpacing: 0, color: 'var(--ink-muted)', fontSize: '0.75rem' }}>(seleziona uno o più)</span></label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem', marginTop: '0.4rem' }}>
{CONTENT_TYPES.map(t => {
const active = form.content_types.includes(t.value)
return (
<button key={t.value} type="button" onClick={() => toggleChip('content_types', t.value)} style={{
padding: '0.35rem 0.875rem', fontSize: '0.82rem', fontWeight: active ? 600 : 400,
fontFamily: "'DM Sans', sans-serif", border: 'none', cursor: 'pointer',
backgroundColor: active ? 'var(--accent)' : 'var(--cream-dark)',
color: active ? 'white' : 'var(--ink-light)',
transition: 'background-color 0.15s, color 0.15s',
}}>
{t.label}
</button>
)
})}
</div>
</div>
{/* Topic hint */}
<div>
<label style={labelStyle}>Parola chiave / Topic <span style={{ fontWeight: 400, textTransform: 'none', letterSpacing: 0, color: 'var(--ink-muted)', fontSize: '0.75rem' }}>(opzionale)</span></label>
<input type="text" value={form.topic_hint} onChange={e => setForm(p => ({ ...p, topic_hint: e.target.value }))}
placeholder="Es. ricetta pasta, trend primavera, lancio prodotto…" style={inputStyle}
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
</div>
{/* Affiliates toggle */}
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<button type="button" onClick={() => setForm(p => ({ ...p, include_affiliates: !p.include_affiliates }))} style={{
width: 44, height: 24, borderRadius: 12, border: 'none', cursor: 'pointer',
backgroundColor: form.include_affiliates ? 'var(--accent)' : 'var(--border-strong)',
position: 'relative', transition: 'background-color 0.2s',
flexShrink: 0,
}}>
<span style={{
position: 'absolute', top: 2, left: form.include_affiliates ? 22 : 2,
width: 20, height: 20, borderRadius: '50%', backgroundColor: 'white',
transition: 'left 0.2s',
}} />
</button>
<span style={{ fontSize: '0.875rem', color: 'var(--ink)' }}>Includi link affiliati</span>
</div>
<button type="submit" disabled={loading || characters.length === 0} style={{
...btnPrimary, width: '100%', justifyContent: 'center', display: 'flex', alignItems: 'center', gap: '0.5rem',
opacity: (loading || characters.length === 0) ? 0.6 : 1,
padding: '0.75rem',
}}>
{loading ? (
<span className="flex items-center justify-center gap-2">
<span className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent" />
Generazione in corso...
</span>
) : 'Genera'}
<>
<span style={{ width: 16, height: 16, border: '2px solid rgba(255,255,255,0.3)', borderTopColor: 'white', borderRadius: '50%', animation: 'spin 0.8s linear infinite', display: 'inline-block' }} />
Generazione in corso…
</>
) : ' Genera'}
</button>
</form>
</div>
{/* Preview */}
<div style={cardStyle}>
<h3 className="font-semibold text-sm uppercase tracking-wider mb-4" style={{ color: 'var(--muted)' }}>
Ultimo Contenuto Generato
</h3>
{/* Preview panel */}
<div style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)', borderTop: '4px solid var(--border-strong)', padding: '1.5rem' }}>
<span style={labelStyle}>Contenuto Generato</span>
{loading ? (
<div className="flex flex-col items-center justify-center py-16">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-t-transparent" style={{ borderColor: 'var(--coral)' }} />
<p className="text-sm mt-3" style={{ color: 'var(--muted)' }}>Generazione in corso...</p>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '4rem 0' }}>
<div style={{ width: 32, height: 32, border: '2px solid var(--border)', borderTopColor: 'var(--accent)', borderRadius: '50%', animation: 'spin 0.8s linear infinite', marginBottom: '1rem' }} />
<p style={{ fontSize: '0.875rem', color: 'var(--ink-muted)' }}>Generazione in corso…</p>
</div>
) : generated ? (
<div className="space-y-4">
<div className="flex items-center gap-2">
<span className={`text-xs px-2 py-0.5 rounded-full ${
generated.status === 'approved' ? 'bg-emerald-50 text-emerald-600' :
generated.status === 'published' ? 'bg-blue-50 text-blue-600' :
'bg-amber-50 text-amber-600'
}`}>
{generated.status === 'approved' ? 'Approvato' :
generated.status === 'published' ? 'Pubblicato' : 'Bozza'}
</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-slate-100 text-slate-600">
{platformLabels[generated.platform_hint] || generated.platform_hint}
</span>
<div style={{ marginTop: '1rem' }}>
{/* Status + platform badges */}
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap', marginBottom: '1rem' }}>
{(() => { const sc = STATUS_COLORS[generated.status] || STATUS_COLORS.draft; return (
<span style={{ fontSize: '0.72rem', fontWeight: 700, padding: '0.2rem 0.5rem', backgroundColor: sc.bg, color: sc.color }}>
{STATUS_LABELS[generated.status] || generated.status}
</span>
)})()}
{generated.platform_hint && (
<span style={{ fontSize: '0.72rem', fontWeight: 600, padding: '0.2rem 0.5rem', backgroundColor: 'var(--cream-dark)', color: 'var(--ink-muted)' }}>
{generated.platform_hint}
</span>
)}
</div>
{editing ? (
<div className="space-y-2">
<textarea
value={editText}
onChange={(e) => setEditText(e.target.value)}
rows={8}
className="w-full px-4 py-2.5 rounded-lg text-sm resize-none focus:outline-none"
style={{ border: '1px solid var(--border)', color: 'var(--ink)' }}
/>
<div className="flex gap-2">
<button
onClick={handleSaveEdit}
className="px-3 py-1.5 text-white text-xs rounded-lg"
style={{ backgroundColor: 'var(--coral)' }}
>
Salva
</button>
<button
onClick={() => { setEditing(false); setEditText(generated.text_content || '') }}
className="px-3 py-1.5 bg-slate-100 text-slate-600 text-xs rounded-lg"
>
Annulla
</button>
<div>
<textarea value={editText} onChange={e => setEditText(e.target.value)} rows={8}
style={{ ...inputStyle, resize: 'vertical', lineHeight: 1.6 }}
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.5rem' }}>
<button onClick={handleSaveEdit} style={btnPrimary}>Salva</button>
<button onClick={() => { setEditing(false); setEditText(generated.text_content || '') }} style={btnSecondary}>Annulla</button>
</div>
</div>
) : (
<div className="p-4 rounded-lg" style={{ backgroundColor: 'var(--cream)' }}>
<p className="text-sm whitespace-pre-wrap leading-relaxed" style={{ color: 'var(--ink)' }}>
<div style={{ padding: '1rem', backgroundColor: 'var(--cream)', marginBottom: '1rem' }}>
<p style={{ fontSize: '0.875rem', whiteSpace: 'pre-wrap', lineHeight: 1.7, color: 'var(--ink)', margin: 0 }}>
{generated.text_content}
</p>
</div>
)}
{generated.hashtags && generated.hashtags.length > 0 && (
<div>
<p className="text-xs font-medium mb-1.5" style={{ color: 'var(--muted)' }}>Hashtag</p>
<div className="flex flex-wrap gap-1.5">
{generated.hashtags?.length > 0 && (
<div style={{ marginBottom: '1rem' }}>
<span style={{ ...labelStyle, display: 'block', marginBottom: '0.4rem' }}>Hashtag</span>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem' }}>
{generated.hashtags.map((tag, i) => (
<span key={i} className="text-xs px-2 py-0.5 rounded" style={{ backgroundColor: '#FFF0EC', color: 'var(--coral)' }}>
<span key={i} style={{ fontSize: '0.78rem', padding: '0.15rem 0.5rem', backgroundColor: 'var(--accent-light)', color: 'var(--accent)' }}>
{tag}
</span>
))}
@@ -293,42 +340,49 @@ export default function ContentPage() {
</div>
)}
<div className="flex items-center gap-2 pt-3 border-t" style={{ borderColor: 'var(--border)' }}>
<div style={{ display: 'flex', gap: '0.5rem', paddingTop: '1rem', borderTop: '1px solid var(--border)', flexWrap: 'wrap' }}>
{generated.status !== 'approved' && (
<button
onClick={handleApprove}
className="text-xs px-3 py-1.5 bg-emerald-50 hover:bg-emerald-100 text-emerald-600 rounded-lg transition-colors font-medium"
>
Approva
</button>
<button onClick={handleApprove} style={{ ...btnPrimary, backgroundColor: 'var(--success)' }}>Approva</button>
)}
{!editing && (
<button
onClick={() => setEditing(true)}
className="text-xs px-3 py-1.5 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-lg transition-colors"
>
Modifica
</button>
)}
<button
onClick={handleDelete}
className="text-xs px-3 py-1.5 text-red-500 hover:bg-red-50 rounded-lg transition-colors ml-auto"
>
Elimina
</button>
{!editing && <button onClick={() => setEditing(true)} style={btnSecondary}>Modifica</button>}
<button onClick={handleDelete} style={{ ...btnSecondary, marginLeft: 'auto', color: 'var(--error)' }}>Elimina</button>
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center py-16 text-center">
<p className="text-4xl mb-3"></p>
<p className="font-medium" style={{ color: 'var(--ink)' }}>Nessun contenuto generato</p>
<p className="text-sm mt-1" style={{ color: 'var(--muted)' }}>
Compila il form e clicca "Genera"
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '4rem 1rem', textAlign: 'center' }}>
<div style={{ fontSize: '2.5rem', color: 'var(--border-strong)', marginBottom: '1rem' }}>✦</div>
<p style={{ fontFamily: "'Fraunces', serif", fontSize: '1rem', color: 'var(--ink)', margin: '0 0 0.5rem' }}>Nessun contenuto ancora</p>
<p style={{ fontSize: '0.82rem', color: 'var(--ink-muted)', margin: 0 }}>
Scrivi un brief, seleziona personaggio e piattaforma, poi clicca "Genera"
</p>
</div>
)}
</div>
</div>
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
</div>
)
}
const labelStyle = {
fontSize: '0.72rem', fontWeight: 700, letterSpacing: '0.1em',
textTransform: 'uppercase', color: 'var(--ink)',
}
const inputStyle = {
width: '100%', padding: '0.625rem 0.875rem',
border: '1px solid var(--border)', borderRadius: 0,
fontSize: '0.875rem', color: 'var(--ink)',
backgroundColor: 'var(--surface)', outline: 'none',
boxSizing: 'border-box', transition: 'border-color 0.15s',
fontFamily: "'DM Sans', sans-serif",
}
const btnPrimary = {
display: 'inline-block', padding: '0.55rem 1.1rem',
backgroundColor: 'var(--ink)', color: 'white',
fontFamily: "'DM Sans', sans-serif", fontWeight: 600,
fontSize: '0.875rem', border: 'none', cursor: 'pointer',
}
const btnSecondary = {
...btnPrimary,
backgroundColor: 'var(--cream-dark)', color: 'var(--ink-light)',
}

View File

@@ -0,0 +1,95 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
const STORAGE_KEY = 'leopost_cookie_consent'
function getConsent() {
try { const raw = localStorage.getItem(STORAGE_KEY); return raw ? JSON.parse(raw) : null } catch { return null }
}
function saveConsent(prefs) {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ ...prefs, date: new Date().toISOString() }))
}
export function useCookieConsent() {
const consent = getConsent()
return { analytics: consent?.analytics ?? false, marketing: consent?.marketing ?? false, given: !!consent }
}
export default function CookieBanner() {
const [visible, setVisible] = useState(false)
const [expanded, setExpanded] = useState(false)
const [prefs, setPrefs] = useState({ analytics: false, marketing: false })
useEffect(() => { if (!getConsent()) setVisible(true) }, [])
if (!visible) return null
const acceptAll = () => { saveConsent({ necessary: true, analytics: true, marketing: true }); setVisible(false) }
const acceptNecessary = () => { saveConsent({ necessary: true, analytics: false, marketing: false }); setVisible(false) }
const saveCustom = () => { saveConsent({ necessary: true, ...prefs }); setVisible(false) }
return (
<div style={{
position: 'fixed', bottom: 0, left: 0, right: 0,
backgroundColor: '#1A1A1A', color: '#FFFBF5', zIndex: 9999,
borderTop: '3px solid #E85A4F', fontFamily: "'DM Sans', sans-serif",
paddingBottom: 'env(safe-area-inset-bottom)',
}}>
<div style={{ maxWidth: 960, margin: '0 auto', padding: '1rem' }}>
<div style={{ marginBottom: '0.75rem' }}>
<p style={{ margin: '0 0 0.25rem', fontSize: '0.875rem', fontWeight: 600 }}>
Questo sito utilizza cookie
</p>
<p style={{ margin: 0, fontSize: '0.78rem', color: 'rgba(255,251,245,0.7)', lineHeight: 1.5 }}>
Usiamo cookie tecnici necessari e, previo consenso, cookie analitici.{' '}
<Link to="/cookie" style={{ color: '#E85A4F', textDecoration: 'underline' }}>Cookie Policy</Link>
{' '}&middot;{' '}
<Link to="/privacy" style={{ color: '#E85A4F', textDecoration: 'underline' }}>Privacy</Link>
</p>
</div>
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap', alignItems: 'center' }}>
<button onClick={() => setExpanded(v => !v)} style={btnSecondary}>
{expanded ? 'Chiudi' : 'Gestisci'}
</button>
<button onClick={acceptNecessary} style={btnOutline}>Solo necessari</button>
<button onClick={acceptAll} style={{ ...btnAccept, marginLeft: 'auto' }}>Accetta tutto</button>
</div>
{expanded && (
<div style={{ marginTop: '1rem', paddingTop: '1rem', borderTop: '1px solid rgba(255,251,245,0.15)', display: 'flex', flexDirection: 'column', gap: '0.875rem' }}>
<Toggle label="Cookie necessari" description="Richiesti per il funzionamento del sito. Non disattivabili." checked={true} disabled={true} />
<Toggle label="Cookie analitici" description="Ci aiutano a migliorare il servizio." checked={prefs.analytics} onChange={v => setPrefs(p => ({ ...p, analytics: v }))} />
<Toggle label="Cookie di marketing" description="Per comunicazioni pertinenti e misurazione campagne." checked={prefs.marketing} onChange={v => setPrefs(p => ({ ...p, marketing: v }))} />
<div><button onClick={saveCustom} style={btnAccept}>Salva preferenze</button></div>
</div>
)}
</div>
</div>
)
}
function Toggle({ label, description, checked, disabled, onChange }) {
return (
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '1rem' }}>
<div style={{ minWidth: 0 }}>
<p style={{ margin: '0 0 0.15rem', fontSize: '0.85rem', fontWeight: 600 }}>{label}</p>
<p style={{ margin: 0, fontSize: '0.75rem', color: 'rgba(255,251,245,0.6)', lineHeight: 1.4 }}>{description}</p>
</div>
<button onClick={() => !disabled && onChange?.(!checked)} disabled={disabled} style={{
flexShrink: 0, width: 44, height: 24, borderRadius: 12, border: 'none',
backgroundColor: checked ? '#E85A4F' : 'rgba(255,251,245,0.2)',
cursor: disabled ? 'not-allowed' : 'pointer', position: 'relative', transition: 'background-color 0.2s',
opacity: disabled ? 0.6 : 1,
}}>
<span style={{ position: 'absolute', top: 2, left: checked ? 22 : 2, width: 20, height: 20, borderRadius: '50%', backgroundColor: 'white', transition: 'left 0.2s' }} />
</button>
</div>
)
}
const btnAccept = {
padding: '0.6rem 1.25rem', backgroundColor: '#E85A4F', color: 'white',
border: 'none', borderRadius: 0, fontFamily: "'DM Sans', sans-serif",
fontWeight: 700, fontSize: '0.85rem', cursor: 'pointer', whiteSpace: 'nowrap', minHeight: 44,
}
const btnOutline = {
padding: '0.6rem 1rem', backgroundColor: 'transparent', color: 'rgba(255,251,245,0.85)',
border: '1px solid rgba(255,251,245,0.3)', borderRadius: 0, fontFamily: "'DM Sans', sans-serif",
fontWeight: 500, fontSize: '0.85rem', cursor: 'pointer', whiteSpace: 'nowrap', minHeight: 44,
}
const btnSecondary = { ...btnOutline, color: 'rgba(255,251,245,0.6)', fontSize: '0.78rem' }

View File

@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../api'
import { useAuth } from '../AuthContext'
import PlanBanner from './PlanBanner'
export default function Dashboard() {
const { user, isAdmin } = useAuth()
@@ -57,7 +57,7 @@ export default function Dashboard() {
color: 'var(--ink)',
margin: '0.4rem 0 0.25rem',
}}>
{greeting}{user?.display_name ? `, ${user.display_name}` : ''}
{greeting}{user?.display_name ? `, ${user.display_name.split(' ')[0]}` : ''}
</h2>
<p style={{ fontSize: '0.875rem', color: 'var(--ink-muted)', margin: 0 }}>
Panoramica del tuo studio editoriale AI
@@ -82,7 +82,7 @@ export default function Dashboard() {
)}
</div>
<PlanBanner />
{/* ── Stats grid ──────────────────────────────────────────── */}
<div style={{

View File

@@ -1,40 +1,15 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../api'
import PlanList from './PlanList'
const BASE_URL = '/leopost-full/api'
const cardStyle = {
backgroundColor: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: '0.75rem',
padding: '1.5rem',
}
const inputStyle = {
width: '100%',
padding: '0.625rem 1rem',
border: '1px solid var(--border)',
borderRadius: '0.5rem',
fontSize: '0.875rem',
color: 'var(--ink)',
backgroundColor: 'var(--cream)',
outline: 'none',
}
const AWARENESS_LABELS = {
1: '1 — Unaware',
2: '2 — Problem Aware',
3: '3 — Solution Aware',
4: '4 — Product Aware',
5: '5 — Most Aware',
}
// ─── Constants ────────────────────────────────────────────────────────────────
const FORMATO_COLORS = {
PAS: { bg: '#FFF0EC', color: 'var(--coral)' },
AIDA: { bg: '#EFF6FF', color: '#3B82F6' },
BAB: { bg: '#F0FDF4', color: '#16A34A' },
Storytelling: { bg: '#FDF4FF', color: '#9333EA' },
Listicle: { bg: '#FFFBEB', color: '#D97706' },
PAS: { bg: '#FFF0EC', color: '#E85A4F' },
AIDA: { bg: '#EFF6FF', color: '#3B82F6' },
BAB: { bg: '#F0FDF4', color: '#16A34A' },
Storytelling: { bg: '#FDF4FF', color: '#9333EA' },
Listicle: { bg: '#FFFBEB', color: '#D97706' },
Dato_Implicazione: { bg: '#F0F9FF', color: '#0284C7' },
}
@@ -46,15 +21,233 @@ const AWARENESS_COLORS = {
5: { bg: '#EFF6FF', color: '#2563EB' },
}
const OBIETTIVI = [
{ value: 'awareness', label: 'Awareness', desc: 'Far conoscere il brand/prodotto' },
{ value: 'engagement', label: 'Engagement', desc: 'Generare interazioni e community' },
{ value: 'conversione', label: 'Conversione', desc: 'Portare a un acquisto o iscrizione' },
{ value: 'fidelizzazione', label: 'Fidelizzazione', desc: 'Mantenere e nutrire i clienti esistenti' },
{ value: 'lancio', label: 'Lancio prodotto', desc: 'Supportare il lancio di un nuovo prodotto' },
]
const TECNICHE = [
{ value: 'PAS', label: 'PAS', desc: 'Problema → Agitazione → Soluzione' },
{ value: 'AIDA', label: 'AIDA', desc: 'Attenzione → Interesse → Desiderio → Azione' },
{ value: 'BAB', label: 'BAB', desc: 'Before → After → Bridge' },
{ value: 'Storytelling', label: 'Storytelling', desc: 'Narrazione coinvolgente con arco emotivo' },
{ value: 'Education', label: 'Education', desc: 'Tutorial, how-to, spiegazioni pratiche' },
{ value: 'Social_proof', label: 'Social Proof', desc: 'Testimonianze, risultati, numeri' },
{ value: 'Listicle', label: 'Listicle', desc: 'Liste di consigli, top N, best of' },
{ value: 'Dato_Implicazione', label: 'Dato + Impl.', desc: 'Statistica → cosa significa per te' },
]
// ─── Main component ───────────────────────────────────────────────────────────
export default function EditorialCalendar() {
const [tab, setTab] = useState('calendario')
const [strategy, setStrategy] = useState({
obiettivo: '',
tecnica: '',
brief: '',
})
return (
<div style={{ animation: 'fade-up 0.5s ease-out both' }}>
{/* Header */}
<div style={{ marginBottom: '1.75rem' }}>
<span className="editorial-tag">Pianificazione</span>
<div className="editorial-line" />
<h2 style={{ fontFamily: "'Fraunces', serif", fontSize: '1.75rem', fontWeight: 600, letterSpacing: '-0.02em', color: 'var(--ink)', margin: '0.5rem 0 0.3rem' }}>
Calendario Editoriale
</h2>
<p style={{ fontSize: '0.875rem', color: 'var(--ink-muted)', margin: 0 }}>
Definisci prima la strategia obiettivo, tecnica e brief. Poi genera un calendario di idee o configura i piani di pubblicazione automatica.
</p>
</div>
{/* ── Strategia condivisa ────────────────────────────────────── */}
<StrategiaCard strategy={strategy} setStrategy={setStrategy} />
{/* Tabs */}
<div style={{ display: 'flex', borderBottom: '2px solid var(--border)', marginBottom: '2rem', gap: 0 }}>
{[
{ id: 'calendario', label: 'Idee & Calendario AI', desc: 'Genera idee per i prossimi post' },
{ id: 'piani', label: 'Piani di Pubblicazione', desc: 'Schedulazione automatica' },
].map(t => (
<button key={t.id} onClick={() => setTab(t.id)} style={{
padding: '0.75rem 1.5rem',
fontFamily: "'DM Sans', sans-serif",
fontSize: '0.875rem',
fontWeight: tab === t.id ? 700 : 400,
color: tab === t.id ? 'var(--accent)' : 'var(--ink-muted)',
backgroundColor: 'transparent',
border: 'none',
borderBottom: tab === t.id ? '2px solid var(--accent)' : '2px solid transparent',
marginBottom: -2,
cursor: 'pointer',
transition: 'color 0.15s',
}}>
{t.label}
</button>
))}
</div>
{tab === 'calendario' && <CalendarioTab strategy={strategy} />}
{tab === 'piani' && <PianiTab />}
</div>
)
}
// ─── Strategia condivisa ──────────────────────────────────────────────────────
function StrategiaCard({ strategy, setStrategy }) {
const [expanded, setExpanded] = useState(true)
return (
<div style={{ marginBottom: '1.5rem', backgroundColor: 'var(--surface)', border: '1px solid var(--border)', borderTop: '4px solid var(--accent)', overflow: 'hidden' }}>
<button
onClick={() => setExpanded(v => !v)}
style={{
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '1rem 1.5rem', background: 'none', border: 'none', cursor: 'pointer',
fontFamily: "'DM Sans', sans-serif",
}}
>
<div style={{ textAlign: 'left' }}>
<span style={{ fontSize: '0.7rem', fontWeight: 700, letterSpacing: '0.1em', textTransform: 'uppercase', color: 'var(--accent)' }}>
Strategia Editoriale
</span>
<p style={{ fontSize: '0.78rem', color: 'var(--ink-muted)', margin: '0.15rem 0 0' }}>
{strategy.brief
? `${strategy.obiettivo ? `[${strategy.obiettivo.toUpperCase()}] ` : ''}${strategy.brief.slice(0, 80)}${strategy.brief.length > 80 ? '…' : ''}`
: 'Definisci obiettivo, tecnica e brief — l\'AI li userà in tutto il calendario'}
</p>
</div>
<span style={{ fontSize: '0.75rem', color: 'var(--ink-muted)', flexShrink: 0, marginLeft: '1rem' }}>
{expanded ? '▲ Comprimi' : '▼ Espandi'}
</span>
</button>
{expanded && (
<div style={{ padding: '0 1.5rem 1.5rem', borderTop: '1px solid var(--border)' }}>
<p style={{ fontSize: '0.8rem', color: 'var(--ink-muted)', margin: '1rem 0 1.25rem', lineHeight: 1.6 }}>
Questa strategia viene usata come contesto da <strong>entrambe le tab</strong>. Definisci obiettivo e tecnica principale, poi descrivi il piano nel brief.
</p>
{/* Obiettivo */}
<div style={{ marginBottom: '1rem' }}>
<label style={labelStyle}>
1. Obiettivo della campagna
</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem', marginTop: '0.5rem' }}>
{OBIETTIVI.map(o => (
<button
key={o.value}
type="button"
onClick={() => setStrategy(p => ({ ...p, obiettivo: p.obiettivo === o.value ? '' : o.value }))}
title={o.desc}
style={{
padding: '0.35rem 0.875rem', fontSize: '0.82rem',
fontFamily: "'DM Sans', sans-serif", border: 'none', cursor: 'pointer',
backgroundColor: strategy.obiettivo === o.value ? 'var(--accent)' : 'var(--cream-dark)',
color: strategy.obiettivo === o.value ? 'white' : 'var(--ink-light)',
fontWeight: strategy.obiettivo === o.value ? 600 : 400,
transition: 'background-color 0.15s',
}}
>
{o.label}
</button>
))}
</div>
{strategy.obiettivo && (
<p style={{ fontSize: '0.72rem', color: 'var(--ink-muted)', margin: '0.35rem 0 0' }}>
{OBIETTIVI.find(o => o.value === strategy.obiettivo)?.desc}
</p>
)}
</div>
{/* Tecnica */}
<div style={{ marginBottom: '1rem' }}>
<label style={labelStyle}>
2. Tecnica narrativa principale
</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem', marginTop: '0.5rem' }}>
{TECNICHE.map(t => (
<button
key={t.value}
type="button"
onClick={() => setStrategy(p => ({ ...p, tecnica: p.tecnica === t.value ? '' : t.value }))}
title={t.desc}
style={{
padding: '0.35rem 0.875rem', fontSize: '0.82rem',
fontFamily: "'DM Sans', sans-serif", border: 'none', cursor: 'pointer',
backgroundColor: strategy.tecnica === t.value ? '#1A1A1A' : 'var(--cream-dark)',
color: strategy.tecnica === t.value ? 'white' : 'var(--ink-light)',
fontWeight: strategy.tecnica === t.value ? 600 : 400,
transition: 'background-color 0.15s',
}}
>
{t.label}
</button>
))}
</div>
{strategy.tecnica && (
<p style={{ fontSize: '0.72rem', color: 'var(--ink-muted)', margin: '0.35rem 0 0' }}>
{TECNICHE.find(t => t.value === strategy.tecnica)?.desc}
</p>
)}
</div>
{/* Brief */}
<div>
<label style={labelStyle}>
3. Brief strategico{' '}
<span style={{ fontWeight: 400, textTransform: 'none', letterSpacing: 0, color: 'var(--ink-muted)', fontSize: '0.75rem' }}>(consigliato)</span>
</label>
<textarea
value={strategy.brief}
onChange={e => setStrategy(p => ({ ...p, brief: e.target.value }))}
placeholder="Descrivi la strategia in dettaglio: a chi ti rivolgi, quale offerta stai comunicando, in quale fase del funnel si trovano gli utenti, tone of voice specifico per questa campagna, cosa vuoi che facciano dopo aver letto il contenuto.&#10;&#10;Es: 'Campagna per food blogger principianti, obiettivo vendere il corso online. Pubblico: 25-40 anni, appassionati di cucina ma frustrati dai risultati. Tono incoraggiante e pratico. CTA finale: link al corso con early bird -30%.'"
rows={5}
style={{ ...inputStyle, resize: 'vertical', lineHeight: 1.6, marginTop: '0.5rem' }}
onFocus={e => e.target.style.borderColor = 'var(--ink)'}
onBlur={e => e.target.style.borderColor = 'var(--border)'}
/>
</div>
</div>
)}
</div>
)
}
// ─── Tab: Piani di Pubblicazione ──────────────────────────────────────────────
function PianiTab() {
return (
<div>
<div style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)', borderLeft: '4px solid #3B82F6', padding: '1rem 1.5rem', marginBottom: '1.5rem' }}>
<p style={{ fontSize: '0.7rem', fontWeight: 700, letterSpacing: '0.1em', textTransform: 'uppercase', color: '#3B82F6', margin: '0 0 0.4rem' }}>
Come funzionano i Piani
</p>
<p style={{ fontSize: '0.875rem', color: 'var(--ink)', margin: 0, lineHeight: 1.6 }}>
I piani di pubblicazione definiscono <strong>con quale frequenza e su quali piattaforme</strong> un personaggio pubblica automaticamente.
A differenza del Calendario AI (che genera idee), i piani <strong>eseguono la pubblicazione ricorrente</strong> secondo gli orari configurati.
La strategia editoriale qui sopra guida il tono e il contenuto dei post generati automaticamente.
</p>
</div>
<PlanList />
</div>
)
}
// ─── Tab: Calendario AI ───────────────────────────────────────────────────────
function CalendarioTab({ strategy }) {
const [formats, setFormats] = useState([])
const [awarenessLevels, setAwarenessLevels] = useState([])
const [characters, setCharacters] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [calendar, setCalendar] = useState(null)
const [exporting, setExporting] = useState(false)
const [form, setForm] = useState({
character_id: '',
topics: '',
format_narrativo: '',
awareness_level: '',
@@ -63,341 +256,243 @@ export default function EditorialCalendar() {
})
useEffect(() => {
api.get('/editorial/formats')
.then((data) => {
setFormats(data.formats || [])
setAwarenessLevels(data.awareness_levels || [])
})
.catch(() => {})
api.get('/editorial/formats').then(data => {
setFormats(data.formats || [])
setAwarenessLevels(data.awareness_levels || [])
}).catch(() => {})
api.get('/characters/').then(setCharacters).catch(() => {})
}, [])
const handleChange = (field, value) => {
setForm((prev) => ({ ...prev, [field]: value }))
const buildBrief = () => {
const parts = []
if (strategy.obiettivo) parts.push(`Obiettivo: ${strategy.obiettivo}`)
if (strategy.tecnica) parts.push(`Tecnica principale: ${strategy.tecnica}`)
if (strategy.brief) parts.push(strategy.brief)
return parts.join('. ')
}
const handleGenerate = async (e) => {
e.preventDefault()
const topicsList = form.topics
.split(',')
.map((t) => t.trim())
.filter(Boolean)
if (topicsList.length === 0) {
setError('Inserisci almeno un topic/keyword')
return
}
const topicsList = form.topics.split(',').map(t => t.trim()).filter(Boolean)
if (topicsList.length === 0) { setError('Inserisci almeno un topic/keyword'); return }
setError('')
setLoading(true)
setCalendar(null)
try {
const payload = {
topics: topicsList,
num_posts: parseInt(form.num_posts) || 7,
start_date: form.start_date || null,
}
const brief = buildBrief()
if (brief) payload.brief = brief
if (form.format_narrativo) payload.format_narrativo = form.format_narrativo
if (form.awareness_level) payload.awareness_level = parseInt(form.awareness_level)
if (form.character_id) payload.character_id = parseInt(form.character_id)
const data = await api.post('/editorial/generate-calendar', payload)
setCalendar(data)
} catch (err) {
setError(err.message || 'Errore nella generazione del calendario')
} finally {
setLoading(false)
}
} finally { setLoading(false) }
}
const handleExportCsv = async () => {
if (!calendar?.slots?.length) return
setExporting(true)
try {
const token = localStorage.getItem('token')
const res = await fetch(`${BASE_URL}/editorial/export-csv`, {
const res = await fetch('/api/editorial/export-csv', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
body: JSON.stringify({ slots: calendar.slots }),
})
if (!res.ok) throw new Error('Export fallito')
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'calendario_editoriale.csv'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch (err) {
setError(err.message || 'Errore nell\'export CSV')
} finally {
setExporting(false)
}
a.href = url; a.download = 'calendario_editoriale.csv'
document.body.appendChild(a); a.click()
document.body.removeChild(a); URL.revokeObjectURL(url)
} catch (err) { setError(err.message || 'Errore export CSV')
} finally { setExporting(false) }
}
const strategySet = strategy.obiettivo || strategy.tecnica || strategy.brief
return (
<div>
<div className="mb-6">
<h2 className="text-2xl font-bold" style={{ color: 'var(--ink)' }}>
Calendario Editoriale AI
</h2>
<p className="mt-1 text-sm" style={{ color: 'var(--muted)' }}>
Genera un piano editoriale con format narrativi e awareness levels (Schwartz)
</p>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(280px, 320px) 1fr', gap: '1.5rem', alignItems: 'flex-start' }}>
{/* Form */}
<div style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)', borderTop: '4px solid var(--accent)', padding: '1.5rem' }}>
<span style={labelStyle}>Parametri del Calendario</span>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">
{error}
</div>
)}
{/* Strategy inherited indicator */}
{strategySet && (
<div style={{ marginTop: '0.75rem', padding: '0.6rem 0.875rem', backgroundColor: '#F0FDF4', border: '1px solid #A7F3D0', fontSize: '0.78rem', color: '#065F46' }}>
Strategia editoriale definita viene inclusa nel brief inviato all'AI
</div>
)}
{!strategySet && (
<div style={{ marginTop: '0.75rem', padding: '0.6rem 0.875rem', backgroundColor: '#FFFBEB', border: '1px solid #FDE68A', fontSize: '0.78rem', color: '#92400E' }}>
⚠ Nessuna strategia definita — compila la sezione qui sopra per risultati migliori
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Form */}
<div style={cardStyle}>
<h3 className="font-semibold text-sm uppercase tracking-wider mb-4" style={{ color: 'var(--muted)' }}>
Parametri
</h3>
{error && <div style={{ padding: '0.625rem', backgroundColor: 'var(--error-light)', color: 'var(--error)', fontSize: '0.82rem', margin: '0.75rem 0', border: '1px solid #FED7D7' }}>{error}</div>}
<form onSubmit={handleGenerate} className="space-y-4">
<form onSubmit={handleGenerate} style={{ display: 'flex', flexDirection: 'column', gap: '1rem', marginTop: '1rem' }}>
{/* Character */}
{characters.length > 0 && (
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>
Topics / Keywords
</label>
<textarea
value={form.topics}
onChange={(e) => handleChange('topics', e.target.value)}
placeholder="Es. marketing digitale, social media, content strategy"
rows={3}
style={{ ...inputStyle, resize: 'vertical' }}
/>
<p className="text-xs mt-1" style={{ color: 'var(--muted)' }}>
Separati da virgola
</p>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>
Formato Narrativo
<span className="font-normal ml-1" style={{ color: 'var(--muted)' }}>(opzionale)</span>
</label>
<select
value={form.format_narrativo}
onChange={(e) => handleChange('format_narrativo', e.target.value)}
style={inputStyle}
>
<option value="">Distribuzione automatica</option>
{formats.map((f) => (
<option key={f.value} value={f.value}>
{f.label}
</option>
))}
<label style={labelStyle}>Personaggio <small style={{ fontWeight: 400, textTransform: 'none', letterSpacing: 0, color: 'var(--ink-muted)' }}>(opzionale)</small></label>
<select value={form.character_id} onChange={e => setForm(p => ({ ...p, character_id: e.target.value }))} style={inputStyle}>
<option value="">Nessuno (generico)</option>
{characters.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>
Awareness Level
<span className="font-normal ml-1" style={{ color: 'var(--muted)' }}>(opzionale)</span>
</label>
<select
value={form.awareness_level}
onChange={(e) => handleChange('awareness_level', e.target.value)}
style={inputStyle}
>
<option value="">Distribuzione automatica</option>
{awarenessLevels.map((l) => (
<option key={l.value} value={l.value}>
{l.value} {l.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>
Numero di post
</label>
<input
type="number"
min={1}
max={30}
value={form.num_posts}
onChange={(e) => handleChange('num_posts', e.target.value)}
style={inputStyle}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>
Data di inizio
</label>
<input
type="date"
value={form.start_date}
onChange={(e) => handleChange('start_date', e.target.value)}
style={inputStyle}
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-2.5 text-white font-medium rounded-lg transition-opacity text-sm"
style={{ backgroundColor: 'var(--coral)', opacity: loading ? 0.7 : 1 }}
>
{loading ? (
<span className="flex items-center justify-center gap-2">
<span className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent" />
Generazione...
</span>
) : 'Genera Calendario'}
</button>
</form>
</div>
{/* Results */}
<div className="lg:col-span-2">
{calendar ? (
<div>
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="font-semibold text-sm uppercase tracking-wider" style={{ color: 'var(--muted)' }}>
Calendario Generato
</h3>
<p className="text-xs mt-0.5" style={{ color: 'var(--muted)' }}>
{calendar.totale_post} post pianificati
</p>
</div>
<button
onClick={handleExportCsv}
disabled={exporting}
className="px-4 py-2 text-sm font-medium rounded-lg transition-opacity"
style={{
backgroundColor: 'var(--ink)',
color: '#fff',
opacity: exporting ? 0.7 : 1,
}}
>
{exporting ? 'Export...' : 'Esporta CSV per Canva'}
</button>
</div>
<div className="space-y-3">
{calendar.slots.map((slot) => {
const fmtColor = FORMATO_COLORS[slot.formato_narrativo] || { bg: '#F8F8F8', color: 'var(--ink)' }
const awColor = AWARENESS_COLORS[slot.awareness_level] || { bg: '#F8F8F8', color: 'var(--ink)' }
return (
<div
key={slot.indice}
className="flex gap-4 p-4 rounded-xl"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
>
{/* Index */}
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold shrink-0"
style={{ backgroundColor: 'var(--coral)', color: '#fff' }}
>
{slot.indice + 1}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2 mb-2">
{/* Date */}
<span
className="text-xs font-medium px-2 py-0.5 rounded"
style={{ backgroundColor: 'var(--cream)', color: 'var(--muted)', border: '1px solid var(--border)' }}
>
{new Date(slot.data_pubblicazione).toLocaleDateString('it-IT', {
weekday: 'short', day: '2-digit', month: 'short'
})}
</span>
{/* Format */}
<span
className="text-xs font-medium px-2 py-0.5 rounded"
style={{ backgroundColor: fmtColor.bg, color: fmtColor.color }}
>
{slot.formato_narrativo.replace('_', ' ')}
</span>
{/* Awareness */}
<span
className="text-xs font-medium px-2 py-0.5 rounded"
style={{ backgroundColor: awColor.bg, color: awColor.color }}
>
L{slot.awareness_level} {slot.awareness_label}
</span>
</div>
{/* Topic */}
<p className="text-sm font-medium" style={{ color: 'var(--ink)' }}>
{slot.topic}
</p>
{/* Note */}
{slot.note && (
<p className="text-xs mt-1" style={{ color: 'var(--muted)' }}>
{slot.note}
</p>
)}
</div>
</div>
)
})}
</div>
</div>
) : (
<div
className="flex flex-col items-center justify-center py-20 rounded-xl text-center"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
>
<p className="text-5xl mb-4"></p>
<p className="font-semibold text-lg font-serif" style={{ color: 'var(--ink)' }}>
Nessun calendario generato
</p>
<p className="text-sm mt-2 max-w-xs" style={{ color: 'var(--muted)' }}>
Inserisci i topic e scegli le impostazioni, poi clicca "Genera Calendario"
</p>
{/* Info boxes */}
<div className="grid grid-cols-2 gap-3 mt-8 text-left max-w-sm">
<InfoBox title="Format narrativi" items={['PAS', 'AIDA', 'BAB', 'Storytelling', 'Listicle', 'Dato Implicazione']} />
<InfoBox title="Awareness levels" items={['1 — Unaware', '2 — Problem', '3 — Solution', '4 — Product', '5 — Most Aware']} />
</div>
</div>
)}
</div>
<div>
<label style={labelStyle}>Topics / Keywords *</label>
<textarea value={form.topics} onChange={e => setForm(p => ({ ...p, topics: e.target.value }))}
placeholder="Es. social media marketing, content strategy, algoritmo Instagram" rows={2}
style={{ ...inputStyle, resize: 'vertical' }}
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
<p style={{ fontSize: '0.75rem', color: 'var(--ink-muted)', margin: '0.25rem 0 0' }}>Separati da virgola. Questi definiscono gli argomenti specifici da trattare.</p>
</div>
<div>
<label style={labelStyle}>Formato Narrativo <small style={{ fontWeight: 400, textTransform: 'none', letterSpacing: 0, color: 'var(--ink-muted)' }}>(opzionale)</small></label>
<select value={form.format_narrativo} onChange={e => setForm(p => ({ ...p, format_narrativo: e.target.value }))} style={inputStyle}>
<option value="">Mix automatico (consigliato)</option>
{formats.map(f => <option key={f.value} value={f.value}>{f.label}</option>)}
</select>
</div>
<div>
<label style={labelStyle}>Awareness Level <small style={{ fontWeight: 400, textTransform: 'none', letterSpacing: 0, color: 'var(--ink-muted)' }}>(opzionale)</small></label>
<select value={form.awareness_level} onChange={e => setForm(p => ({ ...p, awareness_level: e.target.value }))} style={inputStyle}>
<option value="">Mix automatico (consigliato)</option>
{awarenessLevels.map(l => <option key={l.value} value={l.value}>{l.value} — {l.label}</option>)}
</select>
<p style={{ fontSize: '0.72rem', color: 'var(--ink-muted)', margin: '0.25rem 0 0', lineHeight: 1.4 }}>
Scala di Schwartz: 1 (Unaware) → 5 (Most Aware)
</p>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
<div>
<label style={labelStyle}>N. post (130)</label>
<input type="number" min={1} max={30} value={form.num_posts} onChange={e => setForm(p => ({ ...p, num_posts: e.target.value }))} style={inputStyle}
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
</div>
<div>
<label style={labelStyle}>Data inizio</label>
<input type="date" value={form.start_date} onChange={e => setForm(p => ({ ...p, start_date: e.target.value }))} style={inputStyle}
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
</div>
</div>
<button type="submit" disabled={loading} style={{ ...btnPrimary, width: '100%', justifyContent: 'center', display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.75rem', opacity: loading ? 0.6 : 1 }}>
{loading ? (
<>
<span style={{ width: 14, height: 14, border: '2px solid rgba(255,255,255,0.4)', borderTopColor: 'white', borderRadius: '50%', animation: 'spin 0.8s linear infinite', display: 'inline-block' }} />
Generazione in corso…
</>
) : ' Genera Calendario AI'}
</button>
</form>
</div>
{/* Results */}
<div>
{calendar ? (
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.25rem', flexWrap: 'wrap', gap: '0.75rem' }}>
<div>
<span style={labelStyle}>Calendario Generato</span>
<p style={{ fontSize: '0.82rem', color: 'var(--ink-muted)', margin: '0.2rem 0 0' }}>{calendar.totale_post} post pianificati</p>
</div>
<button onClick={handleExportCsv} disabled={exporting} style={{ ...btnPrimary, opacity: exporting ? 0.6 : 1 }}>
{exporting ? 'Export' : 'Esporta CSV per Canva'}
</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{calendar.slots.map((slot) => {
const fc = FORMATO_COLORS[slot.formato_narrativo] || { bg: 'var(--cream-dark)', color: 'var(--ink-muted)' }
const ac = AWARENESS_COLORS[slot.awareness_level] || { bg: 'var(--cream-dark)', color: 'var(--ink-muted)' }
return (
<div key={slot.indice} style={{ display: 'flex', gap: '1rem', padding: '1rem 1.25rem', backgroundColor: 'var(--surface)', border: '1px solid var(--border)', alignItems: 'flex-start' }}>
<div style={{ width: 28, height: 28, borderRadius: '50%', backgroundColor: 'var(--accent)', color: 'white', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '0.75rem', fontWeight: 700, flexShrink: 0 }}>
{slot.indice + 1}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem', marginBottom: '0.5rem' }}>
<span style={{ fontSize: '0.72rem', fontWeight: 600, padding: '0.15rem 0.5rem', backgroundColor: 'var(--cream-dark)', color: 'var(--ink-muted)' }}>
{new Date(slot.data_pubblicazione).toLocaleDateString('it-IT', { weekday: 'short', day: '2-digit', month: 'short' })}
</span>
<span style={{ fontSize: '0.72rem', fontWeight: 600, padding: '0.15rem 0.5rem', backgroundColor: fc.bg, color: fc.color }}>
{slot.formato_narrativo.replace('_', ' ')}
</span>
<span style={{ fontSize: '0.72rem', fontWeight: 600, padding: '0.15rem 0.5rem', backgroundColor: ac.bg, color: ac.color }}>
L{slot.awareness_level} — {slot.awareness_label}
</span>
</div>
<p style={{ fontSize: '0.875rem', fontWeight: 500, color: 'var(--ink)', margin: '0 0 0.2rem' }}>{slot.topic}</p>
{slot.note && <p style={{ fontSize: '0.78rem', color: 'var(--ink-muted)', margin: 0 }}>{slot.note}</p>}
</div>
</div>
)
})}
</div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '4rem 2rem', backgroundColor: 'var(--surface)', border: '1px solid var(--border)', textAlign: 'center' }}>
<div style={{ fontSize: '3rem', color: 'var(--border-strong)', marginBottom: '1rem' }}>◰</div>
<h3 style={{ fontFamily: "'Fraunces', serif", fontSize: '1.1rem', color: 'var(--ink)', margin: '0 0 0.5rem' }}>Nessun calendario generato</h3>
<p style={{ fontSize: '0.85rem', color: 'var(--ink-muted)', maxWidth: 380, margin: '0 0 2rem', lineHeight: 1.6 }}>
Definisci la strategia qui sopra, aggiungi i topic e genera. L'AI creerà un piano con format narrativi e livelli di awareness calibrati sulla tua strategia.
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem', width: '100%', maxWidth: 420, textAlign: 'left' }}>
<InfoBox title="Format narrativi" items={['PAS — Problema/Soluzione', 'AIDA — Funnel classico', 'BAB — Trasformazione', 'Storytelling', 'Listicle', 'Dato + Implicazione']} />
<InfoBox title="Awareness levels" items={['1 — Unaware', '2 — Problem aware', '3 — Solution aware', '4 — Product aware', '5 — Most Aware']} />
</div>
</div>
)}
</div>
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
</div>
)
}
function InfoBox({ title, items }) {
return (
<div
className="p-3 rounded-lg"
style={{ backgroundColor: 'var(--cream)', border: '1px solid var(--border)' }}
>
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--muted)' }}>
{title}
</p>
<ul className="space-y-1">
{items.map((item) => (
<li key={item} className="text-xs" style={{ color: 'var(--ink)' }}>
{item}
</li>
))}
<div style={{ padding: '0.875rem', backgroundColor: 'var(--cream)', border: '1px solid var(--border)' }}>
<p style={{ fontSize: '0.68rem', fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--ink-muted)', margin: '0 0 0.5rem' }}>{title}</p>
<ul style={{ margin: 0, padding: 0, listStyle: 'none', display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
{items.map(item => <li key={item} style={{ fontSize: '0.78rem', color: 'var(--ink)' }}>{item}</li>)}
</ul>
</div>
)
}
const labelStyle = {
fontSize: '0.72rem', fontWeight: 700, letterSpacing: '0.1em',
textTransform: 'uppercase', color: 'var(--ink)',
display: 'block',
}
const inputStyle = {
width: '100%', padding: '0.625rem 0.875rem',
border: '1px solid var(--border)', borderRadius: 0,
fontSize: '0.875rem', color: 'var(--ink)',
backgroundColor: 'var(--surface)', outline: 'none',
boxSizing: 'border-box', transition: 'border-color 0.15s',
fontFamily: "'DM Sans', sans-serif", marginTop: '0.4rem',
}
const btnPrimary = {
display: 'inline-block', padding: '0.6rem 1.25rem',
backgroundColor: '#1A1A1A', color: 'white',
fontFamily: "'DM Sans', sans-serif", fontWeight: 600,
fontSize: '0.875rem', border: 'none', cursor: 'pointer', whiteSpace: 'nowrap',
}

View File

@@ -1,46 +1,64 @@
import { NavLink, Outlet } from 'react-router-dom'
import { NavLink, Outlet, Link, useNavigate } from 'react-router-dom'
import { useState, useEffect } from 'react'
import BetaBanner from './BetaBanner'
import PlanBanner from './PlanBanner'
import OnboardingWizard, { useOnboarding } from './OnboardingWizard'
import { useAuth } from '../AuthContext'
const nav = [
{ to: '/', label: 'Dashboard', icon: '◉' },
{ to: '/characters',label: 'Personaggi', icon: '◎' },
{ to: '/content', label: 'Contenuti', icon: '✦' },
{ to: '/affiliates',label: 'Link Affiliati', icon: '⟁' },
{ to: '/plans', label: 'Piano Editoriale', icon: '▦' },
{ to: '/schedule', label: 'Schedulazione', icon: '◈' },
{ to: '/social', label: 'Social', icon: '◇' },
{ to: '/comments', label: 'Commenti', icon: '◌' },
{ to: '/editorial', label: 'Calendario AI', icon: '◰' },
{ to: '/settings', label: 'Impostazioni', icon: '⚙' },
{ to: '/', label: 'Dashboard' },
{ to: '/characters',label: 'Personaggi' },
{ to: '/content', label: 'Contenuti' },
{ to: '/affiliates',label: 'Link Affiliati' },
{ to: '/editorial', label: 'Pianificazione' },
{ to: '/schedule', label: 'Schedulazione' },
{ to: '/social', label: 'Social' },
{ to: '/comments', label: 'Commenti' },
]
export default function Layout() {
const { user, logout, isPro, isAdmin } = useAuth()
const navigate = useNavigate()
const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768)
const [sidebarOpen, setSidebarOpen] = useState(false)
const onboardingDone = useOnboarding(user?.id)
const [showOnboarding, setShowOnboarding] = useState(!onboardingDone)
useEffect(() => {
const onResize = () => setIsMobile(window.innerWidth < 768)
window.addEventListener('resize', onResize)
return () => window.removeEventListener('resize', onResize)
}, [])
return (
<div style={{ minHeight: '100vh', display: 'flex', backgroundColor: 'var(--cream)' }}>
<div style={{ minHeight: '100dvh', display: 'flex', flexDirection: isMobile ? 'column' : 'row', backgroundColor: 'var(--cream)' }}>
{/* Onboarding wizard — full viewport overlay */}
{showOnboarding && (
<OnboardingWizard onClose={() => setShowOnboarding(false)} userId={user?.id} />
)}
{/* ── Sidebar ─────────────────────────────────────────────── */}
{isMobile && sidebarOpen && (
<div onClick={() => setSidebarOpen(false)} style={{
position: 'fixed', inset: 0, backgroundColor: 'rgba(26,26,26,0.5)', zIndex: 200,
}} />
)}
<aside style={{
width: 240,
width: 220,
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
backgroundColor: 'var(--cream-dark)',
borderRight: '1px solid var(--border)',
...(isMobile ? {
position: 'fixed', top: 0, left: sidebarOpen ? 0 : -220,
height: '100dvh', zIndex: 300, transition: 'left 0.25s ease',
} : {}),
}}>
{/* Logo */}
<div style={{
padding: '1.5rem 1.25rem 1.25rem',
borderBottom: '1px solid var(--border)',
}}>
<h1 style={{
fontFamily: "'Fraunces', serif",
fontWeight: 700,
fontSize: '1.4rem',
letterSpacing: '-0.02em',
color: 'var(--ink)',
margin: 0,
}}>
<div style={{ padding: '1.5rem 1.25rem 1.25rem', borderBottom: '1px solid var(--border)' }}>
<h1 style={{ fontFamily: "'Fraunces', serif", fontWeight: 700, fontSize: '1.4rem', letterSpacing: '-0.02em', color: 'var(--ink)', margin: 0 }}>
Leopost
</h1>
<div style={{ width: 40, height: 3, backgroundColor: 'var(--accent)', marginTop: '0.4rem' }} />
@@ -51,16 +69,15 @@ export default function Layout() {
{/* Nav */}
<nav style={{ flex: 1, padding: '0.75rem 0.5rem', overflowY: 'auto' }}>
{nav.map(({ to, label, icon }) => (
{nav.map(({ to, label }) => (
<NavLink
key={to}
to={to}
end={to === '/'}
onClick={() => isMobile && setSidebarOpen(false)}
style={({ isActive }) => ({
display: 'flex',
alignItems: 'center',
gap: '0.625rem',
padding: '0.625rem 0.875rem',
display: 'block',
padding: '0.6rem 0.875rem',
fontSize: '0.84rem',
fontWeight: isActive ? 600 : 400,
color: isActive ? 'var(--accent)' : 'var(--ink-light)',
@@ -83,82 +100,143 @@ export default function Layout() {
}
}}
>
<span style={{ fontSize: '0.95rem', width: 18, textAlign: 'center', flexShrink: 0 }}>{icon}</span>
{label}
</NavLink>
))}
</nav>
{/* User footer */}
<div style={{
padding: '1rem 1.25rem',
borderTop: '1px solid var(--border)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.625rem', marginBottom: '0.5rem' }}>
{/* User card + logout */}
<div style={{ borderTop: '1px solid var(--border)' }}>
{/* Admin link */}
{isAdmin && (
<div style={{ padding: '0.5rem 1.25rem', borderBottom: '1px solid var(--border)' }}>
<NavLink
to="/admin"
onClick={() => isMobile && setSidebarOpen(false)}
style={{ fontSize: '0.72rem', color: '#D97706', fontWeight: 600, textDecoration: 'none' }}
>
Pannello Admin
</NavLink>
</div>
)}
{/* Clickable user card → /settings */}
<button
onClick={() => { navigate('/settings'); isMobile && setSidebarOpen(false) }}
style={{
width: '100%', display: 'flex', alignItems: 'center', gap: '0.75rem',
padding: '0.875rem 1.25rem', background: 'none', border: 'none',
cursor: 'pointer', textAlign: 'left',
transition: 'background-color 0.15s',
}}
onMouseEnter={e => e.currentTarget.style.backgroundColor = 'var(--border)'}
onMouseLeave={e => e.currentTarget.style.backgroundColor = 'transparent'}
title="Vai alle Impostazioni"
>
<div style={{
width: 32,
height: 32,
borderRadius: '50%',
width: 34, height: 34, borderRadius: '50%',
backgroundColor: 'var(--accent)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontWeight: 700,
fontSize: '0.85rem',
flexShrink: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'white', fontWeight: 700, fontSize: '0.9rem', flexShrink: 0,
}}>
{(user?.display_name || user?.username || '?')[0].toUpperCase()}
</div>
<div style={{ overflow: 'hidden' }}>
<p style={{ fontSize: '0.8rem', fontWeight: 600, color: 'var(--ink)', margin: 0, truncate: true, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{user?.display_name || user?.username}
<div style={{ minWidth: 0, flex: 1 }}>
<p style={{ fontSize: '0.8rem', fontWeight: 600, color: 'var(--ink)', margin: '0 0 0.15rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{user?.display_name?.split(' ')[0] || user?.username}
</p>
<span style={{
display: 'inline-block',
fontSize: '0.65rem',
fontWeight: 700,
letterSpacing: '0.08em',
textTransform: 'uppercase',
padding: '0.1rem 0.4rem',
display: 'inline-block', fontSize: '0.62rem', fontWeight: 700,
letterSpacing: '0.08em', textTransform: 'uppercase',
padding: '0.1rem 0.35rem',
backgroundColor: isPro ? 'var(--success)' : 'var(--border-strong)',
color: isPro ? 'white' : 'var(--ink-muted)',
}}>
{isPro ? 'PRO' : 'FREEMIUM'}
</span>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
{isAdmin && (
<NavLink to="/admin" style={{ fontSize: '0.72rem', color: '#D97706', fontWeight: 600, textDecoration: 'none' }}>
Admin
</NavLink>
)}
<button
onClick={logout}
style={{
marginLeft: 'auto',
background: 'none',
border: 'none',
fontSize: '0.75rem',
color: 'var(--ink-muted)',
cursor: 'pointer',
padding: 0,
fontFamily: "'DM Sans', sans-serif",
}}
>
Logout
</button>
</div>
<span style={{ fontSize: '0.65rem', color: 'var(--ink-muted)', flexShrink: 0 }}></span>
</button>
{/* Logout — ben visibile */}
<button
onClick={logout}
style={{
width: '100%', padding: '0.7rem 1.25rem',
background: '#FFF0EE', border: 'none', borderTop: '1px solid var(--border)',
fontFamily: "'DM Sans', sans-serif", fontSize: '0.82rem', fontWeight: 700,
color: 'var(--accent)', cursor: 'pointer', textAlign: 'left',
transition: 'background-color 0.15s, color 0.15s',
letterSpacing: '0.02em',
}}
onMouseEnter={e => { e.currentTarget.style.backgroundColor = 'var(--accent)'; e.currentTarget.style.color = 'white' }}
onMouseLeave={e => { e.currentTarget.style.backgroundColor = '#FFF0EE'; e.currentTarget.style.color = 'var(--accent)' }}
>
Esci
</button>
</div>
</aside>
{/* ── Main content ──────────────────────────────────────── */}
<main style={{ flex: 1, overflowY: 'auto' }}>
<div style={{ maxWidth: 1100, margin: '0 auto', padding: '2rem 2.5rem' }}>
<Outlet />
</div>
</main>
{/* ── Right column ─────────────────────────────── */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
{/* Mobile top bar */}
{isMobile && (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0 1rem', borderBottom: '1px solid var(--border)',
backgroundColor: 'var(--cream-dark)', flexShrink: 0,
position: 'sticky', top: 0, zIndex: 100,
height: 56,
}}>
<h1 style={{ fontFamily: "'Fraunces', serif", fontWeight: 700, fontSize: '1.2rem', color: 'var(--ink)', margin: 0 }}>
Leopost
</h1>
<button
onClick={() => setSidebarOpen(v => !v)}
style={{
background: 'none', border: '1px solid var(--border)',
cursor: 'pointer', fontSize: '1rem', color: 'var(--ink)', borderRadius: 0,
width: 44, height: 44, display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
aria-label="Apri menu"
>
</button>
</div>
)}
<BetaBanner />
<PlanBanner />
{/* Main content */}
<main style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
<div style={{ flex: 1, maxWidth: 1100, margin: '0 auto', width: '100%', padding: isMobile ? '1.25rem 1rem' : '2rem 2.5rem' }}>
<Outlet />
</div>
<footer style={{
borderTop: '1px solid var(--border)', padding: '1rem 1.5rem',
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
flexWrap: 'wrap', gap: '0.5rem', backgroundColor: 'var(--cream)',
}}>
<p style={{ fontSize: '0.72rem', color: 'var(--ink-muted)', margin: 0 }}>
© {new Date().getFullYear()} Leopost
</p>
<div style={{ display: 'flex', gap: '1.25rem' }}>
{[{ to: '/privacy', label: 'Privacy' }, { to: '/termini', label: 'Termini' }, { to: '/cookie', label: 'Cookie' }].map(({ to, label }) => (
<Link
key={to}
to={to}
style={{ fontSize: '0.72rem', color: 'var(--ink-muted)', textDecoration: 'none' }}
onMouseEnter={e => e.target.style.color = 'var(--accent)'}
onMouseLeave={e => e.target.style.color = 'var(--ink-muted)'}
>
{label}
</Link>
))}
</div>
</footer>
</main>
</div>
</div>
)
}

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { useState, useEffect } from 'react'
import { useNavigate, Link, useSearchParams } from 'react-router-dom'
import { useAuth } from '../AuthContext'
import { BASE_URL } from '../api'
@@ -7,7 +7,7 @@ export default function LoginPage() {
const [mode, setMode] = useState('login') // 'login' | 'register'
return (
<div style={{ minHeight: '100vh', display: 'flex', fontFamily: "'DM Sans', sans-serif" }}>
<div style={{ minHeight: '100dvh', display: 'flex', fontFamily: "'DM Sans', sans-serif" }}>
{/* ── LEFT — dark ink branding panel ─────────────────────── */}
<div style={{
@@ -145,6 +145,13 @@ function LoginForm({ onSwitchMode }) {
const { login } = useAuth()
const navigate = useNavigate()
const [searchParams] = useSearchParams()
// Show OAuth error if redirected from callback
useEffect(() => {
const oauthErr = searchParams.get('error')
if (oauthErr) setError(oauthErr)
}, [])
const handleSubmit = async (e) => {
e.preventDefault()

View File

@@ -0,0 +1,194 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
const ONBOARDING_KEY = 'leopost_onboarding_done'
export function useOnboarding(userId) {
const key = userId ? `${ONBOARDING_KEY}_${userId}` : ONBOARDING_KEY
try { return !!localStorage.getItem(key) } catch { return true }
}
const STEPS = [
{
id: 'welcome',
title: 'Benvenuto in Leopost',
subtitle: 'Il tuo studio editoriale AI',
body: 'Leopost ti aiuta a creare, pianificare e pubblicare contenuti sui social in modo intelligente e coerente. In 3 minuti sarai operativo.',
cta: 'Iniziamo',
icon: '◉',
},
{
id: 'character',
title: 'Crea il tuo Personaggio',
subtitle: 'La voce dei tuoi contenuti',
body: 'Un Personaggio è il profilo editoriale da cui generi i contenuti: nome, bio, tono di voce, pubblico target. Puoi averne più di uno per brand o progetto diverso.',
cta: 'Ho capito',
icon: '◎',
action: { label: 'Crea subito un Personaggio', path: '/characters/new' },
},
{
id: 'redeem',
title: 'Attiva il tuo Piano Pro',
subtitle: 'Early Adopter — 30 giorni gratis',
body: 'Hai un codice di accesso anticipato? Inseriscilo nelle Impostazioni per sbloccare tutte le funzionalità Pro gratuitamente per 30 giorni.',
cta: 'Ho capito',
icon: '✦',
action: { label: 'Vai alle Impostazioni', path: '/settings' },
},
{
id: 'done',
title: 'Tutto pronto',
subtitle: 'Buon lavoro!',
body: 'Sei sulla Dashboard. Da qui puoi vedere le statistiche, creare contenuti e gestire i tuoi social. Se hai dubbi, usa il link di supporto in basso a sinistra.',
cta: 'Vai alla Dashboard',
icon: '▦',
},
]
export default function OnboardingWizard({ onClose, userId: onUserId }) {
const [step, setStep] = useState(0)
const navigate = useNavigate()
const current = STEPS[step]
const isLast = step === STEPS.length - 1
const finish = () => {
try {
const key = onUserId ? `${ONBOARDING_KEY}_${onUserId}` : ONBOARDING_KEY
localStorage.setItem(key, '1')
} catch {}
onClose()
}
const handleCta = () => {
if (isLast) { finish(); return }
setStep(s => s + 1)
}
const handleAction = () => {
finish()
navigate(current.action.path)
}
return (
<div style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(26,26,26,0.7)',
zIndex: 10000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '1rem',
fontFamily: "'DM Sans', sans-serif",
}}>
<div style={{
backgroundColor: '#FFFBF5',
width: '100%',
maxWidth: 520,
borderTop: '4px solid #E85A4F',
padding: '2.5rem 2rem 2rem',
position: 'relative',
}}>
{/* Step dots */}
<div style={{ display: 'flex', gap: '0.4rem', marginBottom: '2rem' }}>
{STEPS.map((_, i) => (
<div key={i} style={{
width: i === step ? 24 : 8,
height: 4,
backgroundColor: i <= step ? '#E85A4F' : '#E5E0D8',
transition: 'width 0.25s, background-color 0.25s',
}} />
))}
</div>
{/* Icon */}
<div style={{
width: 48,
height: 48,
backgroundColor: '#F5F0E8',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '1.4rem',
marginBottom: '1.25rem',
color: '#E85A4F',
}}>
{current.icon}
</div>
{/* Content */}
<span style={{
fontSize: '0.65rem',
fontWeight: 700,
letterSpacing: '0.12em',
textTransform: 'uppercase',
color: '#E85A4F',
}}>
{current.subtitle}
</span>
<h2 style={{
fontFamily: "'Fraunces', serif",
fontSize: '1.5rem',
fontWeight: 600,
color: '#1A1A1A',
letterSpacing: '-0.02em',
margin: '0.5rem 0 1rem',
}}>
{current.title}
</h2>
<p style={{ fontSize: '0.9rem', color: '#4A4A4A', lineHeight: 1.7, margin: '0 0 1.75rem' }}>
{current.body}
</p>
{/* Actions */}
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<button onClick={handleCta} style={{
padding: '0.65rem 1.5rem',
backgroundColor: '#1A1A1A',
color: 'white',
border: 'none',
borderRadius: 0,
fontFamily: "'DM Sans', sans-serif",
fontWeight: 600,
fontSize: '0.875rem',
cursor: 'pointer',
}}>
{current.cta}
</button>
{current.action && (
<button onClick={handleAction} style={{
padding: '0.65rem 1.5rem',
backgroundColor: 'transparent',
color: '#E85A4F',
border: '1px solid #E85A4F',
borderRadius: 0,
fontFamily: "'DM Sans', sans-serif",
fontWeight: 500,
fontSize: '0.875rem',
cursor: 'pointer',
}}>
{current.action.label}
</button>
)}
</div>
{/* Skip */}
{!isLast && (
<button onClick={finish} style={{
position: 'absolute',
top: '1.25rem',
right: '1.25rem',
background: 'none',
border: 'none',
fontSize: '0.75rem',
color: '#9A9A9A',
cursor: 'pointer',
fontFamily: "'DM Sans', sans-serif",
}}>
Salta
</button>
)}
</div>
</div>
)
}

View File

@@ -12,40 +12,37 @@ export default function PlanBanner() {
const expires = user.subscription_expires_at
? new Date(user.subscription_expires_at).toLocaleDateString('it-IT')
: null
// PRO users: minimal bar, non invasivo
return (
<div style={{
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
padding: '0.75rem 1rem',
gap: '0.5rem',
padding: '0.4rem 1.5rem',
backgroundColor: 'var(--success-light, #F0F9F4)',
border: '1px solid #A7F3D0',
marginBottom: '1.5rem',
borderBottom: '1px solid #A7F3D0',
fontSize: '0.72rem',
fontWeight: 600,
letterSpacing: '0.06em',
textTransform: 'uppercase',
color: 'var(--success)',
}}>
<div style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: 'var(--success)', flexShrink: 0 }} />
<div>
<span style={{
fontSize: '0.75rem',
fontWeight: 700,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: 'var(--success)',
}}>
Piano Pro
<span style={{ width: 6, height: 6, borderRadius: '50%', backgroundColor: 'var(--success)', flexShrink: 0 }} />
Piano Pro
{expires && (
<span style={{ fontWeight: 400, letterSpacing: 0, textTransform: 'none', opacity: 0.75 }}>
Attivo fino al {expires}
</span>
{expires && (
<span style={{ fontSize: '0.78rem', color: 'var(--success)', marginLeft: '0.5rem', opacity: 0.8 }}>
Attivo fino al {expires}
</span>
)}
</div>
)}
</div>
)
}
// Freemium users: usage bar con CTA
const postsUsed = user.posts_generated_this_month || 0
const postsMax = 15
const pct = Math.min(100, (postsUsed / postsMax) * 100)
const critical = pct >= 80
return (
<>
@@ -53,31 +50,27 @@ export default function PlanBanner() {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0.75rem 1rem',
backgroundColor: 'var(--accent-light)',
border: '1px solid #FECCC8',
marginBottom: '1.5rem',
padding: '0.55rem 1.5rem',
backgroundColor: critical ? '#FFF5F3' : 'var(--cream-dark)',
borderBottom: `1px solid ${critical ? '#FECCC8' : 'var(--border)'}`,
flexWrap: 'wrap',
gap: '0.5rem',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap', minWidth: 0 }}>
<span style={{
fontSize: '0.72rem',
fontWeight: 700,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: 'var(--accent)',
fontSize: '0.68rem', fontWeight: 700, letterSpacing: '0.1em', textTransform: 'uppercase',
color: critical ? 'var(--accent)' : 'var(--ink-muted)',
flexShrink: 0,
}}>
Freemium
</span>
<span style={{ fontSize: '0.8rem', color: 'var(--ink-light)' }}>
{postsUsed} / {postsMax} post questo mese
<span style={{ fontSize: '0.78rem', color: 'var(--ink-muted)', flexShrink: 0 }}>
{postsUsed}/{postsMax} post
</span>
<div style={{ width: 80, height: 4, backgroundColor: '#FECCC8', overflow: 'hidden' }}>
<div style={{ width: 72, height: 4, backgroundColor: '#E5E0D8', overflow: 'hidden', flexShrink: 0 }}>
<div style={{
height: '100%',
width: `${pct}%`,
backgroundColor: 'var(--accent)',
height: '100%', width: `${pct}%`,
backgroundColor: critical ? 'var(--accent)' : 'var(--border-strong)',
transition: 'width 0.6s ease',
}} />
</div>
@@ -86,20 +79,16 @@ export default function PlanBanner() {
<button
onClick={() => setShowUpgrade(true)}
style={{
padding: '0.4rem 0.9rem',
backgroundColor: 'var(--ink)',
color: 'white',
border: 'none',
borderRadius: 0,
cursor: 'pointer',
padding: '0.3rem 0.75rem',
backgroundColor: 'var(--ink)', color: 'white',
border: 'none', cursor: 'pointer',
fontFamily: "'DM Sans', sans-serif",
fontWeight: 600,
fontSize: '0.78rem',
fontWeight: 600, fontSize: '0.75rem',
letterSpacing: '0.03em',
transition: 'background-color 0.2s, transform 0.15s',
flexShrink: 0,
}}
onMouseEnter={(e) => { e.target.style.backgroundColor = 'var(--accent)'; e.target.style.transform = 'translateY(-1px)' }}
onMouseLeave={(e) => { e.target.style.backgroundColor = 'var(--ink)'; e.target.style.transform = 'translateY(0)' }}
onMouseEnter={e => e.currentTarget.style.backgroundColor = 'var(--accent)'}
onMouseLeave={e => e.currentTarget.style.backgroundColor = 'var(--ink)'}
>
Passa a Pro
</button>

View File

@@ -3,21 +3,21 @@ import { useNavigate, useParams } from 'react-router-dom'
import { api } from '../api'
const FREQUENCY_OPTIONS = [
{ value: 'daily', label: 'Giornaliero' },
{ value: 'daily', label: 'Giornaliero' },
{ value: 'twice_daily', label: 'Due volte al giorno' },
{ value: 'weekly', label: 'Settimanale' },
{ value: 'custom', label: 'Personalizzato' },
{ value: 'weekly', label: 'Settimanale' },
{ value: 'custom', label: 'Personalizzato' },
]
const PLATFORM_OPTIONS = [
{ value: 'instagram', label: 'Instagram' },
{ value: 'facebook', label: 'Facebook' },
{ value: 'youtube', label: 'YouTube' },
{ value: 'tiktok', label: 'TikTok' },
{ value: 'facebook', label: 'Facebook' },
{ value: 'youtube', label: 'YouTube' },
{ value: 'tiktok', label: 'TikTok' },
]
const CONTENT_TYPE_OPTIONS = [
{ value: 'text', label: 'Testo' },
{ value: 'text', label: 'Testo' },
{ value: 'image', label: 'Immagine' },
{ value: 'video', label: 'Video' },
]
@@ -46,11 +46,7 @@ export default function PlanForm() {
const [error, setError] = useState('')
const [loading, setLoading] = useState(isEdit)
useEffect(() => {
api.get('/characters/')
.then(setCharacters)
.catch(() => {})
}, [])
useEffect(() => { api.get('/characters/').then(setCharacters).catch(() => {}) }, [])
useEffect(() => {
if (isEdit) {
@@ -63,9 +59,7 @@ export default function PlanForm() {
posts_per_day: data.posts_per_day || 1,
platforms: data.platforms || [],
content_types: data.content_types || [],
posting_times: data.posting_times && data.posting_times.length > 0
? data.posting_times
: ['09:00'],
posting_times: data.posting_times?.length > 0 ? data.posting_times : ['09:00'],
start_date: data.start_date ? data.start_date.split('T')[0] : '',
end_date: data.end_date ? data.end_date.split('T')[0] : '',
is_active: data.is_active ?? true,
@@ -76,61 +70,25 @@ export default function PlanForm() {
}
}, [id, isEdit])
const handleChange = (field, value) => {
setForm((prev) => ({ ...prev, [field]: value }))
}
const handleChange = (field, value) => setForm((prev) => ({ ...prev, [field]: value }))
const toggleArrayItem = (field, value) => {
setForm((prev) => {
const arr = prev[field] || []
return {
...prev,
[field]: arr.includes(value)
? arr.filter((v) => v !== value)
: [...arr, value],
}
return { ...prev, [field]: arr.includes(value) ? arr.filter((v) => v !== value) : [...arr, value] }
})
}
const addPostingTime = () => {
setForm((prev) => ({
...prev,
posting_times: [...prev.posting_times, '12:00'],
}))
}
const updatePostingTime = (index, value) => {
setForm((prev) => {
const times = [...prev.posting_times]
times[index] = value
return { ...prev, posting_times: times }
})
}
const removePostingTime = (index) => {
setForm((prev) => ({
...prev,
posting_times: prev.posting_times.filter((_, i) => i !== index),
}))
}
const addPostingTime = () => setForm((prev) => ({ ...prev, posting_times: [...prev.posting_times, '12:00'] }))
const updatePostingTime = (index, value) => setForm((prev) => { const t = [...prev.posting_times]; t[index] = value; return { ...prev, posting_times: t } })
const removePostingTime = (index) => setForm((prev) => ({ ...prev, posting_times: prev.posting_times.filter((_, i) => i !== index) }))
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
if (!form.character_id) {
setError('Seleziona un personaggio')
return
}
if (form.platforms.length === 0) {
setError('Seleziona almeno una piattaforma')
return
}
if (form.content_types.length === 0) {
setError('Seleziona almeno un tipo di contenuto')
return
}
if (!form.character_id) { setError('Seleziona un personaggio'); return }
if (form.platforms.length === 0) { setError('Seleziona almeno una piattaforma'); return }
if (form.content_types.length === 0) { setError('Seleziona almeno un tipo di contenuto'); return }
setSaving(true)
try {
const payload = {
@@ -140,269 +98,222 @@ export default function PlanForm() {
start_date: form.start_date || null,
end_date: form.end_date || null,
}
if (isEdit) {
await api.put(`/plans/${id}`, payload)
} else {
await api.post('/plans/', payload)
}
if (isEdit) await api.put(`/plans/${id}`, payload)
else await api.post('/plans/', payload)
navigate('/plans')
} catch (err) {
setError(err.message || 'Errore nel salvataggio')
} finally {
setSaving(false)
}
} finally { setSaving(false) }
}
if (loading) {
return (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-brand-500 border-t-transparent" />
</div>
)
}
if (loading) return (
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem 0' }}>
<div style={{ width: 28, height: 28, border: '2px solid var(--border)', borderTopColor: 'var(--accent)', borderRadius: '50%', animation: 'spin 0.8s linear infinite' }} />
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
</div>
)
return (
<div>
<div className="mb-6">
<h2 className="text-2xl font-bold text-slate-800">
<div style={{ animation: 'fade-up 0.5s ease-out both' }}>
{/* Header */}
<div style={{ marginBottom: '2rem' }}>
<span className="editorial-tag">{isEdit ? 'Modifica Piano' : 'Nuovo Piano'}</span>
<div className="editorial-line" />
<h2 style={{ fontFamily: "'Fraunces', serif", fontSize: '1.75rem', fontWeight: 600, letterSpacing: '-0.02em', color: 'var(--ink)', margin: '0.5rem 0 0.3rem' }}>
{isEdit ? 'Modifica piano editoriale' : 'Nuovo piano editoriale'}
</h2>
<p className="text-slate-500 mt-1 text-sm">
{isEdit ? 'Aggiorna la configurazione del piano' : 'Configura un nuovo piano di pubblicazione'}
<p style={{ fontSize: '0.875rem', color: 'var(--ink-muted)', margin: 0 }}>
Configura un piano di pubblicazione automatica per un personaggio specifico.
</p>
</div>
<form onSubmit={handleSubmit} className="max-w-2xl space-y-6">
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">
{error}
</div>
)}
{/* Basic info */}
<div className="bg-white rounded-xl border border-slate-200 p-6 space-y-4">
<h3 className="font-semibold text-slate-700 text-sm uppercase tracking-wider">
Informazioni base
</h3>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Personaggio
</label>
<select
value={form.character_id}
onChange={(e) => handleChange('character_id', e.target.value)}
className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm bg-white"
required
>
<option value="">Seleziona personaggio...</option>
{characters.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Nome piano
</label>
<input
type="text"
value={form.name}
onChange={(e) => handleChange('name', e.target.value)}
placeholder="Es. Piano Instagram Giornaliero..."
className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm"
required
/>
</div>
<div className="flex items-center gap-3">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={form.is_active}
onChange={(e) => handleChange('is_active', e.target.checked)}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-slate-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-brand-500"></div>
</label>
<span className="text-sm text-slate-700">Attivo</span>
</div>
{error && (
<div style={{ padding: '0.75rem 1rem', backgroundColor: 'var(--error-light)', border: '1px solid #FED7D7', color: 'var(--error)', fontSize: '0.875rem', marginBottom: '1.25rem' }}>
{error}
</div>
)}
{/* Frequency */}
<div className="bg-white rounded-xl border border-slate-200 p-6 space-y-4">
<h3 className="font-semibold text-slate-700 text-sm uppercase tracking-wider">
Frequenza pubblicazione
</h3>
<form onSubmit={handleSubmit} style={{ maxWidth: 680, display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Frequenza
</label>
<select
value={form.frequency}
onChange={(e) => handleChange('frequency', e.target.value)}
className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm bg-white"
>
{FREQUENCY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
{/* ── Informazioni base ─────────────────────────────────── */}
<Section title="Informazioni base">
<Field label="Personaggio">
<select value={form.character_id} onChange={(e) => handleChange('character_id', e.target.value)} style={selectStyle} required>
<option value="">Seleziona personaggio</option>
{characters.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</Field>
<Field label="Nome piano">
<input type="text" value={form.name} onChange={(e) => handleChange('name', e.target.value)}
placeholder="Es. Piano Instagram Giornaliero…" style={inputStyle} required
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
</Field>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<button type="button" onClick={() => handleChange('is_active', !form.is_active)} style={{
width: 40, height: 22, borderRadius: 11, border: 'none', cursor: 'pointer',
backgroundColor: form.is_active ? 'var(--accent)' : 'var(--border-strong)',
position: 'relative', transition: 'background-color 0.2s', flexShrink: 0,
}}>
<span style={{
position: 'absolute', top: 2, left: form.is_active ? 20 : 2,
width: 18, height: 18, borderRadius: '50%', backgroundColor: 'white', transition: 'left 0.2s',
}} />
</button>
<span style={{ fontSize: '0.875rem', color: 'var(--ink)' }}>Attivo</span>
</div>
</Section>
{/* ── Frequenza ─────────────────────────────────────────── */}
<Section title="Frequenza di pubblicazione">
<Field label="Frequenza">
<select value={form.frequency} onChange={(e) => handleChange('frequency', e.target.value)} style={selectStyle}>
{FREQUENCY_OPTIONS.map((opt) => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
</select>
</Field>
{form.frequency === 'custom' && (
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Post al giorno
</label>
<input
type="number"
min="1"
max="20"
value={form.posts_per_day}
<Field label="Post al giorno">
<input type="number" min="1" max="20" value={form.posts_per_day}
onChange={(e) => handleChange('posts_per_day', e.target.value)}
className="w-32 px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm"
/>
</div>
style={{ ...inputStyle, width: 100 }}
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
</Field>
)}
</div>
</Section>
{/* Platforms & Content Types */}
<div className="bg-white rounded-xl border border-slate-200 p-6 space-y-4">
<h3 className="font-semibold text-slate-700 text-sm uppercase tracking-wider">
Piattaforme e tipi di contenuto
</h3>
{/* ── Piattaforme e tipi ────────────────────────────────── */}
<Section title="Piattaforme e tipi di contenuto">
<Field label="Piattaforme">
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem', marginTop: '0.25rem' }}>
{PLATFORM_OPTIONS.map((opt) => {
const active = form.platforms.includes(opt.value)
return (
<button key={opt.value} type="button" onClick={() => toggleArrayItem('platforms', opt.value)} style={{
padding: '0.35rem 0.875rem', fontSize: '0.82rem', fontFamily: "'DM Sans', sans-serif",
border: 'none', cursor: 'pointer',
backgroundColor: active ? 'var(--ink)' : 'var(--cream-dark)',
color: active ? 'white' : 'var(--ink-muted)',
transition: 'background-color 0.15s',
}}>
{active ? '✓ ' : ''}{opt.label}
</button>
)
})}
</div>
</Field>
<Field label="Tipi di contenuto">
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem', marginTop: '0.25rem' }}>
{CONTENT_TYPE_OPTIONS.map((opt) => {
const active = form.content_types.includes(opt.value)
return (
<button key={opt.value} type="button" onClick={() => toggleArrayItem('content_types', opt.value)} style={{
padding: '0.35rem 0.875rem', fontSize: '0.82rem', fontFamily: "'DM Sans', sans-serif",
border: 'none', cursor: 'pointer',
backgroundColor: active ? 'var(--accent)' : 'var(--cream-dark)',
color: active ? 'white' : 'var(--ink-muted)',
transition: 'background-color 0.15s',
}}>
{active ? '✓ ' : ''}{opt.label}
</button>
)
})}
</div>
</Field>
</Section>
{/* ── Orari ─────────────────────────────────────────────── */}
<Section title="Orari di pubblicazione">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Piattaforme
</label>
<div className="flex flex-wrap gap-3">
{PLATFORM_OPTIONS.map((opt) => (
<label key={opt.value} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={form.platforms.includes(opt.value)}
onChange={() => toggleArrayItem('platforms', opt.value)}
className="w-4 h-4 rounded border-slate-300 text-brand-600 focus:ring-brand-500"
/>
<span className="text-sm text-slate-700">{opt.label}</span>
</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{form.posting_times.map((time, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<input type="time" value={time} onChange={(e) => updatePostingTime(i, e.target.value)}
style={{ ...inputStyle, width: 'auto' }}
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
{form.posting_times.length > 1 && (
<button type="button" onClick={() => removePostingTime(i)} style={{
padding: '0.35rem 0.75rem', fontSize: '0.78rem',
backgroundColor: 'var(--cream-dark)', color: 'var(--error)',
border: 'none', cursor: 'pointer', fontFamily: "'DM Sans', sans-serif",
}}>
Rimuovi
</button>
)}
</div>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Tipi di contenuto
</label>
<div className="flex flex-wrap gap-3">
{CONTENT_TYPE_OPTIONS.map((opt) => (
<label key={opt.value} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={form.content_types.includes(opt.value)}
onChange={() => toggleArrayItem('content_types', opt.value)}
className="w-4 h-4 rounded border-slate-300 text-brand-600 focus:ring-brand-500"
/>
<span className="text-sm text-slate-700">{opt.label}</span>
</label>
))}
</div>
</div>
</div>
{/* Posting times */}
<div className="bg-white rounded-xl border border-slate-200 p-6 space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-slate-700 text-sm uppercase tracking-wider">
Orari di pubblicazione
</h3>
<button
type="button"
onClick={addPostingTime}
className="text-xs px-2.5 py-1 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-lg transition-colors"
>
<button type="button" onClick={addPostingTime} style={{ ...btnSecondary, marginTop: '0.5rem', fontSize: '0.82rem', padding: '0.4rem 0.875rem' }}>
+ Aggiungi orario
</button>
</div>
</Section>
<div className="space-y-2">
{form.posting_times.map((time, i) => (
<div key={i} className="flex items-center gap-2">
<input
type="time"
value={time}
onChange={(e) => updatePostingTime(i, e.target.value)}
className="px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm"
/>
{form.posting_times.length > 1 && (
<button
type="button"
onClick={() => removePostingTime(i)}
className="text-xs px-2 py-1 text-red-500 hover:bg-red-50 rounded transition-colors"
>
Rimuovi
</button>
)}
</div>
))}
{/* ── Periodo ───────────────────────────────────────────── */}
<Section title="Periodo">
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
<Field label="Data inizio">
<input type="date" value={form.start_date} onChange={(e) => handleChange('start_date', e.target.value)}
style={inputStyle}
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
</Field>
<Field label="Data fine (opzionale)">
<input type="date" value={form.end_date} onChange={(e) => handleChange('end_date', e.target.value)}
style={inputStyle}
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
</Field>
</div>
</div>
</Section>
{/* Date range */}
<div className="bg-white rounded-xl border border-slate-200 p-6 space-y-4">
<h3 className="font-semibold text-slate-700 text-sm uppercase tracking-wider">
Periodo
</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Data inizio
</label>
<input
type="date"
value={form.start_date}
onChange={(e) => handleChange('start_date', e.target.value)}
className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Data fine
<span className="text-slate-400 font-normal ml-1">(opzionale)</span>
</label>
<input
type="date"
value={form.end_date}
onChange={(e) => handleChange('end_date', e.target.value)}
className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm"
/>
</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-3">
<button
type="submit"
disabled={saving}
className="px-6 py-2.5 bg-brand-600 hover:bg-brand-700 disabled:bg-brand-300 text-white font-medium rounded-lg transition-colors text-sm"
>
{saving ? 'Salvataggio...' : isEdit ? 'Salva modifiche' : 'Crea piano'}
</button>
<button
type="button"
onClick={() => navigate('/plans')}
className="px-6 py-2.5 bg-white hover:bg-slate-50 text-slate-600 font-medium rounded-lg border border-slate-200 transition-colors text-sm"
>
Annulla
{/* ── Actions ───────────────────────────────────────────── */}
<div style={{ display: 'flex', gap: '0.75rem' }}>
<button type="submit" disabled={saving} style={{ ...btnPrimary, opacity: saving ? 0.6 : 1 }}>
{saving ? 'Salvataggio…' : isEdit ? 'Salva modifiche' : 'Crea piano'}
</button>
<button type="button" onClick={() => navigate('/plans')} style={btnSecondary}>Annulla</button>
</div>
</form>
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
</div>
)
}
function Section({ title, children }) {
return (
<div style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)', borderTop: '3px solid var(--accent)', padding: '1.5rem' }}>
<p style={{ fontSize: '0.7rem', fontWeight: 700, letterSpacing: '0.1em', textTransform: 'uppercase', color: 'var(--ink-muted)', margin: '0 0 1.25rem' }}>{title}</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
{children}
</div>
</div>
)
}
function Field({ label, children }) {
return (
<div>
<label style={{ display: 'block', fontSize: '0.72rem', fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--ink)', marginBottom: '0.4rem' }}>{label}</label>
{children}
</div>
)
}
const inputStyle = {
width: '100%', padding: '0.625rem 0.875rem',
border: '1px solid var(--border)', borderRadius: 0,
fontSize: '0.875rem', color: 'var(--ink)',
backgroundColor: 'var(--surface)', outline: 'none',
boxSizing: 'border-box', transition: 'border-color 0.15s',
fontFamily: "'DM Sans', sans-serif",
}
const selectStyle = { ...inputStyle, cursor: 'pointer' }
const btnPrimary = {
display: 'inline-block', padding: '0.65rem 1.5rem',
backgroundColor: 'var(--ink)', color: 'white',
fontFamily: "'DM Sans', sans-serif", fontWeight: 600,
fontSize: '0.875rem', border: 'none', cursor: 'pointer',
}
const btnSecondary = { ...btnPrimary, backgroundColor: 'var(--cream-dark)', color: '#1A1A1A', border: '1px solid #C8C0B4' }

View File

@@ -2,18 +2,13 @@ import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../api'
const frequencyLabels = {
daily: 'Giornaliero',
twice_daily: 'Due volte al giorno',
weekly: 'Settimanale',
custom: 'Personalizzato',
const FREQUENCY_LABELS = {
daily: 'Giornaliero', twice_daily: '2× al giorno',
weekly: 'Settimanale', custom: 'Personalizzato',
}
const platformLabels = {
instagram: 'Instagram',
facebook: 'Facebook',
youtube: 'YouTube',
tiktok: 'TikTok',
const PLATFORM_LABELS = {
instagram: 'Instagram', facebook: 'Facebook',
youtube: 'YouTube', tiktok: 'TikTok',
}
export default function PlanList() {
@@ -21,193 +16,173 @@ export default function PlanList() {
const [characters, setCharacters] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
loadData()
}, [])
useEffect(() => { loadData() }, [])
const loadData = async () => {
setLoading(true)
try {
const [plansData, charsData] = await Promise.all([
api.get('/plans/'),
api.get('/characters/'),
])
const [plansData, charsData] = await Promise.all([api.get('/plans/'), api.get('/characters/')])
setPlans(plansData)
setCharacters(charsData)
} catch {
// silent
} finally {
setLoading(false)
}
} catch {} finally { setLoading(false) }
}
const getCharacterName = (id) => {
const c = characters.find((ch) => ch.id === id)
return c ? c.name : '—'
}
const getCharacterName = (id) => characters.find(c => c.id === id)?.name || '—'
const handleToggle = async (plan) => {
try {
await api.post(`/plans/${plan.id}/toggle`)
loadData()
} catch {
// silent
}
await api.post(`/plans/${plan.id}/toggle`).catch(() => {})
loadData()
}
const handleDelete = async (id, name) => {
if (!confirm(`Eliminare il piano "${name}"?`)) return
try {
await api.delete(`/plans/${id}`)
loadData()
} catch {
// silent
}
await api.delete(`/plans/${id}`).catch(() => {})
loadData()
}
const formatDate = (dateStr) => {
if (!dateStr) return '—'
const d = new Date(dateStr)
return d.toLocaleDateString('it-IT', { day: '2-digit', month: 'short', year: 'numeric' })
}
const formatDate = (d) => d ? new Date(d).toLocaleDateString('it-IT', { day: '2-digit', month: 'short', year: 'numeric' }) : '—'
return (
<div>
<div className="flex items-center justify-between mb-6">
<div style={{ animation: 'fade-up 0.5s ease-out both' }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: '2rem' }}>
<div>
<h2 className="text-2xl font-bold text-slate-800">Piano Editoriale</h2>
<p className="text-slate-500 mt-1 text-sm">
Gestisci i piani di pubblicazione automatica
<span className="editorial-tag">Piano Editoriale</span>
<div className="editorial-line" />
<h2 style={{ fontFamily: "'Fraunces', serif", fontSize: '1.75rem', fontWeight: 600, letterSpacing: '-0.02em', color: 'var(--ink)', margin: '0.5rem 0 0.3rem' }}>
Piani di Pubblicazione
</h2>
<p style={{ fontSize: '0.875rem', color: 'var(--ink-muted)', margin: 0 }}>
Definisci con quale frequenza ogni personaggio pubblica sui social. I piani attivi guidano la schedulazione automatica.
</p>
</div>
<Link
to="/plans/new"
className="px-4 py-2 bg-brand-600 hover:bg-brand-700 text-white text-sm font-medium rounded-lg transition-colors"
>
+ Nuovo Piano
</Link>
<Link to="/plans/new" style={btnPrimary}>+ Nuovo Piano</Link>
</div>
{loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-brand-500 border-t-transparent" />
</div>
<Spinner />
) : plans.length === 0 ? (
<div className="text-center py-16 bg-white rounded-xl border border-slate-200">
<p className="text-4xl mb-3"></p>
<p className="text-slate-500 font-medium">Nessun piano editoriale</p>
<p className="text-slate-400 text-sm mt-1">
Crea un piano per automatizzare la pubblicazione dei contenuti
</p>
<Link
to="/plans/new"
className="inline-block mt-4 px-4 py-2 bg-brand-600 hover:bg-brand-700 text-white text-sm font-medium rounded-lg transition-colors"
>
+ Crea piano
</Link>
</div>
<EmptyState
icon="▦"
title="Nessun piano di pubblicazione"
description="Un piano editoriale definisce quando e dove ogni personaggio pubblica. Crea il primo piano per iniziare la schedulazione automatica dei contenuti."
cta="+ Crea il primo Piano"
to="/plans/new"
/>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{plans.map((plan) => (
<div
key={plan.id}
className="bg-white rounded-xl border border-slate-200 hover:border-brand-300 transition-all overflow-hidden"
>
<div className="p-5">
{/* Header */}
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<span className={`w-2.5 h-2.5 rounded-full shrink-0 ${plan.is_active ? 'bg-emerald-500' : 'bg-slate-300'}`} />
<h3 className="font-semibold text-slate-800">{plan.name}</h3>
</div>
<button
onClick={() => handleToggle(plan)}
className={`text-xs px-2.5 py-1 rounded-full font-medium transition-colors ${
plan.is_active
? 'bg-emerald-50 text-emerald-600 hover:bg-emerald-100'
: 'bg-slate-100 text-slate-500 hover:bg-slate-200'
}`}
>
{plan.is_active ? 'Attivo' : 'Inattivo'}
</button>
</div>
{/* Character */}
<p className="text-sm text-slate-500 mb-3">
{getCharacterName(plan.character_id)}
</p>
{/* Info grid */}
<div className="space-y-2">
{/* Frequency */}
<div className="flex items-center gap-2">
<span className="text-xs text-slate-400 w-20 shrink-0">Frequenza</span>
<span className="text-xs font-medium text-slate-600">
{frequencyLabels[plan.frequency] || plan.frequency}
{plan.frequency === 'custom' && plan.posts_per_day && (
<span className="text-slate-400 font-normal ml-1">
({plan.posts_per_day} post/giorno)
</span>
)}
</span>
</div>
{/* Platforms */}
<div className="flex items-center gap-2">
<span className="text-xs text-slate-400 w-20 shrink-0">Piattaforme</span>
<div className="flex flex-wrap gap-1">
{plan.platforms && plan.platforms.map((p) => (
<span key={p} className="text-xs px-1.5 py-0.5 bg-slate-100 text-slate-600 rounded">
{platformLabels[p] || p}
</span>
))}
</div>
</div>
{/* Posting times */}
{plan.posting_times && plan.posting_times.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-xs text-slate-400 w-20 shrink-0">Orari</span>
<div className="flex flex-wrap gap-1">
{plan.posting_times.map((t, i) => (
<span key={i} className="text-xs px-1.5 py-0.5 bg-brand-50 text-brand-700 rounded font-mono">
{t}
</span>
))}
</div>
</div>
)}
{/* Date range */}
<div className="flex items-center gap-2">
<span className="text-xs text-slate-400 w-20 shrink-0">Periodo</span>
<span className="text-xs text-slate-600">
{formatDate(plan.start_date)}
{plan.end_date ? `${formatDate(plan.end_date)}` : ' — In corso'}
</span>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2 mt-4 pt-3 border-t border-slate-100">
<Link
to={`/plans/${plan.id}/edit`}
className="text-xs px-3 py-1.5 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-lg transition-colors"
>
Modifica
</Link>
<button
onClick={() => handleDelete(plan.id, plan.name)}
className="text-xs px-3 py-1.5 text-red-500 hover:bg-red-50 rounded-lg transition-colors ml-auto"
>
Elimina
</button>
</div>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: '1rem' }}>
{plans.map(plan => (
<PlanCard key={plan.id} plan={plan} characterName={getCharacterName(plan.character_id)}
onToggle={handleToggle} onDelete={handleDelete} formatDate={formatDate} />
))}
</div>
)}
</div>
)
}
function PlanCard({ plan, characterName, onToggle, onDelete, formatDate }) {
return (
<div style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)', overflow: 'hidden' }}>
<div style={{ height: 4, backgroundColor: plan.is_active ? 'var(--accent)' : 'var(--border-strong)' }} />
<div style={{ padding: '1.25rem' }}>
{/* Title row */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', minWidth: 0 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: plan.is_active ? 'var(--success)' : 'var(--border-strong)', flexShrink: 0 }} />
<h3 style={{ fontWeight: 600, color: 'var(--ink)', margin: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{plan.name}</h3>
</div>
<button onClick={() => onToggle(plan)} style={{
fontSize: '0.68rem', fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase',
padding: '0.2rem 0.6rem', border: 'none', cursor: 'pointer', flexShrink: 0,
backgroundColor: plan.is_active ? 'var(--success-light)' : 'var(--cream-dark)',
color: plan.is_active ? 'var(--success)' : 'var(--ink-muted)',
}}>
{plan.is_active ? 'Attivo' : 'Inattivo'}
</button>
</div>
<p style={{ fontSize: '0.82rem', color: 'var(--ink-muted)', margin: '0 0 1rem' }}>{characterName}</p>
{/* Info */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', fontSize: '0.82rem' }}>
<Row label="Frequenza">
{FREQUENCY_LABELS[plan.frequency] || plan.frequency}
{plan.frequency === 'custom' && plan.posts_per_day && ` (${plan.posts_per_day} post/giorno)`}
</Row>
<Row label="Piattaforme">
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.3rem' }}>
{plan.platforms?.map(p => (
<span key={p} style={{ fontSize: '0.72rem', padding: '0.15rem 0.4rem', backgroundColor: 'var(--cream-dark)', color: 'var(--ink-light)' }}>
{PLATFORM_LABELS[p] || p}
</span>
))}
</div>
</Row>
{plan.posting_times?.length > 0 && (
<Row label="Orari">
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.3rem' }}>
{plan.posting_times.map((t, i) => (
<span key={i} style={{ fontSize: '0.72rem', padding: '0.15rem 0.4rem', backgroundColor: 'var(--accent-light)', color: 'var(--accent)', fontFamily: 'monospace' }}>{t}</span>
))}
</div>
</Row>
)}
<Row label="Periodo">
{formatDate(plan.start_date)}{plan.end_date ? `${formatDate(plan.end_date)}` : ' — In corso'}
</Row>
</div>
{/* Actions */}
<div style={{ display: 'flex', gap: '0.5rem', paddingTop: '0.875rem', marginTop: '0.875rem', borderTop: '1px solid var(--border)' }}>
<Link to={`/plans/${plan.id}/edit`} style={btnSmall}>Modifica</Link>
<button onClick={() => onDelete(plan.id, plan.name)} style={{ ...btnSmall, marginLeft: 'auto', color: 'var(--error)' }}>Elimina</button>
</div>
</div>
</div>
)
}
function Row({ label, children }) {
return (
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'flex-start' }}>
<span style={{ fontSize: '0.72rem', fontWeight: 700, letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--ink-muted)', minWidth: 72, paddingTop: '0.1rem' }}>{label}</span>
<span style={{ color: 'var(--ink-light)', flex: 1 }}>{children}</span>
</div>
)
}
function EmptyState({ icon, title, description, cta, to }) {
return (
<div style={{ textAlign: 'center', padding: '4rem 2rem', backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}>
<div style={{ fontSize: '2.5rem', marginBottom: '1rem', color: 'var(--accent)' }}>{icon}</div>
<h3 style={{ fontFamily: "'Fraunces', serif", fontSize: '1.2rem', fontWeight: 600, color: 'var(--ink)', margin: '0 0 0.75rem' }}>{title}</h3>
<p style={{ fontSize: '0.875rem', color: 'var(--ink-muted)', maxWidth: 400, margin: '0 auto 1.5rem', lineHeight: 1.6 }}>{description}</p>
<Link to={to} style={btnPrimary}>{cta}</Link>
</div>
)
}
function Spinner() {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem 0' }}>
<div style={{ width: 28, height: 28, border: '2px solid var(--border)', borderTopColor: 'var(--accent)', borderRadius: '50%', animation: 'spin 0.8s linear infinite' }} />
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
</div>
)
}
const btnPrimary = {
display: 'inline-block', padding: '0.6rem 1.25rem',
backgroundColor: 'var(--ink)', color: 'white',
fontFamily: "'DM Sans', sans-serif", fontWeight: 600,
fontSize: '0.875rem', textDecoration: 'none',
border: 'none', cursor: 'pointer', whiteSpace: 'nowrap',
}
const btnSmall = {
display: 'inline-block', padding: '0.35rem 0.75rem',
backgroundColor: 'var(--cream-dark)', color: 'var(--ink-light)',
fontFamily: "'DM Sans', sans-serif", fontWeight: 500,
fontSize: '0.78rem', textDecoration: 'none',
border: 'none', cursor: 'pointer',
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,324 +1,330 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../api'
import { useAuth } from '../AuthContext'
const platformLabels = {
instagram: 'Instagram',
facebook: 'Facebook',
youtube: 'YouTube',
tiktok: 'TikTok',
const PLATFORMS = {
instagram: {
label: 'Instagram', color: '#E1306C', bg: '#FFF0F5',
steps: [
'Vai su <a href="https://developers.facebook.com/" target="_blank" rel="noreferrer">developers.facebook.com</a> e crea un\'app di tipo "Business".',
'Nel pannello app, aggiungi il prodotto <strong>Instagram Graph API</strong>.',
'In "Instagram → Basic Display", ottieni il tuo <strong>User Access Token</strong> (long-lived, ~60 giorni).',
'Copia il token e incollalo nel campo "Access Token" qui sotto.',
'Il campo "Page ID" non è richiesto per Instagram Basic Display.',
],
tokenLabel: 'Instagram Access Token',
pageIdLabel: null,
tokenPlaceholder: 'EAAG...',
docs: 'https://developers.facebook.com/docs/instagram-basic-display-api',
proOnly: false,
},
facebook: {
label: 'Facebook', color: '#1877F2', bg: '#F0F4FF',
steps: [
'Vai su <a href="https://developers.facebook.com/" target="_blank" rel="noreferrer">developers.facebook.com</a> e crea un\'app "Business".',
'Aggiungi il prodotto <strong>Facebook Login</strong> e abilita le permission: <code>pages_manage_posts</code>, <code>pages_read_engagement</code>.',
'Dal Meta Business Suite, vai su <strong>Impostazioni → Pagine → Accesso API</strong>.',
'Genera un <strong>Page Access Token</strong> per la pagina che vuoi gestire.',
'Copia il <strong>Page ID</strong> dalla URL della pagina Facebook.',
],
tokenLabel: 'Page Access Token',
pageIdLabel: 'Page ID (ID della pagina Facebook)',
tokenPlaceholder: 'EAABwzLixnjYBO...',
pageIdPlaceholder: '123456789',
docs: 'https://developers.facebook.com/docs/pages-api',
proOnly: false,
},
youtube: {
label: 'YouTube', color: '#FF0000', bg: '#FFF5F5',
steps: [
'Vai su <a href="https://console.cloud.google.com/" target="_blank" rel="noreferrer">Google Cloud Console</a>, crea un progetto e abilita <strong>YouTube Data API v3</strong>.',
'In "Credenziali", crea un <strong>OAuth 2.0 Client ID</strong> di tipo "Web application".',
'Usa <a href="https://developers.google.com/oauthplayground/" target="_blank" rel="noreferrer">OAuth Playground</a> per generare un <strong>refresh token</strong> con scope <code>youtube.upload</code>.',
'Incolla il refresh token nel campo Access Token.',
'Il "Channel ID" si trova su youtube.com → icona account → La tua presenza su YouTube.',
],
tokenLabel: 'OAuth Refresh Token',
pageIdLabel: 'Channel ID',
tokenPlaceholder: '1//0g...',
pageIdPlaceholder: 'UCxxxxxxxxxxxxxxxx',
docs: 'https://developers.google.com/youtube/v3/guides/uploading_a_video',
proOnly: true,
},
tiktok: {
label: 'TikTok', color: '#000000', bg: '#F5F5F5',
steps: [
'Registra un account su <a href="https://developers.tiktok.com/" target="_blank" rel="noreferrer">developers.tiktok.com</a> e crea un\'app.',
'Richiedi accesso al prodotto <strong>Content Posting API</strong> (richiede approvazione da TikTok).',
'Una volta approvato, usa il flusso OAuth per ottenere un <strong>access token</strong> con scope <code>video.upload</code>.',
'Il token ha durata limitata (24h) — salva il refresh token per rinnovarlo automaticamente.',
],
tokenLabel: 'Access Token',
pageIdLabel: null,
tokenPlaceholder: 'act.xxxxxxxx...',
docs: 'https://developers.tiktok.com/doc/content-posting-api-get-started',
proOnly: true,
},
}
const platformColors = {
instagram: 'bg-pink-50 text-pink-600 border-pink-200',
facebook: 'bg-blue-50 text-blue-600 border-blue-200',
youtube: 'bg-red-50 text-red-600 border-red-200',
tiktok: 'bg-slate-900 text-white border-slate-700',
}
const EMPTY_ACCOUNT = {
platform: 'instagram',
account_name: '',
access_token: '',
page_id: '',
}
const EMPTY_FORM = { platform: 'instagram', account_name: '', access_token: '', page_id: '' }
export default function SocialAccounts() {
const { isPro } = useAuth()
const [characters, setCharacters] = useState([])
const [accounts, setAccounts] = useState([])
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(null) // character_id or null
const [form, setForm] = useState(EMPTY_ACCOUNT)
const [showForm, setShowForm] = useState(null)
const [guideOpen, setGuideOpen] = useState({})
const [form, setForm] = useState(EMPTY_FORM)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [testing, setTesting] = useState(null)
const [testResult, setTestResult] = useState({})
useEffect(() => {
loadData()
}, [])
useEffect(() => { loadData() }, [])
const loadData = async () => {
setLoading(true)
try {
const [charsData, accsData] = await Promise.all([
api.get('/characters/'),
api.get('/social/accounts'),
])
setCharacters(charsData)
setAccounts(accsData)
} catch {
// silent
} finally {
setLoading(false)
}
const [chars, accs] = await Promise.all([api.get('/characters/'), api.get('/social/accounts')])
setCharacters(chars)
setAccounts(accs)
} catch {} finally { setLoading(false) }
}
const getAccountsForCharacter = (characterId) => {
return accounts.filter((a) => a.character_id === characterId)
}
const getCharAccounts = (charId) => accounts.filter(a => a.character_id === charId)
const handleFormChange = (field, value) => {
setForm((prev) => ({ ...prev, [field]: value }))
}
const handleAddAccount = async (characterId) => {
const handleAdd = async (charId) => {
setError('')
setSaving(true)
try {
await api.post('/social/accounts', {
character_id: characterId,
platform: form.platform,
account_name: form.account_name,
access_token: form.access_token,
page_id: form.page_id || null,
})
await api.post('/social/accounts', { character_id: charId, ...form, page_id: form.page_id || null })
setShowForm(null)
setForm(EMPTY_ACCOUNT)
setForm(EMPTY_FORM)
loadData()
} catch (err) {
setError(err.message || 'Errore nel salvataggio')
} finally {
setSaving(false)
}
} catch (e) { setError(e.message || 'Errore nel salvataggio')
} finally { setSaving(false) }
}
const handleTest = async (accountId) => {
setTesting(accountId)
setTestResult((prev) => ({ ...prev, [accountId]: null }))
const handleTest = async (id) => {
setTesting(id)
try {
const result = await api.post(`/social/accounts/${accountId}/test`)
setTestResult((prev) => ({ ...prev, [accountId]: { success: true, message: result.message || 'Connessione OK' } }))
} catch (err) {
setTestResult((prev) => ({ ...prev, [accountId]: { success: false, message: err.message || 'Test fallito' } }))
} finally {
setTesting(null)
}
const r = await api.post(`/social/accounts/${id}/test`)
setTestResult(p => ({ ...p, [id]: { ok: true, msg: r.message || 'OK' } }))
} catch (e) {
setTestResult(p => ({ ...p, [id]: { ok: false, msg: e.message || 'Fallito' } }))
} finally { setTesting(null) }
}
const handleToggle = async (account) => {
try {
await api.put(`/social/accounts/${account.id}`, { is_active: !account.is_active })
loadData()
} catch {
// silent
}
const handleToggle = async (acc) => {
await api.put(`/social/accounts/${acc.id}`, { is_active: !acc.is_active }).catch(() => {})
loadData()
}
const handleRemove = async (accountId) => {
if (!confirm('Rimuovere questo account social?')) return
try {
await api.delete(`/social/accounts/${accountId}`)
loadData()
} catch {
// silent
}
const handleRemove = async (id) => {
if (!confirm('Rimuovere questo account?')) return
await api.delete(`/social/accounts/${id}`).catch(() => {})
loadData()
}
if (loading) {
return (
<div>
<h2 className="text-2xl font-bold text-slate-800 mb-1">Account Social</h2>
<p className="text-slate-500 text-sm mb-6">Gestisci le connessioni ai social network</p>
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-brand-500 border-t-transparent" />
</div>
</div>
)
}
const isProLocked = (platform) => PLATFORMS[platform]?.proOnly && !isPro
if (loading) return <Spinner />
return (
<div>
<div className="mb-6">
<h2 className="text-2xl font-bold text-slate-800">Account Social</h2>
<p className="text-slate-500 mt-1 text-sm">
Gestisci le connessioni ai social network per ogni personaggio
<div style={{ animation: 'fade-up 0.5s ease-out both' }}>
{/* Header */}
<div style={{ marginBottom: '2rem' }}>
<span className="editorial-tag">Social</span>
<div className="editorial-line" />
<h2 style={{ fontFamily: "'Fraunces', serif", fontSize: '1.75rem', fontWeight: 600, letterSpacing: '-0.02em', color: 'var(--ink)', margin: '0.5rem 0 0.3rem' }}>
Account Social
</h2>
<p style={{ fontSize: '0.875rem', color: 'var(--ink-muted)', margin: 0 }}>
Collega i social network a ogni personaggio per abilitare la pubblicazione diretta.
YouTube e TikTok sono disponibili con il piano Pro.
</p>
</div>
{/* Info box */}
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-xl">
<div className="flex gap-3">
<span className="text-blue-500 text-lg shrink-0">i</span>
<div>
<p className="text-sm text-blue-700 font-medium">Configurazione OAuth</p>
<p className="text-xs text-blue-600 mt-0.5">
Per la pubblicazione automatica, ogni piattaforma richiede la configurazione di un'app
developer con le relative credenziali OAuth. Inserisci access token e page ID ottenuti
dalla console developer di ciascuna piattaforma.
</p>
</div>
</div>
</div>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">
{error}
{/* No characters gate */}
{characters.length === 0 && (
<div style={{ padding: '3rem 2rem', textAlign: 'center', backgroundColor: 'var(--surface)', border: '1px solid var(--border)', borderTop: '4px solid var(--accent)', marginBottom: '1.5rem' }}>
<div style={{ fontSize: '2rem', color: 'var(--accent)', marginBottom: '1rem' }}></div>
<h3 style={{ fontFamily: "'Fraunces', serif", fontSize: '1.1rem', fontWeight: 600, color: 'var(--ink)', margin: '0 0 0.5rem' }}>Prima crea un Personaggio</h3>
<p style={{ fontSize: '0.875rem', color: 'var(--ink-muted)', maxWidth: 360, margin: '0 auto 1.25rem', lineHeight: 1.6 }}>
Gli account social si collegano ai personaggi. Crea prima un personaggio, poi torna qui per collegare i suoi account.
</p>
<Link to="/characters/new" style={btnPrimary}>Crea il tuo primo Personaggio </Link>
</div>
)}
{characters.length === 0 ? (
<div className="text-center py-16 bg-white rounded-xl border border-slate-200">
<p className="text-4xl mb-3">◇</p>
<p className="text-slate-500 font-medium">Nessun personaggio</p>
<p className="text-slate-400 text-sm mt-1">
Crea un personaggio per poi collegare gli account social
</p>
{error && <div style={{ padding: '0.75rem 1rem', backgroundColor: 'var(--error-light)', border: '1px solid #FED7D7', color: 'var(--error)', fontSize: '0.875rem', marginBottom: '1rem' }}>{error}</div>}
{/* Platform guides */}
<div style={{ marginBottom: '2rem' }}>
<p style={{ fontSize: '0.78rem', fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--ink-muted)', marginBottom: '0.75rem' }}>Guide per piattaforma</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{Object.entries(PLATFORMS).map(([key, p]) => (
<div key={key} style={{ border: '1px solid var(--border)', backgroundColor: 'var(--surface)', overflow: 'hidden' }}>
<button onClick={() => setGuideOpen(prev => ({ ...prev, [key]: !prev[key] }))} style={{
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0.875rem 1.25rem', backgroundColor: 'transparent', border: 'none', cursor: 'pointer',
fontFamily: "'DM Sans', sans-serif",
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<span style={{ width: 10, height: 10, borderRadius: '50%', backgroundColor: p.color, flexShrink: 0 }} />
<span style={{ fontWeight: 600, fontSize: '0.9rem', color: 'var(--ink)' }}>{p.label}</span>
{p.proOnly && (
<span style={{ fontSize: '0.68rem', fontWeight: 700, padding: '0.1rem 0.4rem', backgroundColor: isPro ? 'var(--success-light)' : '#FFF0EC', color: isPro ? 'var(--success)' : 'var(--accent)', letterSpacing: '0.05em' }}>
PRO
</span>
)}
<a href={p.docs} target="_blank" rel="noreferrer" onClick={e => e.stopPropagation()} style={{ fontSize: '0.75rem', color: 'var(--accent)', textDecoration: 'none' }}>Docs ufficiali </a>
</div>
<span style={{ fontSize: '0.75rem', color: 'var(--ink-muted)' }}>{guideOpen[key] ? '▲ Chiudi' : '▼ Come ottenere il token'}</span>
</button>
{guideOpen[key] && (
<div style={{ padding: '1rem 1.25rem 1.25rem', borderTop: '1px solid var(--border)', backgroundColor: p.bg }}>
<ol style={{ margin: 0, paddingLeft: '1.25rem', display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{p.steps.map((step, i) => (
<li key={i} style={{ fontSize: '0.85rem', color: 'var(--ink)', lineHeight: 1.6 }}
dangerouslySetInnerHTML={{ __html: step }} />
))}
</ol>
</div>
)}
</div>
))}
</div>
) : (
<div className="space-y-6">
{characters.map((character) => {
const charAccounts = getAccountsForCharacter(character.id)
const isFormOpen = showForm === character.id
</div>
{/* Characters list */}
{characters.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
{characters.map(char => {
const charAccs = getCharAccounts(char.id)
const formOpen = showForm === char.id
const color = char.visual_style?.primary_color || 'var(--accent)'
const currentPlatformLocked = isProLocked(form.platform)
return (
<div key={character.id} className="bg-white rounded-xl border border-slate-200 overflow-hidden">
{/* Character header */}
<div className="p-5 border-b border-slate-100">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold text-sm shrink-0"
style={{ backgroundColor: character.visual_style?.primary_color || '#f97316' }}
>
{character.name?.charAt(0).toUpperCase()}
</div>
<div>
<h3 className="font-semibold text-slate-800">{character.name}</h3>
<p className="text-xs text-slate-400">{character.niche}</p>
</div>
<div key={char.id} style={{ border: '1px solid var(--border)', backgroundColor: 'var(--surface)', overflow: 'hidden' }}>
{/* Char header */}
<div style={{ padding: '1rem 1.25rem', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<div style={{ width: 36, height: 36, borderRadius: '50%', backgroundColor: color, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'white', fontWeight: 700, fontSize: '0.9rem', flexShrink: 0 }}>
{char.name?.charAt(0).toUpperCase()}
</div>
<div>
<p style={{ fontWeight: 600, color: 'var(--ink)', margin: 0, fontSize: '0.9rem' }}>{char.name}</p>
<p style={{ fontSize: '0.75rem', color: 'var(--ink-muted)', margin: 0 }}>{char.niche}</p>
</div>
<button
onClick={() => {
setShowForm(isFormOpen ? null : character.id)
setForm(EMPTY_ACCOUNT)
setError('')
}}
className="text-xs px-3 py-1.5 bg-brand-50 hover:bg-brand-100 text-brand-600 rounded-lg transition-colors font-medium"
>
{isFormOpen ? 'Annulla' : '+ Connetti Account'}
</button>
</div>
<button onClick={() => { setShowForm(formOpen ? null : char.id); setForm(EMPTY_FORM); setError('') }} style={formOpen ? btnSecondary : btnPrimary}>
{formOpen ? 'Annulla' : '+ Connetti Account'}
</button>
</div>
{/* Inline form */}
{isFormOpen && (
<div className="p-5 bg-slate-50 border-b border-slate-100">
<div className="max-w-md space-y-3">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Piattaforma</label>
<select
value={form.platform}
onChange={(e) => handleFormChange('platform', e.target.value)}
className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm bg-white"
>
{Object.entries(platformLabels).map(([val, label]) => (
<option key={val} value={val}>{label}</option>
{/* Add form */}
{formOpen && (
<div style={{ padding: '1.25rem', backgroundColor: 'var(--cream)', borderBottom: '1px solid var(--border)' }}>
<div style={{ maxWidth: 480 }}>
<p style={{ fontSize: '0.78rem', fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--ink-muted)', marginBottom: '1rem' }}>
Aggiungi account social per {char.name}
</p>
<Field label="Piattaforma">
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap' }}>
{Object.entries(PLATFORMS).map(([key, p]) => (
<button key={key} type="button" onClick={() => setForm(prev => ({ ...prev, platform: key }))} style={{
padding: '0.35rem 0.875rem', fontSize: '0.82rem', fontWeight: form.platform === key ? 600 : 400,
fontFamily: "'DM Sans', sans-serif", border: 'none', cursor: 'pointer',
backgroundColor: form.platform === key ? p.color : 'var(--cream-dark)',
color: form.platform === key ? 'white' : 'var(--ink-light)',
}}>
{p.label}
{p.proOnly && !isPro && ' 🔒'}
</button>
))}
</select>
</div>
</div>
</Field>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Nome account</label>
<input
type="text"
value={form.account_name}
onChange={(e) => handleFormChange('account_name', e.target.value)}
placeholder="Es. @mio_profilo"
className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm"
required
/>
</div>
{/* PRO lock for YouTube/TikTok */}
{currentPlatformLocked ? (
<div style={{ padding: '1.25rem', backgroundColor: 'var(--accent-light)', border: '1px solid var(--border)', borderLeft: '3px solid var(--accent)', marginTop: '0.75rem' }}>
<p style={{ fontSize: '0.875rem', fontWeight: 700, color: 'var(--ink)', margin: '0 0 0.4rem' }}>
{PLATFORMS[form.platform].label} richiede il piano Pro
</p>
<p style={{ fontSize: '0.82rem', color: 'var(--ink-muted)', margin: '0 0 0.875rem', lineHeight: 1.5 }}>
La pubblicazione su {PLATFORMS[form.platform].label} è disponibile esclusivamente con il piano Pro. Attiva Pro per sbloccarla.
</p>
<Link to="/settings" style={{ display: 'inline-block', padding: '0.55rem 1.1rem', backgroundColor: 'var(--ink)', color: 'white', fontFamily: "'DM Sans', sans-serif", fontWeight: 600, fontSize: '0.875rem', textDecoration: 'none' }}>
Attiva Piano Pro
</Link>
</div>
) : (
<>
{/* Platform-specific hint */}
<div style={{ padding: '0.75rem', backgroundColor: PLATFORMS[form.platform].bg, marginBottom: '1rem', fontSize: '0.8rem', color: 'var(--ink)', lineHeight: 1.5 }}>
Vedi la guida <strong>{PLATFORMS[form.platform].label}</strong> qui sopra per ottenere le credenziali richieste.
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Access Token</label>
<input
type="password"
value={form.access_token}
onChange={(e) => handleFormChange('access_token', e.target.value)}
placeholder="Token di accesso dalla piattaforma"
className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm font-mono"
required
/>
</div>
<Field label="Nome account">
<input type="text" value={form.account_name} onChange={e => setForm(p => ({ ...p, account_name: e.target.value }))}
placeholder="Es. @mio_profilo o Nome Pagina" style={inputStyle}
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
</Field>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Page ID
<span className="text-slate-400 font-normal ml-1">(opzionale)</span>
</label>
<input
type="text"
value={form.page_id}
onChange={(e) => handleFormChange('page_id', e.target.value)}
placeholder="ID pagina (per Facebook/YouTube)"
className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm font-mono"
/>
</div>
<Field label={PLATFORMS[form.platform].tokenLabel}>
<input type="password" value={form.access_token} onChange={e => setForm(p => ({ ...p, access_token: e.target.value }))}
placeholder={PLATFORMS[form.platform].tokenPlaceholder} style={{ ...inputStyle, fontFamily: 'monospace', fontSize: '0.82rem' }}
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
</Field>
<button
onClick={() => handleAddAccount(character.id)}
disabled={saving || !form.account_name || !form.access_token}
className="px-4 py-2 bg-brand-600 hover:bg-brand-700 disabled:bg-brand-300 text-white text-sm font-medium rounded-lg transition-colors"
>
{saving ? 'Salvataggio...' : 'Salva Account'}
</button>
{PLATFORMS[form.platform].pageIdLabel && (
<Field label={PLATFORMS[form.platform].pageIdLabel}>
<input type="text" value={form.page_id} onChange={e => setForm(p => ({ ...p, page_id: e.target.value }))}
placeholder={PLATFORMS[form.platform].pageIdPlaceholder} style={{ ...inputStyle, fontFamily: 'monospace', fontSize: '0.82rem' }}
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
</Field>
)}
<button onClick={() => handleAdd(char.id)} disabled={saving || !form.account_name || !form.access_token} style={{
...btnPrimary, opacity: (saving || !form.account_name || !form.access_token) ? 0.6 : 1,
}}>
{saving ? 'Salvataggio…' : 'Salva Account'}
</button>
</>
)}
</div>
</div>
)}
{/* Accounts list */}
<div className="divide-y divide-slate-50">
{charAccounts.length === 0 ? (
<div className="px-5 py-8 text-center">
<p className="text-sm text-slate-400">Nessun account collegato</p>
<div>
{charAccs.length === 0 ? (
<div style={{ padding: '1.5rem', textAlign: 'center', color: 'var(--ink-muted)', fontSize: '0.85rem' }}>
Nessun account collegato clicca "+ Connetti Account" per iniziare.
</div>
) : (
charAccounts.map((account) => (
<div key={account.id} className="px-5 py-3 flex items-center gap-3">
{/* Platform badge */}
<span className={`text-xs px-2 py-0.5 rounded-full font-medium border ${platformColors[account.platform] || 'bg-slate-100 text-slate-600 border-slate-200'}`}>
{platformLabels[account.platform] || account.platform}
</span>
{/* Account name */}
<span className="text-sm font-medium text-slate-700 flex-1 min-w-0 truncate">
{account.account_name}
</span>
{/* Status */}
<span className={`w-2 h-2 rounded-full shrink-0 ${account.is_active ? 'bg-emerald-500' : 'bg-slate-300'}`} />
{/* Test result */}
{testResult[account.id] && (
<span className={`text-xs ${testResult[account.id].success ? 'text-emerald-600' : 'text-red-500'}`}>
{testResult[account.id].message}
</span>
)}
{/* Actions */}
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => handleTest(account.id)}
disabled={testing === account.id}
className="text-xs px-2 py-1 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded transition-colors disabled:opacity-50"
>
{testing === account.id ? 'Test...' : 'Test'}
</button>
<button
onClick={() => handleToggle(account)}
className="text-xs px-2 py-1 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded transition-colors"
>
{account.is_active ? 'Disattiva' : 'Attiva'}
</button>
<button
onClick={() => handleRemove(account.id)}
className="text-xs px-2 py-1 text-red-500 hover:bg-red-50 rounded transition-colors"
>
Rimuovi
</button>
charAccs.map(acc => {
const plat = PLATFORMS[acc.platform] || { label: acc.platform, color: 'var(--ink-muted)' }
const tr = testResult[acc.id]
return (
<div key={acc.id} style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '0.75rem 1.25rem', borderTop: '1px solid var(--border)', flexWrap: 'wrap' }}>
<span style={{ fontSize: '0.72rem', fontWeight: 700, padding: '0.2rem 0.6rem', backgroundColor: plat.bg || 'var(--cream-dark)', color: plat.color }}>{plat.label}</span>
<span style={{ fontWeight: 600, color: 'var(--ink)', fontSize: '0.875rem', flex: 1, minWidth: 100, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{acc.account_name}</span>
<span style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: acc.is_active ? 'var(--success)' : 'var(--border-strong)', flexShrink: 0 }} />
{tr && <span style={{ fontSize: '0.75rem', color: tr.ok ? 'var(--success)' : 'var(--error)' }}>{tr.msg}</span>}
<div style={{ display: 'flex', gap: '0.35rem', flexShrink: 0 }}>
<button onClick={() => handleTest(acc.id)} disabled={testing === acc.id} style={{ ...btnTiny, opacity: testing === acc.id ? 0.5 : 1 }}>{testing === acc.id ? 'Test…' : 'Test'}</button>
<button onClick={() => handleToggle(acc)} style={btnTiny}>{acc.is_active ? 'Disattiva' : 'Attiva'}</button>
<button onClick={() => handleRemove(acc.id)} style={{ ...btnTiny, color: 'var(--error)' }}>Rimuovi</button>
</div>
</div>
</div>
))
)
})
)}
</div>
</div>
@@ -329,3 +335,42 @@ export default function SocialAccounts() {
</div>
)
}
function Field({ label, children }) {
return (
<div style={{ marginBottom: '0.875rem' }}>
<label style={{ display: 'block', fontSize: '0.72rem', fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--ink)', marginBottom: '0.4rem' }}>{label}</label>
{children}
</div>
)
}
function Spinner() {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem 0' }}>
<div style={{ width: 28, height: 28, border: '2px solid var(--border)', borderTopColor: 'var(--accent)', borderRadius: '50%', animation: 'spin 0.8s linear infinite' }} />
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
</div>
)
}
const inputStyle = {
width: '100%', padding: '0.625rem 0.875rem',
border: '1px solid var(--border)', borderRadius: 0,
fontSize: '0.875rem', color: 'var(--ink)',
backgroundColor: 'var(--surface)', outline: 'none',
boxSizing: 'border-box', transition: 'border-color 0.15s',
fontFamily: "'DM Sans', sans-serif",
}
const btnPrimary = {
display: 'inline-block', padding: '0.55rem 1.1rem',
backgroundColor: 'var(--ink)', color: 'white',
fontFamily: "'DM Sans', sans-serif", fontWeight: 600,
fontSize: '0.875rem', border: 'none', cursor: 'pointer', whiteSpace: 'nowrap',
}
const btnSecondary = { ...btnPrimary, backgroundColor: 'var(--cream-dark)', color: '#1A1A1A', border: '1px solid #C8C0B4' }
const btnTiny = {
padding: '0.3rem 0.65rem', backgroundColor: 'var(--cream-dark)', color: 'var(--ink-light)',
fontFamily: "'DM Sans', sans-serif", fontWeight: 500, fontSize: '0.75rem',
border: 'none', cursor: 'pointer',
}

View File

@@ -0,0 +1,129 @@
import LegalLayout from './LegalLayout'
export default function CookiePolicy() {
return (
<LegalLayout title="Cookie Policy" updated="1 aprile 2026">
<Section title="1. Cosa sono i cookie">
<p>
I cookie sono piccoli file di testo che vengono salvati sul tuo dispositivo quando visiti
un sito web. Consentono al sito di ricordare le tue azioni e preferenze nel tempo,
migliorando la tua esperienza di navigazione.
</p>
</Section>
<Section title="2. Tipologie di cookie utilizzati">
<table>
<thead>
<tr>
<th>Nome</th>
<th>Tipo</th>
<th>Durata</th>
<th>Finalità</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>leopost_token</code></td>
<td>Necessario</td>
<td>7 giorni</td>
<td>Token di autenticazione JWT. Mantiene la sessione attiva dopo il login.</td>
</tr>
<tr>
<td><code>leopost_cookie_consent</code></td>
<td>Necessario</td>
<td>12 mesi</td>
<td>Registra le tue preferenze di consenso sui cookie per non riproporre il banner.</td>
</tr>
<tr>
<td><code>leopost_session</code></td>
<td>Necessario</td>
<td>Sessione</td>
<td>Cookie di sessione per la sicurezza del form CSRF. Eliminato alla chiusura del browser.</td>
</tr>
</tbody>
</table>
<p style={{ marginTop: '1.25rem' }}>
Al momento Leopost <strong>non utilizza cookie analitici o di marketing di terze parti</strong>.
Se in futuro venissero introdotti (es. Google Analytics), questa policy verrà aggiornata
e ti verrà richiesto un nuovo consenso.
</p>
</Section>
<Section title="3. Cookie di terze parti">
<p>
Se accedi tramite <strong>Google OAuth</strong>, Google potrebbe impostare cookie propri
sul tuo browser durante il processo di autenticazione. Questi cookie sono soggetti alla
<a href="https://policies.google.com/privacy" target="_blank" rel="noreferrer"> Privacy Policy di Google</a>.
</p>
<p>
Se colleghi account social (Facebook, Instagram, YouTube, TikTok) per la pubblicazione,
le rispettive piattaforme potrebbero impostare cookie propri. Questi sono al di fuori
del controllo di Leopost.
</p>
</Section>
<Section title="4. Come gestire i cookie">
<p>Puoi gestire le tue preferenze in qualsiasi momento tramite:</p>
<ul>
<li>
<strong>Il banner cookie di Leopost</strong> Clicca su "Gestisci preferenze" nel
banner che appare al primo accesso, oppure cancella il cookie{' '}
<code>leopost_cookie_consent</code> dal tuo browser per ripristinare le opzioni.
</li>
<li>
<strong>Le impostazioni del browser</strong> Puoi bloccare o cancellare tutti i cookie
direttamente dalle impostazioni del tuo browser. Nota: disabilitare i cookie necessari
potrebbe impedire l'accesso alla piattaforma.
</li>
</ul>
<table style={{ marginTop: '1rem' }}>
<thead>
<tr><th>Browser</th><th>Come gestire i cookie</th></tr>
</thead>
<tbody>
<tr><td>Chrome</td><td>Impostazioni → Privacy e sicurezza → Cookie e altri dati dei siti</td></tr>
<tr><td>Firefox</td><td>Impostazioni → Privacy e sicurezza → Cookie e dati del sito</td></tr>
<tr><td>Safari</td><td>Preferenze → Privacy → Gestisci dati dei siti web</td></tr>
<tr><td>Edge</td><td>Impostazioni → Cookie e autorizzazioni del sito</td></tr>
</tbody>
</table>
</Section>
<Section title="5. Aggiornamenti a questa policy">
<p>
Questa Cookie Policy potrebbe essere aggiornata per riflettere modifiche al servizio
o alla normativa applicabile. In caso di modifiche sostanziali, ti informeremo tramite
email o con un avviso nella piattaforma.
</p>
<p>
Per domande scrivi a <a href="mailto:info@leopost.it">info@leopost.it</a>.
</p>
</Section>
</LegalLayout>
)
}
function Section({ title, children }) {
return (
<section style={{ marginBottom: '2rem' }}>
<h2 style={{
fontFamily: "'Fraunces', serif",
fontSize: '1.1rem',
fontWeight: 600,
color: '#1A1A1A',
marginBottom: '0.75rem',
paddingBottom: '0.5rem',
borderBottom: '2px solid #E85A4F',
display: 'inline-block',
}}>
{title}
</h2>
<div style={{ fontSize: '0.9rem', color: '#1A1A1A', lineHeight: 1.7 }}>
{children}
</div>
</section>
)
}

View File

@@ -0,0 +1,97 @@
import { Link } from 'react-router-dom'
export default function LegalLayout({ title, updated, children }) {
return (
<div style={{
minHeight: '100vh',
backgroundColor: '#FFFBF5',
fontFamily: "'DM Sans', sans-serif",
}}>
{/* Header */}
<header style={{
borderBottom: '1px solid #E5E0D8',
padding: '1rem 1.5rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}>
<Link to="/" style={{ textDecoration: 'none' }}>
<span style={{ fontFamily: "'Fraunces', serif", fontSize: '1.4rem', fontWeight: 600, color: '#1A1A1A' }}>
Leopost
</span>
</Link>
<Link to="/" style={{ fontSize: '0.85rem', color: '#E85A4F', textDecoration: 'underline', textUnderlineOffset: '3px' }}>
Torna alla piattaforma
</Link>
</header>
{/* Content */}
<main style={{ maxWidth: 760, margin: '0 auto', padding: '3rem 1.5rem 4rem' }}>
<div style={{ marginBottom: '2.5rem' }}>
<span style={{
fontSize: '0.7rem',
fontWeight: 700,
letterSpacing: '0.12em',
textTransform: 'uppercase',
color: '#E85A4F',
}}>
Documenti legali
</span>
<div style={{ width: 48, height: 3, backgroundColor: '#E85A4F', margin: '0.5rem 0' }} />
<h1 style={{
fontFamily: "'Fraunces', serif",
fontSize: '2rem',
fontWeight: 600,
color: '#1A1A1A',
letterSpacing: '-0.02em',
margin: '0 0 0.5rem',
}}>
{title}
</h1>
<p style={{ fontSize: '0.8rem', color: '#7A7A7A', margin: 0 }}>
Ultimo aggiornamento: {updated}
</p>
</div>
<div className="legal-content">
{children}
</div>
</main>
{/* Footer */}
<footer style={{
borderTop: '1px solid #E5E0D8',
padding: '1.5rem',
textAlign: 'center',
backgroundColor: '#F5F0E8',
}}>
<div style={{ display: 'flex', justifyContent: 'center', gap: '1.5rem', flexWrap: 'wrap', marginBottom: '0.75rem' }}>
<Link to="/privacy" style={footerLink}>Privacy Policy</Link>
<Link to="/termini" style={footerLink}>Termini di Servizio</Link>
<Link to="/cookie" style={footerLink}>Cookie Policy</Link>
</div>
<p style={{ fontSize: '0.75rem', color: '#7A7A7A', margin: 0 }}>
© {new Date().getFullYear()} Leopost · <a href="mailto:info@leopost.it" style={footerLink}>info@leopost.it</a>
</p>
</footer>
<style>{`
.legal-content ul { padding-left: 1.5rem; margin: 0.75rem 0; }
.legal-content ul li { margin-bottom: 0.4rem; }
.legal-content a { color: #E85A4F; }
.legal-content table { width: 100%; border-collapse: collapse; margin: 0.75rem 0; font-size: 0.85rem; }
.legal-content th { background: #F5F0E8; padding: 0.6rem 0.875rem; text-align: left; font-weight: 600; border-bottom: 2px solid #E5E0D8; }
.legal-content td { padding: 0.6rem 0.875rem; border-bottom: 1px solid #E5E0D8; vertical-align: top; }
.legal-content tr:last-child td { border-bottom: none; }
.legal-content p { margin: 0 0 0.75rem; }
.legal-content strong { font-weight: 600; }
`}</style>
</div>
)
}
const footerLink = {
fontSize: '0.8rem',
color: '#4A4A4A',
textDecoration: 'none',
}

View File

@@ -0,0 +1,123 @@
import { Link } from 'react-router-dom'
import LegalLayout from './LegalLayout'
export default function PrivacyPolicy() {
return (
<LegalLayout title="Informativa sulla Privacy" updated="1 aprile 2026">
<Section title="1. Titolare del trattamento">
<p>
Il titolare del trattamento dei dati personali è <strong>Michele Borraccia</strong>,
contattabile all'indirizzo email: <a href="mailto:info@leopost.it">info@leopost.it</a>.
</p>
<p>
Leopost è una piattaforma SaaS in fase beta per la gestione e automazione dei contenuti
editoriali sui social media.
</p>
</Section>
<Section title="2. Dati raccolti">
<p>Raccogliamo i seguenti dati personali:</p>
<ul>
<li><strong>Dati di registrazione:</strong> indirizzo email, nome visualizzato, password (in forma cifrata)</li>
<li><strong>Dati OAuth:</strong> se accedi tramite Google, riceviamo nome, email e identificativo univoco dal provider</li>
<li><strong>Dati di utilizzo:</strong> numero di post generati, data di registrazione, piano di abbonamento</li>
<li><strong>Credenziali social:</strong> token di accesso alle piattaforme che colleghi volontariamente (Facebook, Instagram, YouTube, TikTok) — salvati in forma cifrata e utilizzati esclusivamente per pubblicare contenuti a tuo nome</li>
<li><strong>Chiavi API:</strong> eventuali chiavi API di provider AI che inserisci nelle impostazioni, salvate in forma cifrata</li>
<li><strong>Dati tecnici:</strong> log di accesso, indirizzo IP (per sicurezza e prevenzione abusi)</li>
</ul>
</Section>
<Section title="3. Finalità e base giuridica">
<table>
<thead>
<tr><th>Finalità</th><th>Base giuridica</th></tr>
</thead>
<tbody>
<tr><td>Erogazione del servizio (autenticazione, generazione contenuti, pubblicazione sui social)</td><td>Esecuzione del contratto (Art. 6.1.b GDPR)</td></tr>
<tr><td>Sicurezza del servizio, prevenzione frodi</td><td>Legittimo interesse (Art. 6.1.f GDPR)</td></tr>
<tr><td>Comunicazioni sul servizio (avvisi tecnici, aggiornamenti importanti)</td><td>Esecuzione del contratto</td></tr>
<tr><td>Analisi aggregata dell'utilizzo per migliorare il prodotto</td><td>Consenso (Art. 6.1.a GDPR)</td></tr>
<tr><td>Rispetto di obblighi legali</td><td>Obbligo legale (Art. 6.1.c GDPR)</td></tr>
</tbody>
</table>
</Section>
<Section title="4. Terze parti e sub-responsabili">
<p>Per erogare il servizio ci avvaliamo dei seguenti fornitori:</p>
<ul>
<li><strong>Hetzner / Hostinger</strong> hosting del server e del database</li>
<li><strong>Google LLC</strong> Google OAuth per l'accesso, YouTube API per la pubblicazione</li>
<li><strong>Meta Platforms Inc.</strong> — Facebook Graph API e Instagram API per la pubblicazione</li>
<li><strong>TikTok Inc.</strong> — TikTok Content Posting API</li>
<li><strong>OpenAI / Anthropic / Google</strong> — provider AI per la generazione dei contenuti (solo se configurati dall'utente con proprie API key)</li>
</ul>
<p>I dati non vengono ceduti a terzi per finalità di marketing o profilazione commerciale.</p>
</Section>
<Section title="5. Conservazione dei dati">
<ul>
<li><strong>Account attivi:</strong> i dati sono conservati per tutta la durata del rapporto contrattuale</li>
<li><strong>Account cancellati:</strong> i dati sono eliminati entro 30 giorni dalla richiesta di cancellazione</li>
<li><strong>Log tecnici:</strong> conservati per un massimo di 90 giorni</li>
<li><strong>Backup:</strong> i backup del database vengono conservati per 7 giorni in locale e archiviati su Google Drive per un massimo di 90 giorni</li>
</ul>
</Section>
<Section title="6. I tuoi diritti (GDPR)">
<p>In qualità di interessato hai il diritto di:</p>
<ul>
<li><strong>Accesso</strong> richiedere una copia dei tuoi dati personali</li>
<li><strong>Rettifica</strong> correggere dati inesatti o incompleti</li>
<li><strong>Cancellazione</strong> richiedere la cancellazione del tuo account e dei relativi dati</li>
<li><strong>Portabilità</strong> ricevere i tuoi dati in formato strutturato e leggibile da macchina</li>
<li><strong>Opposizione</strong> opporti al trattamento basato su legittimo interesse</li>
<li><strong>Revoca del consenso</strong> revocare in qualsiasi momento il consenso precedentemente prestato</li>
</ul>
<p>
Per esercitare i tuoi diritti scrivi a{' '}
<a href="mailto:info@leopost.it">info@leopost.it</a>.
Risponderemo entro 30 giorni. Hai inoltre il diritto di proporre reclamo al{' '}
<a href="https://www.garanteprivacy.it" target="_blank" rel="noreferrer">Garante per la protezione dei dati personali</a>.
</p>
</Section>
<Section title="7. Cookie">
<p>
Per informazioni dettagliate sui cookie utilizzati consulta la nostra{' '}
<Link to="/cookie">Cookie Policy</Link>.
</p>
</Section>
<Section title="8. Modifiche alla presente informativa">
<p>
Ci riserviamo il diritto di aggiornare questa informativa. In caso di modifiche sostanziali
ti informeremo tramite email o con un avviso in evidenza nella piattaforma.
</p>
</Section>
</LegalLayout>
)
}
function Section({ title, children }) {
return (
<section style={{ marginBottom: '2rem' }}>
<h2 style={{
fontFamily: "'Fraunces', serif",
fontSize: '1.1rem',
fontWeight: 600,
color: '#1A1A1A',
marginBottom: '0.75rem',
paddingBottom: '0.5rem',
borderBottom: '2px solid #E85A4F',
display: 'inline-block',
}}>
{title}
</h2>
<div style={{ fontSize: '0.9rem', color: '#1A1A1A', lineHeight: 1.7 }}>
{children}
</div>
</section>
)
}

View File

@@ -0,0 +1,182 @@
import LegalLayout from './LegalLayout'
export default function TermsOfService() {
return (
<LegalLayout title="Termini di Servizio" updated="1 aprile 2026">
<Section title="1. Accettazione dei termini">
<p>
Utilizzando Leopost accetti i presenti Termini di Servizio. Se non li accetti,
non puoi utilizzare la piattaforma.
</p>
<p>
<strong>Leopost è attualmente in fase Beta.</strong> Il servizio viene fornito "così com'è"
e potrebbe essere soggetto a modifiche, interruzioni o discontinuità senza preavviso.
Utilizzando Leopost in questa fase accetti le condizioni specifiche per gli Early Adopter
descritte alla sezione 9.
</p>
</Section>
<Section title="2. Descrizione del servizio">
<p>
Leopost è una piattaforma SaaS che consente agli utenti di:
</p>
<ul>
<li>Creare e gestire personaggi editoriali per la produzione di contenuti</li>
<li>Generare contenuti per social media tramite intelligenza artificiale</li>
<li>Pianificare e schedulare la pubblicazione sui principali social network</li>
<li>Monitorare e gestire commenti e interazioni</li>
<li>Accedere a statistiche editoriali e calendari di contenuto</li>
</ul>
</Section>
<Section title="3. Account e responsabilità">
<p>Per utilizzare Leopost devi:</p>
<ul>
<li>Avere almeno 18 anni o essere autorizzato da un genitore/tutore</li>
<li>Fornire informazioni accurate e aggiornate durante la registrazione</li>
<li>Mantenere la riservatezza delle credenziali di accesso</li>
<li>Essere responsabile di tutte le attività svolte tramite il tuo account</li>
</ul>
<p>
Sei tenuto a notificare immediatamente eventuali accessi non autorizzati al tuo account
scrivendo a <a href="mailto:info@leopost.it">info@leopost.it</a>.
</p>
</Section>
<Section title="4. Contenuti generati dall'utente">
<p>
Sei l'unico responsabile dei contenuti che crei, pubblichi o distribuisci tramite Leopost.
Ti impegni a non utilizzare la piattaforma per:
</p>
<ul>
<li>Pubblicare contenuti illegali, diffamatori, violenti o discriminatori</li>
<li>Violare diritti di proprietà intellettuale di terzi</li>
<li>Diffondere spam, malware o contenuti ingannevoli</li>
<li>Impersonare altre persone o organizzazioni</li>
<li>Violare le policy delle piattaforme social a cui colleghi l'account</li>
</ul>
<p>
Leopost non monitora preventivamente i contenuti, ma si riserva il diritto di sospendere
account che violano le presenti condizioni.
</p>
</Section>
<Section title="5. Piani di abbonamento">
<table>
<thead>
<tr><th>Piano</th><th>Caratteristiche</th><th>Limitazioni</th></tr>
</thead>
<tbody>
<tr>
<td><strong>Freemium</strong></td>
<td>Accesso base alla piattaforma, generazione contenuti limitata</td>
<td>Limite mensile di post generati, funzioni avanzate non disponibili</td>
</tr>
<tr>
<td><strong>Pro</strong></td>
<td>Accesso completo, generazione illimitata, tutte le integrazioni social</td>
<td>Soggetto a fair use policy</td>
</tr>
</tbody>
</table>
<p>
I piani a pagamento si attivano tramite codici di riscatto (durante la fase Beta)
o tramite abbonamento ricorrente (quando disponibile). I prezzi sono indicati sulla pagina
dei piani all'interno della piattaforma.
</p>
</Section>
<Section title="6. Proprietà intellettuale">
<p>
Leopost e il suo design, codice sorgente, logo e contenuti originali sono di proprietà
di Michele Borraccia. Non puoi copiare, modificare o distribuire questi elementi senza
autorizzazione scritta.
</p>
<p>
I contenuti da te generati tramite la piattaforma rimangono di tua proprietà. Concedi
a Leopost una licenza limitata per elaborare e pubblicare tali contenuti in conformità
con le istruzioni da te fornite.
</p>
</Section>
<Section title="7. Limitazione di responsabilità">
<p>
Leopost non è responsabile per:
</p>
<ul>
<li>Danni derivanti da interruzioni del servizio o perdita di dati</li>
<li>Contenuti generati dall'AI che potrebbero risultare inesatti o non appropriati</li>
<li>Azioni delle piattaforme social di terze parti (es. sospensione account)</li>
<li>Danni indiretti, consequenziali o lucro cessante</li>
</ul>
<p>
La responsabilità massima di Leopost nei tuoi confronti è limitata all'importo
da te pagato negli ultimi 12 mesi per il servizio.
</p>
</Section>
<Section title="8. Modifiche e interruzione del servizio">
<p>
Leopost si riserva il diritto di modificare, sospendere o interrompere il servizio
in qualsiasi momento. Ti notificheremo con almeno 30 giorni di anticipo in caso di
interruzione definitiva del servizio, tramite email all'indirizzo registrato.
</p>
<p>
In caso di modifiche sostanziali ai presenti termini, ti notificheremo via email.
L'uso continuato della piattaforma dopo la notifica costituisce accettazione delle modifiche.
</p>
</Section>
<Section title="9. Condizioni Early Adopter (Beta)">
<p>
Durante la fase Beta, gli Early Adopter beneficiano di:
</p>
<ul>
<li><strong>30 giorni di piano Pro gratuito</strong> tramite codice di riscatto</li>
<li>Accesso prioritario alle nuove funzionalità</li>
<li>Canale diretto di feedback con il team</li>
</ul>
<p>
In cambio, gli Early Adopter accettano che il servizio possa presentare malfunzionamenti
e si impegnano a fornire feedback costruttivo quando possibile.
Il periodo Beta potrebbe terminare senza preavviso.
</p>
</Section>
<Section title="10. Legge applicabile e foro competente">
<p>
I presenti termini sono regolati dalla legge italiana. Per qualsiasi controversia
è competente il Tribunale di Bari, salvo diversa normativa inderogabile applicabile
al consumatore.
</p>
<p>
Per qualsiasi domanda scrivi a <a href="mailto:info@leopost.it">info@leopost.it</a>.
</p>
</Section>
</LegalLayout>
)
}
function Section({ title, children }) {
return (
<section style={{ marginBottom: '2rem' }}>
<h2 style={{
fontFamily: "'Fraunces', serif",
fontSize: '1.1rem',
fontWeight: 600,
color: '#1A1A1A',
marginBottom: '0.75rem',
paddingBottom: '0.5rem',
borderBottom: '2px solid #E85A4F',
display: 'inline-block',
}}>
{title}
</h2>
<div style={{ fontSize: '0.9rem', color: '#1A1A1A', lineHeight: 1.7 }}>
{children}
</div>
</section>
)
}