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

2
.gitignore vendored
View File

@@ -34,3 +34,5 @@ npm-debug.log*
data/ data/
*.db *.db
*.sqlite *.sqlite
pgdata/
backups/

View File

@@ -131,6 +131,25 @@ def me(user: User = Depends(get_current_user)):
return _user_response(user) return _user_response(user)
class UpdateProfileRequest(BaseModel):
display_name: str
@router.put("/me")
def update_profile(
request: UpdateProfileRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Update user display name."""
name = request.display_name.strip()
if not name:
raise HTTPException(status_code=400, detail="Il nome non può essere vuoto.")
current_user.display_name = name
db.commit()
db.refresh(current_user)
return _user_response(current_user)
@router.post("/logout") @router.post("/logout")
def logout(): def logout():
"""Logout — client should remove the token.""" """Logout — client should remove the token."""
@@ -160,11 +179,16 @@ def oauth_google_start():
@router.get("/oauth/google/callback") @router.get("/oauth/google/callback")
async def oauth_google_callback(code: str, state: Optional[str] = None, db: Session = Depends(get_db)): async def oauth_google_callback(code: str, state: Optional[str] = None, error: Optional[str] = None, db: Session = Depends(get_db)):
"""Exchange Google OAuth code for token, create/find user, redirect with JWT.""" """Exchange Google OAuth code for token, create/find user, redirect with JWT."""
if not settings.google_client_id or not settings.google_client_secret: # Handle Google OAuth user denial or access errors
raise HTTPException(status_code=501, detail="Google OAuth non configurato.") if error:
return RedirectResponse(url=f"{settings.app_url}/login?oauth_error={error}")
if not settings.google_client_id or not settings.google_client_secret:
return RedirectResponse(url=f"{settings.app_url}/login?oauth_error=non_configurato")
try:
# Exchange code for tokens # Exchange code for tokens
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
token_resp = await client.post( token_resp = await client.post(
@@ -178,7 +202,7 @@ async def oauth_google_callback(code: str, state: Optional[str] = None, db: Sess
}, },
) )
if token_resp.status_code != 200: if token_resp.status_code != 200:
raise HTTPException(status_code=400, detail="Errore scambio token Google.") return RedirectResponse(url=f"{settings.app_url}/login?oauth_error=token_exchange")
token_data = token_resp.json() token_data = token_resp.json()
# Get user info # Get user info
@@ -187,7 +211,7 @@ async def oauth_google_callback(code: str, state: Optional[str] = None, db: Sess
headers={"Authorization": f"Bearer {token_data['access_token']}"}, headers={"Authorization": f"Bearer {token_data['access_token']}"},
) )
if userinfo_resp.status_code != 200: if userinfo_resp.status_code != 200:
raise HTTPException(status_code=400, detail="Errore recupero profilo Google.") return RedirectResponse(url=f"{settings.app_url}/login?oauth_error=userinfo")
google_user = userinfo_resp.json() google_user = userinfo_resp.json()
google_id = google_user.get("sub") google_id = google_user.get("sub")
@@ -195,6 +219,9 @@ async def oauth_google_callback(code: str, state: Optional[str] = None, db: Sess
name = google_user.get("name") name = google_user.get("name")
picture = google_user.get("picture") picture = google_user.get("picture")
if not google_id or not email:
return RedirectResponse(url=f"{settings.app_url}/login?oauth_error=missing_data")
# Find existing user by google_id or email # Find existing user by google_id or email
user = db.query(User).filter(User.google_id == google_id).first() user = db.query(User).filter(User.google_id == google_id).first()
if not user and email: if not user and email:
@@ -235,6 +262,11 @@ async def oauth_google_callback(code: str, state: Optional[str] = None, db: Sess
redirect_url = f"{settings.app_url}/auth/callback?token={jwt_token}" redirect_url = f"{settings.app_url}/auth/callback?token={jwt_token}"
return RedirectResponse(url=redirect_url) return RedirectResponse(url=redirect_url)
except Exception as e:
import logging
logging.getLogger(__name__).error(f"Google OAuth callback error: {e}", exc_info=True)
return RedirectResponse(url=f"{settings.app_url}/login?oauth_error=server_error")
# === Change password === # === Change password ===
@@ -300,3 +332,60 @@ def redeem_code(
"subscription_plan": current_user.subscription_plan, "subscription_plan": current_user.subscription_plan,
"subscription_expires_at": current_user.subscription_expires_at.isoformat(), "subscription_expires_at": current_user.subscription_expires_at.isoformat(),
} }
# === Export user data (GDPR) ===
@router.get("/export-data")
def export_user_data(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Export all personal data for the current user (GDPR compliance)."""
from app.models import Post, Character, AffiliateLinkModel, PublishingPlan, SocialAccount
posts = db.query(Post).filter(Post.user_id == current_user.id).all() if hasattr(Post, 'user_id') else []
data = {
"exported_at": datetime.utcnow().isoformat(),
"user": {
"id": current_user.id,
"username": current_user.username,
"email": current_user.email,
"display_name": current_user.display_name,
"auth_provider": current_user.auth_provider,
"subscription_plan": current_user.subscription_plan,
"subscription_expires_at": current_user.subscription_expires_at.isoformat() if current_user.subscription_expires_at else None,
"created_at": current_user.created_at.isoformat() if current_user.created_at else None,
},
}
from fastapi.responses import JSONResponse
from fastapi import Response
import json
content = json.dumps(data, ensure_ascii=False, indent=2)
return Response(
content=content,
media_type="application/json",
headers={"Content-Disposition": f'attachment; filename="leopost-data-{current_user.username}.json"'}
)
# === Delete account ===
class DeleteAccountRequest(BaseModel):
confirmation: str # must equal "ELIMINA"
@router.delete("/account")
def delete_account(
request: DeleteAccountRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Permanently delete the user account and all associated data."""
if request.confirmation != "ELIMINA":
raise HTTPException(status_code=400, detail="Conferma non valida. Digita ELIMINA per procedere.")
# Delete user (cascade should handle related records if FK set, else manual)
db.delete(current_user)
db.commit()
return {"message": "Account eliminato con successo."}

View File

@@ -96,12 +96,12 @@ def generate_content(
text = generate_post_text( text = generate_post_text(
character=char_dict, character=char_dict,
llm_provider=llm, llm_provider=llm,
platform=request.platform, platform=request.effective_platform,
topic_hint=request.topic_hint, topic_hint=request.topic_hint,
) )
# Generate hashtags # Generate hashtags
hashtags = generate_hashtags(text, llm, request.platform) hashtags = generate_hashtags(text, llm, request.effective_platform)
# Handle affiliate links # Handle affiliate links
affiliate_links_used: list[dict] = [] affiliate_links_used: list[dict] = []

View File

@@ -1,5 +1,5 @@
from datetime import datetime from datetime import datetime
from typing import Optional from typing import List, Optional
from pydantic import BaseModel from pydantic import BaseModel
@@ -103,13 +103,23 @@ class PostResponse(BaseModel):
class GenerateContentRequest(BaseModel): class GenerateContentRequest(BaseModel):
character_id: int character_id: int
platform: str = "instagram" platform: str = "instagram" # legacy single-platform (kept for compat)
content_type: str = "text" content_type: str = "text" # legacy single type (kept for compat)
platforms: List[str] = [] # new: multi-platform (overrides platform if non-empty)
content_types: List[str] = [] # new: multi-type (overrides content_type if non-empty)
topic_hint: Optional[str] = None topic_hint: Optional[str] = None
include_affiliates: bool = True include_affiliates: bool = True
provider: Optional[str] = None # override default LLM provider: Optional[str] = None
model: Optional[str] = None model: Optional[str] = None
@property
def effective_platform(self) -> str:
return self.platforms[0] if self.platforms else self.platform
@property
def effective_content_type(self) -> str:
return self.content_types[0] if self.content_types else self.content_type
class GenerateImageRequest(BaseModel): class GenerateImageRequest(BaseModel):
character_id: int character_id: int

27
backup.sh Executable file
View File

@@ -0,0 +1,27 @@
#!/bin/bash
set -euo pipefail
BACKUP_DIR="/opt/lab-leopost-full-prod/backups"
FNAME="leopost_$(date +%Y%m%d_%H%M).sql.gz"
GDRIVE_DIR="gdrive:Backups/Leopost"
LOG="$BACKUP_DIR/backup.log"
RETENTION_DAYS=7
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG"; }
log "--- Avvio backup $FNAME ---"
# 1. pg_dump → gzip
docker exec prod-leopost-postgres pg_dump -U leopost leopost | gzip > "$BACKUP_DIR/$FNAME"
SIZE=$(du -sh "$BACKUP_DIR/$FNAME" | cut -f1)
log "Dump creato: $FNAME ($SIZE)"
# 2. Upload su Google Drive
rclone copy "$BACKUP_DIR/$FNAME" "$GDRIVE_DIR" --log-level ERROR 2>>"$LOG"
log "Upload Drive completato: $GDRIVE_DIR/$FNAME"
# 3. Cleanup locale > 7 giorni
DELETED=$(find "$BACKUP_DIR" -name 'leopost_*.sql.gz' -mtime +$RETENTION_DAYS -print -delete | wc -l)
log "Cleanup: $DELETED file rimossi (retention ${RETENTION_DAYS}gg)"
log "--- Backup completato con successo ---"

View File

@@ -19,6 +19,10 @@ import CommentsQueue from './components/CommentsQueue'
import SettingsPage from './components/SettingsPage' import SettingsPage from './components/SettingsPage'
import EditorialCalendar from './components/EditorialCalendar' import EditorialCalendar from './components/EditorialCalendar'
import AdminSettings from './components/AdminSettings' import AdminSettings from './components/AdminSettings'
import CookieBanner from './components/CookieBanner'
import PrivacyPolicy from './components/legal/PrivacyPolicy'
import TermsOfService from './components/legal/TermsOfService'
import CookiePolicy from './components/legal/CookiePolicy'
const BASE_PATH = import.meta.env.VITE_BASE_PATH !== undefined const BASE_PATH = import.meta.env.VITE_BASE_PATH !== undefined
? (import.meta.env.VITE_BASE_PATH || '/') ? (import.meta.env.VITE_BASE_PATH || '/')
@@ -28,9 +32,16 @@ export default function App() {
return ( return (
<BrowserRouter basename={BASE_PATH}> <BrowserRouter basename={BASE_PATH}>
<AuthProvider> <AuthProvider>
<CookieBanner />
<Routes> <Routes>
{/* Public routes */}
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/auth/callback" element={<AuthCallback />} /> <Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/privacy" element={<PrivacyPolicy />} />
<Route path="/termini" element={<TermsOfService />} />
<Route path="/cookie" element={<CookiePolicy />} />
{/* Protected routes */}
<Route element={<ProtectedRoute />}> <Route element={<ProtectedRoute />}>
<Route element={<Layout />}> <Route element={<Layout />}>
<Route path="/" element={<Dashboard />} /> <Route path="/" element={<Dashboard />} />
@@ -46,6 +57,7 @@ export default function App() {
<Route path="/plans/new" element={<PlanForm />} /> <Route path="/plans/new" element={<PlanForm />} />
<Route path="/plans/:id/edit" element={<PlanForm />} /> <Route path="/plans/:id/edit" element={<PlanForm />} />
<Route path="/schedule" element={<ScheduleView />} /> <Route path="/schedule" element={<ScheduleView />} />
<Route path="/plans" element={<Navigate to="/editorial" />} />
<Route path="/social" element={<SocialAccounts />} /> <Route path="/social" element={<SocialAccounts />} />
<Route path="/comments" element={<CommentsQueue />} /> <Route path="/comments" element={<CommentsQueue />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />

View File

@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { api } from '../api' 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 = { const EMPTY_FORM = {
character_id: '', character_id: '',
@@ -26,11 +26,7 @@ export default function AffiliateForm() {
const [error, setError] = useState('') const [error, setError] = useState('')
const [loading, setLoading] = useState(isEdit) const [loading, setLoading] = useState(isEdit)
useEffect(() => { useEffect(() => { api.get('/characters/').then(setCharacters).catch(() => {}) }, [])
api.get('/characters/')
.then(setCharacters)
.catch(() => {})
}, [])
useEffect(() => { useEffect(() => {
if (isEdit) { if (isEdit) {
@@ -51,30 +47,18 @@ export default function AffiliateForm() {
} }
}, [id, isEdit]) }, [id, isEdit])
const handleChange = (field, value) => { const handleChange = (field, value) => setForm((prev) => ({ ...prev, [field]: value }))
setForm((prev) => ({ ...prev, [field]: value }))
}
const addTopic = () => { const addTopic = () => {
const topic = topicInput.trim() const topic = topicInput.trim()
if (topic && !form.topics.includes(topic)) { if (topic && !form.topics.includes(topic)) setForm((prev) => ({ ...prev, topics: [...prev.topics, topic] }))
setForm((prev) => ({ ...prev, topics: [...prev.topics, topic] }))
}
setTopicInput('') setTopicInput('')
} }
const removeTopic = (topic) => { const removeTopic = (topic) => setForm((prev) => ({ ...prev, topics: prev.topics.filter((t) => t !== topic) }))
setForm((prev) => ({
...prev,
topics: prev.topics.filter((t) => t !== topic),
}))
}
const handleTopicKeyDown = (e) => { const handleTopicKeyDown = (e) => {
if (e.key === 'Enter' || e.key === ',') { if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); addTopic() }
e.preventDefault()
addTopic()
}
} }
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
@@ -82,225 +66,185 @@ export default function AffiliateForm() {
setError('') setError('')
setSaving(true) setSaving(true)
try { try {
const payload = { const payload = { ...form, character_id: form.character_id ? parseInt(form.character_id) : null }
...form, if (isEdit) await api.put(`/affiliates/${id}`, payload)
character_id: form.character_id ? parseInt(form.character_id) : null, else await api.post('/affiliates/', payload)
}
if (isEdit) {
await api.put(`/affiliates/${id}`, payload)
} else {
await api.post('/affiliates/', payload)
}
navigate('/affiliates') navigate('/affiliates')
} catch (err) { } catch (err) {
setError(err.message || 'Errore nel salvataggio') setError(err.message || 'Errore nel salvataggio')
} finally { } finally { setSaving(false) }
setSaving(false)
}
} }
if (loading) { if (loading) return (
return ( <div style={{ display: 'flex', justifyContent: 'center', padding: '3rem 0' }}>
<div className="flex justify-center py-12"> <div style={{ width: 28, height: 28, border: '2px solid var(--border)', borderTopColor: 'var(--accent)', borderRadius: '50%', animation: 'spin 0.8s linear infinite' }} />
<div className="animate-spin rounded-full h-8 w-8 border-2 border-brand-500 border-t-transparent" /> <style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
</div> </div>
) )
}
return ( return (
<div> <div style={{ animation: 'fade-up 0.5s ease-out both' }}>
<div className="mb-6"> {/* Header */}
<h2 className="text-2xl font-bold text-slate-800"> <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'} {isEdit ? 'Modifica link affiliato' : 'Nuovo link affiliato'}
</h2> </h2>
<p className="text-slate-500 mt-1 text-sm"> <p style={{ fontSize: '0.875rem', color: 'var(--ink-muted)', margin: 0 }}>
{isEdit ? 'Aggiorna le informazioni del link' : 'Aggiungi un nuovo link affiliato'} {isEdit ? 'Aggiorna le informazioni del link' : 'Aggiungi un nuovo link affiliato al tuo catalogo'}
</p> </p>
</div> </div>
<form onSubmit={handleSubmit} className="max-w-2xl space-y-6">
{error && ( {error && (
<div className="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: '1.25rem' }}>
{error} {error}
</div> </div>
)} )}
{/* Main info */} <form onSubmit={handleSubmit} style={{ maxWidth: 680, display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
<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> {/* ── Informazioni link ────────────────────────────────── */}
<label className="block text-sm font-medium text-slate-700 mb-1"> <Section title="Informazioni link">
Personaggio <Field label="Personaggio (opzionale)">
<span className="text-slate-400 font-normal ml-1">(lascia vuoto per globale)</span> <select value={form.character_id} onChange={(e) => handleChange('character_id', e.target.value)} style={selectStyle}>
</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> <option value="">Globale (tutti i personaggi)</option>
{characters.map((c) => ( {characters.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select> </select>
</div> </Field>
<div> <Field label="Network">
<label className="block text-sm font-medium text-slate-700 mb-1"> <input type="text" value={form.network} onChange={(e) => handleChange('network', e.target.value)}
Network placeholder="Es. Amazon, ClickBank…" style={inputStyle} required
</label> onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
<div className="space-y-2"> <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem', marginTop: '0.5rem' }}>
<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) => ( {SUGGESTED_NETWORKS.map((net) => (
<button <button key={net} type="button" onClick={() => handleChange('network', net)} style={{
key={net} padding: '0.25rem 0.65rem', fontSize: '0.78rem', fontFamily: "'DM Sans', sans-serif",
type="button" border: 'none', cursor: 'pointer',
onClick={() => handleChange('network', net)} backgroundColor: form.network === net ? 'var(--ink)' : 'var(--cream-dark)',
className={`text-xs px-2 py-1 rounded-lg transition-colors ${ color: form.network === net ? 'white' : 'var(--ink-muted)',
form.network === net transition: 'background-color 0.15s',
? 'bg-brand-100 text-brand-700 border border-brand-200' }}>
: 'bg-slate-100 text-slate-500 hover:bg-slate-200 border border-transparent'
}`}
>
{net} {net}
</button> </button>
))} ))}
</div> </div>
</div> </Field>
</div>
<div> <Field label="Nome">
<label className="block text-sm font-medium text-slate-700 mb-1"> <input type="text" value={form.name} onChange={(e) => handleChange('name', e.target.value)}
Nome placeholder="Es. Corso Python, Hosting Premium, Libro XYZ…" style={inputStyle} required
</label> onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
<input </Field>
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> <Field label="URL completo">
<label className="block text-sm font-medium text-slate-700 mb-1"> <input type="url" value={form.url} onChange={(e) => handleChange('url', e.target.value)}
URL completo placeholder="https://example.com/ref/..." style={{ ...inputStyle, fontFamily: 'monospace', fontSize: '0.82rem' }} required
</label> onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
<input </Field>
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> <Field label="Tag di tracciamento">
<label className="block text-sm font-medium text-slate-700 mb-1"> <input type="text" value={form.tag} onChange={(e) => handleChange('tag', e.target.value)}
Tag di tracciamento placeholder="Es. ref-luigi, campagna-maggio…" style={{ ...inputStyle, fontFamily: 'monospace', fontSize: '0.82rem' }}
</label> onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
<input </Field>
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"> <div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<label className="relative inline-flex items-center cursor-pointer"> <button type="button" onClick={() => handleChange('is_active', !form.is_active)} style={{
<input width: 40, height: 22, borderRadius: 11, border: 'none', cursor: 'pointer',
type="checkbox" backgroundColor: form.is_active ? 'var(--accent)' : 'var(--border-strong)',
checked={form.is_active} position: 'relative', transition: 'background-color 0.2s', flexShrink: 0,
onChange={(e) => handleChange('is_active', e.target.checked)} }}>
className="sr-only peer" <span style={{
/> position: 'absolute', top: 2, left: form.is_active ? 20 : 2,
<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> width: 18, height: 18, borderRadius: '50%', backgroundColor: 'white', transition: 'left 0.2s',
</label> }} />
<span className="text-sm text-slate-700">Attivo</span> </button>
</div> <span style={{ fontSize: '0.875rem', color: 'var(--ink)' }}>Attivo</span>
</div> </div>
</Section>
{/* Topics */} {/* ── Topic correlati ──────────────────────────────────── */}
<div className="bg-white rounded-xl border border-slate-200 p-6 space-y-4"> <Section title="Topic correlati">
<h3 className="font-semibold text-slate-700 text-sm uppercase tracking-wider"> <p style={{ fontSize: '0.82rem', color: 'var(--ink-muted)', margin: 0, lineHeight: 1.5 }}>
Topic correlati I topic aiutano l'AI a scegliere il link giusto per ogni contenuto generato.
</h3>
<p className="text-xs text-slate-400 -mt-2">
I topic aiutano l'AI a scegliere il link giusto per ogni contenuto
</p> </p>
<div className="flex gap-2"> <div style={{ display: 'flex', gap: '0.5rem' }}>
<input <input type="text" value={topicInput} onChange={(e) => setTopicInput(e.target.value)}
type="text" onKeyDown={handleTopicKeyDown} placeholder="Scrivi un topic e premi Invio"
value={topicInput} style={{ ...inputStyle, flex: 1 }}
onChange={(e) => setTopicInput(e.target.value)} onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
onKeyDown={handleTopicKeyDown} <button type="button" onClick={addTopic} style={btnSecondary}>Aggiungi</button>
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> </div>
{form.topics.length > 0 && ( {form.topics.length > 0 && (
<div className="flex flex-wrap gap-2"> <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem' }}>
{form.topics.map((topic) => ( {form.topics.map((topic) => (
<span <span key={topic} style={{
key={topic} display: 'inline-flex', alignItems: 'center', gap: '0.4rem',
className="inline-flex items-center gap-1.5 px-3 py-1 bg-brand-50 text-brand-700 rounded-lg text-sm" padding: '0.25rem 0.7rem', fontSize: '0.82rem',
> backgroundColor: 'var(--ink)', color: 'white',
}}>
{topic} {topic}
<button <button type="button" onClick={() => removeTopic(topic)} style={{
type="button" background: 'none', border: 'none', color: 'rgba(255,255,255,0.7)', cursor: 'pointer',
onClick={() => removeTopic(topic)} padding: 0, fontSize: '1rem', lineHeight: 1, display: 'flex', alignItems: 'center',
className="text-brand-400 hover:text-brand-600" }}>×</button>
>
×
</button>
</span> </span>
))} ))}
</div> </div>
)} )}
</div> </Section>
{/* Actions */} {/* ── Actions ───────────────────────────────────────────── */}
<div className="flex items-center gap-3"> <div style={{ display: 'flex', gap: '0.75rem' }}>
<button <button type="submit" disabled={saving} style={{ ...btnPrimary, opacity: saving ? 0.6 : 1 }}>
type="submit" {saving ? 'Salvataggio' : isEdit ? 'Salva modifiche' : 'Crea link'}
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
</button> </button>
<button type="button" onClick={() => navigate('/affiliates')} style={btnSecondary}>Annulla</button>
</div> </div>
</form> </form>
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
</div> </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 { Link } from 'react-router-dom'
import { api } from '../api' import { api } from '../api'
const networkColors = { const NETWORK_COLORS = {
Amazon: 'bg-amber-50 text-amber-700', Amazon: { bg: '#FFF8E1', color: '#B45309' },
ClickBank: 'bg-emerald-50 text-emerald-700', ClickBank: { bg: '#ECFDF5', color: '#065F46' },
ShareASale: 'bg-blue-50 text-blue-700', ShareASale:{ bg: '#EFF6FF', color: '#1D4ED8' },
CJ: 'bg-violet-50 text-violet-700', CJ: { bg: '#F5F3FF', color: '#6D28D9' },
Impact: 'bg-rose-50 text-rose-700', Impact: { bg: '#FFF1F2', color: '#BE123C' },
} }
export default function AffiliateList() { export default function AffiliateList() {
@@ -16,203 +16,166 @@ export default function AffiliateList() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [filterCharacter, setFilterCharacter] = useState('') const [filterCharacter, setFilterCharacter] = useState('')
useEffect(() => { useEffect(() => { loadData() }, [])
loadData()
}, [])
const loadData = async () => { const loadData = async () => {
setLoading(true) setLoading(true)
try { try {
const [linksData, charsData] = await Promise.all([ const [linksData, charsData] = await Promise.all([
api.get('/affiliates/'), api.get('/affiliates/'), api.get('/characters/'),
api.get('/characters/'),
]) ])
setLinks(linksData) setLinks(linksData)
setCharacters(charsData) setCharacters(charsData)
} catch { } catch {} finally { setLoading(false) }
// silent
} finally {
setLoading(false)
}
} }
const getCharacterName = (id) => { const getCharacterName = (id) => {
if (!id) return 'Globale' if (!id) return 'Globale'
const c = characters.find((ch) => ch.id === id) return characters.find(c => c.id === id)?.name || '—'
return c ? c.name : '—'
}
const getNetworkColor = (network) => {
return networkColors[network] || 'bg-slate-100 text-slate-600'
} }
const handleToggle = async (link) => { const handleToggle = async (link) => {
try { await api.put(`/affiliates/${link.id}`, { is_active: !link.is_active }).catch(() => {})
await api.put(`/affiliates/${link.id}`, { is_active: !link.is_active })
loadData() loadData()
} catch {
// silent
}
} }
const handleDelete = async (id, name) => { const handleDelete = async (id, name) => {
if (!confirm(`Eliminare "${name}"?`)) return if (!confirm(`Eliminare "${name}"?`)) return
try { await api.delete(`/affiliates/${id}`).catch(() => {})
await api.delete(`/affiliates/${id}`)
loadData() loadData()
} catch {
// silent
}
} }
const truncateUrl = (url) => { const filtered = links.filter(l => {
if (!url) return '—'
if (url.length <= 50) return url
return url.substring(0, 50) + '...'
}
const filtered = links.filter((l) => {
if (filterCharacter === '') return true if (filterCharacter === '') return true
if (filterCharacter === 'global') return !l.character_id if (filterCharacter === 'global') return !l.character_id
return String(l.character_id) === filterCharacter return String(l.character_id) === filterCharacter
}) })
return ( return (
<div style={{ animation: 'fade-up 0.5s ease-out both' }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: '2rem' }}>
<div> <div>
<div className="flex items-center justify-between mb-6"> <span className="editorial-tag">Link Affiliati</span>
<div> <div className="editorial-line" />
<h2 className="text-2xl font-bold text-slate-800">Link Affiliati</h2> <h2 style={{ fontFamily: "'Fraunces', serif", fontSize: '1.75rem', fontWeight: 600, letterSpacing: '-0.02em', color: 'var(--ink)', margin: '0.5rem 0 0.3rem' }}>
<p className="text-slate-500 mt-1 text-sm"> Monetizzazione
Gestisci i link affiliati per la 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> </p>
</div> </div>
<Link <Link to="/affiliates/new" style={btnPrimary}>+ Nuovo Link</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>
</div> </div>
{/* Filters */} {/* Filter */}
<div className="flex flex-wrap gap-3 mb-6"> {characters.length > 0 && (
<select <div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '1.5rem' }}>
value={filterCharacter} <label style={{ fontSize: '0.78rem', fontWeight: 600, color: 'var(--ink-muted)', textTransform: 'uppercase', letterSpacing: '0.08em' }}>Filtra per</label>
onChange={(e) => setFilterCharacter(e.target.value)} <select value={filterCharacter} onChange={e => setFilterCharacter(e.target.value)} style={selectStyle}>
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="">Tutti</option>
<option value="global">Globale</option> <option value="global">Globale</option>
{characters.map((c) => ( {characters.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select> </select>
<span style={{ fontSize: '0.78rem', color: 'var(--ink-muted)', marginLeft: 'auto' }}>{filtered.length} link</span>
<span className="flex items-center text-xs text-slate-400 ml-auto">
{filtered.length} link
</span>
</div> </div>
)}
{loading ? ( {loading ? (
<div className="flex justify-center py-12"> <Spinner />
<div className="animate-spin rounded-full h-8 w-8 border-2 border-brand-500 border-t-transparent" />
</div>
) : filtered.length === 0 ? ( ) : filtered.length === 0 ? (
<div className="text-center py-16 bg-white rounded-xl border border-slate-200"> <EmptyState
<p className="text-4xl mb-3"></p> icon="⟁"
<p className="text-slate-500 font-medium">Nessun link affiliato</p> title="Nessun link affiliato"
<p className="text-slate-400 text-sm mt-1"> description="Aggiungi i link affiliati dei tuoi programmi (Amazon, ClickBank, ecc.) e Leopost li inserirà automaticamente nei contenuti pertinenti."
Aggiungi i tuoi primi link affiliati per monetizzare i contenuti cta="+ Aggiungi primo link"
</p>
<Link
to="/affiliates/new" 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>
) : ( ) : (
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden"> <div style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)', overflowX: 'auto' }}>
<div className="overflow-x-auto"> <table style={{ width: '100%', fontSize: '0.85rem', borderCollapse: 'collapse' }}>
<table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-slate-100"> <tr style={{ borderBottom: '2px solid var(--border)' }}>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wider">Network</th> {['Network','Nome','URL','Personaggio','Stato','Click',''].map(h => (
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wider">Nome</th> <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>
<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> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-50"> <tbody>
{filtered.map((link) => ( {filtered.map(link => {
<tr key={link.id} className="hover:bg-slate-50 transition-colors"> const nc = NETWORK_COLORS[link.network] || { bg: 'var(--cream-dark)', color: 'var(--ink-muted)' }
<td className="px-4 py-3"> return (
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${getNetworkColor(link.network)}`}> <tr key={link.id} style={{ borderBottom: '1px solid var(--border)' }}
{link.network || ''} onMouseEnter={e => e.currentTarget.style.backgroundColor = 'var(--cream)'}
</span> onMouseLeave={e => e.currentTarget.style.backgroundColor = 'transparent'}
</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>
<td className="px-4 py-3 text-slate-500 hidden lg:table-cell">
<span className="font-mono text-xs">{link.tag || '—'}</span>
</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 <td style={{ padding: '0.75rem 1rem' }}>
</Link> <span style={{ fontSize: '0.72rem', fontWeight: 600, padding: '0.2rem 0.5rem', backgroundColor: nc.bg, color: nc.color }}>{link.network || '—'}</span>
<button </td>
onClick={() => handleToggle(link)} <td style={{ padding: '0.75rem 1rem', fontWeight: 600, color: 'var(--ink)' }}>{link.name}</td>
className="text-xs px-2 py-1 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded transition-colors" <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 ? '…' : ''}
{link.is_active ? 'Disattiva' : 'Attiva'} </td>
</button> <td style={{ padding: '0.75rem 1rem', color: 'var(--ink-muted)', fontSize: '0.82rem' }}>{getCharacterName(link.character_id)}</td>
<button <td style={{ padding: '0.75rem 1rem' }}>
onClick={() => handleDelete(link.id, link.name)} <span style={{ display: 'inline-block', width: 8, height: 8, borderRadius: '50%', backgroundColor: link.is_active ? 'var(--success)' : 'var(--border-strong)' }} />
className="text-xs px-2 py-1 text-red-500 hover:bg-red-50 rounded transition-colors" </td>
> <td style={{ padding: '0.75rem 1rem', color: 'var(--ink-muted)' }}>{link.click_count ?? 0}</td>
Elimina <td style={{ padding: '0.75rem 1rem' }}>
</button> <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> </div>
</td> </td>
</tr> </tr>
))} )
})}
</tbody> </tbody>
</table> </table>
</div> </div>
</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',
}
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 { useNavigate, useSearchParams } from 'react-router-dom'
import { useAuth } from '../AuthContext' 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() { export default function AuthCallback() {
const [params] = useSearchParams() const [params] = useSearchParams()
const navigate = useNavigate() const navigate = useNavigate()
@@ -9,27 +19,31 @@ export default function AuthCallback() {
useEffect(() => { useEffect(() => {
const token = params.get('token') const token = params.get('token')
const oauthError = params.get('oauth_error')
if (token) { if (token) {
loginWithToken(token) loginWithToken(token)
navigate('/', { replace: true }) navigate('/', { replace: true })
} else if (oauthError) {
const msg = ERROR_MESSAGES[oauthError] || ERROR_MESSAGES.default
navigate(`/login?error=${encodeURIComponent(msg)}`, { replace: true })
} else { } else {
navigate('/login', { replace: true }) navigate('/login', { replace: true })
} }
}, []) }, [])
return ( 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={{ textAlign: 'center' }}>
<div style={{ <div style={{
width: 40, width: 40, height: 40,
height: 40, border: '3px solid #E85A4F',
border: '3px solid #FF6B4A',
borderTopColor: 'transparent', borderTopColor: 'transparent',
borderRadius: '50%', borderRadius: '50%',
animation: 'spin 0.8s linear infinite', animation: 'spin 0.8s linear infinite',
margin: '0 auto 1rem', 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> </div>
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style> <style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</div> </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 { useState, useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { api } from '../api' import { api } from '../api'
import { useAuth } from '../AuthContext'
const EMPTY_FORM = { const EMPTY_FORM = {
name: '', name: '',
niche: '', niche: '',
topics: [], topics: [],
tone: '', tone: '',
visual_style: { primary_color: '#f97316', secondary_color: '#1e293b', font: '' }, visual_style: { primary_color: '#E85A4F', secondary_color: '#1A1A1A', font: '' },
is_active: true, is_active: true,
} }
const PLATFORMS = [ const NICHE_CHIPS = [
{ 'Food & Ricette', 'Fitness & Sport', 'Tech & AI', 'Beauty & Skincare',
id: 'facebook', 'Fashion & Style', 'Travel & Lifestyle', 'Finance & Investimenti', 'Salute & Wellness',
name: 'Facebook', 'Gaming', 'Business & Marketing', 'Ambiente & Sostenibilità', 'Arte & Design',
icon: '📘', 'Musica', 'Educazione', 'Cucina Italiana', 'Automotive',
color: '#1877F2', ]
guide: [
'Vai su developers.facebook.com e accedi con il tuo account.', const TOPIC_CHIPS = [
'Crea una nuova App → scegli "Business".', 'Tutorial', 'Before/After', 'Tips & Tricks', 'Dietro le quinte',
'Aggiungi il prodotto "Facebook Login" e "Pages API".', 'Recensione prodotto', 'Unboxing', 'Trend del momento', 'FAQ',
'In "Graph API Explorer", seleziona la tua app e la tua Pagina.', 'Motivazione', 'Case study', 'Confronto', 'Sfida / Challenge',
'Genera un Page Access Token con permessi: pages_manage_posts, pages_read_engagement.', 'News del settore', 'Ispirazione', 'Lista consigli', 'Storia personale',
'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,
},
] ]
export default function CharacterForm() { export default function CharacterForm() {
const { id } = useParams() const { id } = useParams()
const isEdit = Boolean(id) const isEdit = Boolean(id)
const navigate = useNavigate() const navigate = useNavigate()
const { isPro } = useAuth()
const [activeTab, setActiveTab] = useState('profile')
const [form, setForm] = useState(EMPTY_FORM) const [form, setForm] = useState(EMPTY_FORM)
const [topicInput, setTopicInput] = useState('') const [topicInput, setTopicInput] = useState('')
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [loading, setLoading] = useState(isEdit) 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(() => { useEffect(() => {
if (isEdit) { if (isEdit) {
api.get(`/characters/${id}`) api.get(`/characters/${id}`)
@@ -103,8 +46,8 @@ export default function CharacterForm() {
topics: data.topics || [], topics: data.topics || [],
tone: data.tone || '', tone: data.tone || '',
visual_style: { visual_style: {
primary_color: data.visual_style?.primary_color || '#f97316', primary_color: data.visual_style?.primary_color || '#E85A4F',
secondary_color: data.visual_style?.secondary_color || '#1e293b', secondary_color: data.visual_style?.secondary_color || '#1A1A1A',
font: data.visual_style?.font || '', font: data.visual_style?.font || '',
}, },
is_active: data.is_active ?? true, is_active: data.is_active ?? true,
@@ -112,49 +55,24 @@ export default function CharacterForm() {
}) })
.catch(() => setError('Personaggio non trovato')) .catch(() => setError('Personaggio non trovato'))
.finally(() => setLoading(false)) .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]) }, [id, isEdit])
const handleChange = (field, value) => { const handleChange = (field, value) => setForm((prev) => ({ ...prev, [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) => { const addTopic = (t) => {
setForm((prev) => ({ const topic = (t || topicInput).trim()
...prev,
visual_style: { ...prev.visual_style, [field]: value },
}))
}
const addTopic = () => {
const topic = topicInput.trim()
if (topic && !form.topics.includes(topic)) { if (topic && !form.topics.includes(topic)) {
setForm((prev) => ({ ...prev, topics: [...prev.topics, topic] })) setForm((prev) => ({ ...prev, topics: [...prev.topics, topic] }))
} }
setTopicInput('') if (!t) setTopicInput('')
} }
const removeTopic = (topic) => { const removeTopic = (topic) => setForm((prev) => ({ ...prev, topics: prev.topics.filter((t) => t !== topic) }))
setForm((prev) => ({
...prev,
topics: prev.topics.filter((t) => t !== topic),
}))
}
const handleTopicKeyDown = (e) => { const handleTopicKeyDown = (e) => {
if (e.key === 'Enter' || e.key === ',') { if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); addTopic() }
e.preventDefault()
addTopic()
}
} }
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
@@ -162,471 +80,252 @@ export default function CharacterForm() {
setError('') setError('')
setSaving(true) setSaving(true)
try { try {
if (isEdit) { if (isEdit) { await api.put(`/characters/${id}`, form) }
await api.put(`/characters/${id}`, form) else { await api.post('/characters/', form) }
} else {
await api.post('/characters/', form)
}
navigate('/characters') navigate('/characters')
} catch (err) { } catch (err) {
if (err.data?.upgrade_required) { if (err.data?.upgrade_required) setError(err.data.message || 'Limite piano raggiunto. Passa a Pro.')
setError(err.data.message || 'Limite piano raggiunto. Passa a Pro.') else setError(err.message || 'Errore nel salvataggio')
} else { } finally { setSaving(false) }
setError(err.message || 'Errore nel salvataggio')
}
} finally {
setSaving(false)
}
} }
const handleSaveToken = async (platform) => { if (loading) return (
if (!isEdit) return <div style={{ display: 'flex', justifyContent: 'center', padding: '3rem 0' }}>
const token = tokenInputs[platform] || '' <div style={{ width: 28, height: 28, border: '2px solid var(--border)', borderTopColor: 'var(--accent)', borderRadius: '50%', animation: 'spin 0.8s linear infinite' }} />
const pageId = pageIdInputs[platform] || '' <style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
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> </div>
) )
}
return ( return (
<div> <div style={{ animation: 'fade-up 0.5s ease-out both' }}>
<div className="mb-6"> {/* Header */}
<h2 className="text-2xl font-bold text-slate-800"> <div style={{ marginBottom: '2rem' }}>
{isEdit ? 'Modifica personaggio' : 'Nuovo personaggio'} <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> </h2>
<p className="text-slate-500 mt-1 text-sm"> <p style={{ fontSize: '0.875rem', color: 'var(--ink-muted)', margin: 0 }}>
{isEdit ? 'Aggiorna il profilo editoriale' : 'Crea un nuovo profilo editoriale'} Il personaggio è la voce editoriale. Definisci nicchia, tono e topic ricorrenti: l'AI li userà ogni volta che genera contenuti.
</p> </p>
</div> </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 && ( {error && (
<div className="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: '1.25rem' }}>
{error} {error}
</div> </div>
)} )}
{/* Basic info */} <form onSubmit={handleSubmit} style={{ maxWidth: 680, display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
<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> {/* ── Informazioni base ─────────────────────────────────── */}
<label className="block text-sm font-medium text-slate-700 mb-1"> <Section title="Informazioni base">
Nome personaggio <Field label="Nome personaggio">
</label> <input type="text" value={form.name} onChange={(e) => handleChange('name', e.target.value)}
<input placeholder="Es. TechGuru, FoodBlogger, FitCoach…" style={inputStyle} required
type="text" onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
value={form.name} </Field>
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> <Field label="Nicchia / Settore">
<label className="block text-sm font-medium text-slate-700 mb-1"> <input type="text" value={form.niche} onChange={(e) => handleChange('niche', e.target.value)}
Niche / Settore placeholder="Es. Food & Ricette, Tech & AI, Fitness…" style={inputStyle} required
</label> onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
<input <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem', marginTop: '0.6rem' }}>
type="text" {NICHE_CHIPS.map(n => (
value={form.niche} <button key={n} type="button" onClick={() => handleChange('niche', n)} style={{
onChange={(e) => handleChange('niche', e.target.value)} padding: '0.25rem 0.7rem', fontSize: '0.78rem', fontFamily: "'DM Sans', sans-serif",
placeholder="Es. Tecnologia, Food, Fitness..." border: 'none', cursor: 'pointer',
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" backgroundColor: form.niche === n ? 'var(--ink)' : 'var(--cream-dark)',
required color: form.niche === n ? 'white' : 'var(--ink-muted)',
/> transition: 'background-color 0.15s',
</div> }}>
{n}
<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> </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> </div>
{form.topics.length > 0 && ( {form.topics.length > 0 && (
<div className="flex flex-wrap gap-2"> <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem', marginTop: '0.75rem' }}>
{form.topics.map((topic) => ( {form.topics.map((topic) => (
<span <span key={topic} style={{
key={topic} display: 'inline-flex', alignItems: 'center', gap: '0.4rem',
className="inline-flex items-center gap-1.5 px-3 py-1 bg-brand-50 text-brand-700 rounded-lg text-sm" padding: '0.25rem 0.7rem', fontSize: '0.82rem',
> backgroundColor: 'var(--ink)', color: 'white',
}}>
{topic} {topic}
<button <button type="button" onClick={() => removeTopic(topic)} style={{
type="button" background: 'none', border: 'none', color: 'rgba(255,255,255,0.7)', cursor: 'pointer',
onClick={() => removeTopic(topic)} padding: 0, fontSize: '1rem', lineHeight: 1, display: 'flex', alignItems: 'center',
className="text-brand-400 hover:text-brand-600" }}>×</button>
>
×
</button>
</span> </span>
))} ))}
</div> </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> </div>
{/* Visual style */} <Field label="Font preferito">
<div className="bg-white rounded-xl border border-slate-200 p-6 space-y-4"> <input type="text" value={form.visual_style.font} onChange={(e) => handleStyleChange('font', e.target.value)}
<h3 className="font-semibold text-slate-700 text-sm uppercase tracking-wider"> placeholder="Es. Montserrat, Poppins, Inter" style={inputStyle}
Stile visivo onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
</h3> </Field>
<div className="grid grid-cols-2 gap-4"> {/* Anteprima */}
<div> <div style={{ padding: '1rem', border: '1px dashed var(--border)', backgroundColor: 'var(--cream)' }}>
<label className="block text-sm font-medium text-slate-700 mb-1"> <p style={{ fontSize: '0.7rem', color: 'var(--ink-muted)', margin: '0 0 0.5rem' }}>Anteprima</p>
Colore primario <div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
</label> <div style={{
<div className="flex items-center gap-2"> width: 38, height: 38, borderRadius: '50%', flexShrink: 0,
<input backgroundColor: form.visual_style.primary_color,
type="color" display: 'flex', alignItems: 'center', justifyContent: 'center',
value={form.visual_style.primary_color} color: 'white', fontWeight: 700, fontSize: '1rem',
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() || '?'} {form.name?.charAt(0)?.toUpperCase() || '?'}
</div> </div>
<div> <div>
<p className="font-semibold text-sm" style={{ color: form.visual_style.secondary_color }}> <p style={{ fontWeight: 600, fontSize: '0.875rem', color: form.visual_style.secondary_color, margin: '0 0 0.1rem' }}>
{form.name || 'Nome personaggio'} {form.name || 'Nome personaggio'}
</p> </p>
<p className="text-xs text-slate-400">{form.niche || 'Niche'}</p> <p style={{ fontSize: '0.75rem', color: 'var(--ink-muted)', margin: 0 }}>{form.niche || 'Nicchia'}</p>
</div>
</div> </div>
</div> </div>
</div> </div>
</Section>
{/* Actions */} {/* ── Actions ───────────────────────────────────────────── */}
<div className="flex items-center gap-3"> <div style={{ display: 'flex', gap: '0.75rem' }}>
<button <button type="submit" disabled={saving} style={{ ...btnPrimary, opacity: saving ? 0.6 : 1 }}>
type="submit" {saving ? 'Salvataggio…' : isEdit ? 'Salva modifiche' : 'Crea personaggio'}
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>
<button <button type="button" onClick={() => navigate('/characters')} style={btnSecondary}>
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 Annulla
</button> </button>
</div> </div>
</form> </form>
)}
{activeTab === 'social' && isEdit && ( <style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
<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>
)
})}
</div>
)}
</div> </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 [characters, setCharacters] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
useEffect(() => { useEffect(() => { loadCharacters() }, [])
loadCharacters()
}, [])
const loadCharacters = () => { const loadCharacters = () => {
setLoading(true) setLoading(true)
api.get('/characters/') api.get('/characters/').then(setCharacters).catch(() => {}).finally(() => setLoading(false))
.then(setCharacters)
.catch(() => {})
.finally(() => setLoading(false))
} }
const handleDelete = async (id, name) => { const handleDelete = async (id, name) => {
@@ -30,132 +25,134 @@ export default function CharacterList() {
} }
return ( return (
<div style={{ animation: 'fade-up 0.5s ease-out both' }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: '2rem' }}>
<div> <div>
<div className="flex items-center justify-between mb-6"> <span className="editorial-tag">Personaggi</span>
<div> <div className="editorial-line" />
<h2 className="text-2xl font-bold text-slate-800">Personaggi</h2> <h2 style={{ fontFamily: "'Fraunces', serif", fontSize: '1.75rem', fontWeight: 600, letterSpacing: '-0.02em', color: 'var(--ink)', margin: '0.5rem 0 0.3rem' }}>
<p className="text-slate-500 mt-1 text-sm"> I tuoi Personaggi
Gestisci i tuoi profili editoriali </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> </p>
</div> </div>
<Link <Link to="/characters/new" style={btnPrimary}>+ Crea Personaggio</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>
</div> </div>
{loading ? ( {loading ? (
<div className="flex justify-center py-12"> <Spinner />
<div className="animate-spin rounded-full h-8 w-8 border-2 border-brand-500 border-t-transparent" />
</div>
) : characters.length === 0 ? ( ) : characters.length === 0 ? (
<div className="text-center py-16 bg-white rounded-xl border border-slate-200"> <EmptyState
<p className="text-4xl mb-3"></p> icon="◎"
<p className="text-slate-500 font-medium">Nessun personaggio</p> title="Nessun personaggio ancora"
<p className="text-slate-400 text-sm mt-1"> description="Il personaggio è il cuore di Leopost: definisce chi parla, come parla e a chi. Creane uno per iniziare a generare contenuti."
Crea il tuo primo profilo editoriale cta="+ Crea il tuo primo Personaggio"
</p>
<Link
to="/characters/new" 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>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{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 style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '1rem' }}>
<div className="flex items-start gap-3"> {characters.map((c) => (
<div <CharacterCard key={c.id} character={c} onDelete={handleDelete} onToggle={handleToggle} />
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>
))} ))}
</div> </div>
)} )}
</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 { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../api' import { api } from '../api'
const cardStyle = { const PLATFORMS = [
backgroundColor: 'var(--surface)', { value: 'instagram', label: 'Instagram' },
border: '1px solid var(--border)', { value: 'facebook', label: 'Facebook' },
borderRadius: '0.75rem', { value: 'youtube', label: 'YouTube' },
padding: '1.5rem', { value: 'tiktok', label: 'TikTok' },
} ]
const inputStyle = { const CONTENT_TYPES = [
width: '100%', { value: 'text', label: 'Testo' },
padding: '0.625rem 1rem', { value: 'image', label: 'Immagine' },
border: '1px solid var(--border)', { value: 'video', label: 'Video' },
borderRadius: '0.5rem', { value: 'reel', label: 'Reel/Short' },
fontSize: '0.875rem', { value: 'story', label: 'Story' },
color: 'var(--ink)', ]
backgroundColor: 'var(--cream)',
outline: 'none', 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() { export default function ContentPage() {
const [characters, setCharacters] = useState([]) const [characters, setCharacters] = useState([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [charsLoading, setCharsLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
const [generated, setGenerated] = useState(null) const [generated, setGenerated] = useState(null)
const [editing, setEditing] = useState(false) const [editing, setEditing] = useState(false)
@@ -29,35 +45,41 @@ export default function ContentPage() {
const [form, setForm] = useState({ const [form, setForm] = useState({
character_id: '', character_id: '',
platform: 'instagram', brief: '',
content_type: 'text', platforms: ['instagram'],
content_types: ['text'],
topic_hint: '', topic_hint: '',
tecnica: '',
include_affiliates: false, include_affiliates: false,
}) })
useEffect(() => { useEffect(() => {
api.get('/characters/').then(setCharacters).catch(() => {}) api.get('/characters/').then(d => { setCharacters(d); setCharsLoading(false) }).catch(() => setCharsLoading(false))
}, []) }, [])
const handleChange = (field, value) => { const toggleChip = (field, value) => {
setForm((prev) => ({ ...prev, [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) => { const handleGenerate = async (e) => {
e.preventDefault() e.preventDefault()
if (!form.character_id) { if (!form.character_id) { setError('Seleziona un personaggio'); return }
setError('Seleziona un personaggio')
return
}
setError('') setError('')
setLoading(true) setLoading(true)
setGenerated(null) setGenerated(null)
try { try {
const data = await api.post('/content/generate', { const data = await api.post('/content/generate', {
character_id: parseInt(form.character_id), character_id: parseInt(form.character_id),
platform: form.platform, platforms: form.platforms,
content_type: form.content_type, content_types: form.content_types,
topic_hint: form.topic_hint || null, topic_hint: form.topic_hint || null,
brief: [
form.tecnica ? `Tecnica narrativa: ${form.tecnica}` : '',
form.brief,
].filter(Boolean).join('. ') || null,
include_affiliates: form.include_affiliates, include_affiliates: form.include_affiliates,
}) })
setGenerated(data) setGenerated(data)
@@ -70,222 +92,247 @@ export default function ContentPage() {
} }
const handleApprove = async () => { const handleApprove = async () => {
if (!generated) return
try { try {
await api.post(`/content/posts/${generated.id}/approve`) await api.post(`/content/posts/${generated.id}/approve`)
setGenerated((prev) => ({ ...prev, status: 'approved' })) setGenerated(prev => ({ ...prev, status: 'approved' }))
} catch (err) { } catch (err) { setError(err.message || 'Errore approvazione') }
setError(err.message || 'Errore approvazione')
}
} }
const handleSaveEdit = async () => { const handleSaveEdit = async () => {
if (!generated) return
try { try {
await api.put(`/content/posts/${generated.id}`, { text_content: editText }) await api.put(`/content/posts/${generated.id}`, { text_content: editText })
setGenerated((prev) => ({ ...prev, text_content: editText })) setGenerated(prev => ({ ...prev, text_content: editText }))
setEditing(false) setEditing(false)
} catch (err) { } catch (err) { setError(err.message || 'Errore salvataggio') }
setError(err.message || 'Errore salvataggio')
}
} }
const handleDelete = async () => { const handleDelete = async () => {
if (!generated) return
if (!confirm('Eliminare questo contenuto?')) return if (!confirm('Eliminare questo contenuto?')) return
try { try {
await api.delete(`/content/posts/${generated.id}`) await api.delete(`/content/posts/${generated.id}`)
setGenerated(null) setGenerated(null)
} catch (err) { } catch (err) { setError(err.message || 'Errore eliminazione') }
setError(err.message || 'Errore eliminazione')
}
}
const platformLabels = {
instagram: 'Instagram',
facebook: 'Facebook',
youtube: 'YouTube',
tiktok: 'TikTok',
}
const contentTypeLabels = {
text: 'Testo',
image: 'Immagine',
video: 'Video',
} }
return ( return (
<div> <div style={{ animation: 'fade-up 0.5s ease-out both' }}>
<div className="mb-6"> {/* Header */}
<h2 className="text-2xl font-bold" style={{ color: 'var(--ink)' }}>Contenuti</h2> <div style={{ marginBottom: '2rem' }}>
<p className="mt-1 text-sm" style={{ color: 'var(--muted)' }}> <span className="editorial-tag">Contenuti</span>
Genera e gestisci contenuti per i tuoi personaggi <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> </p>
</div> </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 && ( {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} {error}
</div> </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 */} {/* Generation form */}
<div style={cardStyle}> <div style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)', borderTop: '4px solid var(--accent)', padding: '1.5rem' }}>
<h3 className="font-semibold text-sm uppercase tracking-wider mb-4" style={{ color: 'var(--muted)' }}> <div style={{ marginBottom: '1.25rem' }}>
Genera Contenuto <span style={labelStyle}>Genera Contenuto</span>
</h3> </div>
<form onSubmit={handleGenerate} className="space-y-4"> <form onSubmit={handleGenerate} style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
{/* Character select */}
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>Personaggio</label> <label style={labelStyle}>Personaggio</label>
<select <select value={form.character_id} onChange={e => setForm(p => ({ ...p, character_id: e.target.value }))} style={inputStyle} required>
value={form.character_id} <option value="">Seleziona personaggio…</option>
onChange={(e) => handleChange('character_id', e.target.value)} {characters.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
style={inputStyle}
required
>
<option value="">Seleziona personaggio...</option>
{characters.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select> </select>
</div> </div>
{/* Prompt / Brief strategico */}
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>Piattaforma</label> <label style={{ ...labelStyle, marginBottom: '0.5rem' }}>
<select Prompt & Strategia del Post
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> </label>
<input
type="text" {/* Tecnica narrativa chips */}
value={form.topic_hint} <div style={{ marginBottom: '0.75rem' }}>
onChange={(e) => handleChange('topic_hint', e.target.value)} <p style={{ fontSize: '0.7rem', fontWeight: 600, letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--ink-muted)', margin: '0 0 0.4rem' }}>Tecnica narrativa</p>
placeholder="Es. ultimi trend, tutorial..." <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem' }}>
style={inputStyle} {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> </div>
<div className="flex items-center gap-3"> {/* Brief textarea */}
<label className="relative inline-flex items-center cursor-pointer"> <textarea value={form.brief} onChange={e => setForm(p => ({ ...p, brief: e.target.value }))}
<input 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'"
type="checkbox" rows={4} style={{ ...inputStyle, resize: 'vertical', lineHeight: 1.6 }}
checked={form.include_affiliates} onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
onChange={(e) => handleChange('include_affiliates', e.target.checked)} <p style={{ fontSize: '0.72rem', color: 'var(--ink-muted)', margin: '0.3rem 0 0', lineHeight: 1.5 }}>
className="sr-only peer" Più sei specifico, più il contenuto sarà preciso e pubblicabile senza revisioni.
/> </p>
<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>
</div> </div>
<button {/* Platform chips */}
type="submit" <div>
disabled={loading} <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>
className="w-full py-2.5 text-white font-medium rounded-lg transition-opacity text-sm" <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem', marginTop: '0.4rem' }}>
style={{ backgroundColor: 'var(--coral)', opacity: loading ? 0.7 : 1 }} {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>
{/* 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 ? ( {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" /> <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... Generazione in corso…
</span> </>
) : 'Genera'} ) : ' Genera'}
</button> </button>
</form> </form>
</div> </div>
{/* Preview */} {/* Preview panel */}
<div style={cardStyle}> <div style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)', borderTop: '4px solid var(--border-strong)', padding: '1.5rem' }}>
<h3 className="font-semibold text-sm uppercase tracking-wider mb-4" style={{ color: 'var(--muted)' }}> <span style={labelStyle}>Contenuto Generato</span>
Ultimo Contenuto Generato
</h3>
{loading ? ( {loading ? (
<div className="flex flex-col items-center justify-center py-16"> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '4rem 0' }}>
<div className="animate-spin rounded-full h-8 w-8 border-2 border-t-transparent" style={{ borderColor: 'var(--coral)' }} /> <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 className="text-sm mt-3" style={{ color: 'var(--muted)' }}>Generazione in corso...</p> <p style={{ fontSize: '0.875rem', color: 'var(--ink-muted)' }}>Generazione in corso…</p>
</div> </div>
) : generated ? ( ) : generated ? (
<div className="space-y-4"> <div style={{ marginTop: '1rem' }}>
<div className="flex items-center gap-2"> {/* Status + platform badges */}
<span className={`text-xs px-2 py-0.5 rounded-full ${ <div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap', marginBottom: '1rem' }}>
generated.status === 'approved' ? 'bg-emerald-50 text-emerald-600' : {(() => { const sc = STATUS_COLORS[generated.status] || STATUS_COLORS.draft; return (
generated.status === 'published' ? 'bg-blue-50 text-blue-600' : <span style={{ fontSize: '0.72rem', fontWeight: 700, padding: '0.2rem 0.5rem', backgroundColor: sc.bg, color: sc.color }}>
'bg-amber-50 text-amber-600' {STATUS_LABELS[generated.status] || generated.status}
}`}>
{generated.status === 'approved' ? 'Approvato' :
generated.status === 'published' ? 'Pubblicato' : 'Bozza'}
</span> </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} {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> </span>
)}
</div> </div>
{editing ? ( {editing ? (
<div className="space-y-2"> <div>
<textarea <textarea value={editText} onChange={e => setEditText(e.target.value)} rows={8}
value={editText} style={{ ...inputStyle, resize: 'vertical', lineHeight: 1.6 }}
onChange={(e) => setEditText(e.target.value)} onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
rows={8} <div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.5rem' }}>
className="w-full px-4 py-2.5 rounded-lg text-sm resize-none focus:outline-none" <button onClick={handleSaveEdit} style={btnPrimary}>Salva</button>
style={{ border: '1px solid var(--border)', color: 'var(--ink)' }} <button onClick={() => { setEditing(false); setEditText(generated.text_content || '') }} style={btnSecondary}>Annulla</button>
/>
<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> </div>
</div> </div>
) : ( ) : (
<div className="p-4 rounded-lg" style={{ backgroundColor: 'var(--cream)' }}> <div style={{ padding: '1rem', backgroundColor: 'var(--cream)', marginBottom: '1rem' }}>
<p className="text-sm whitespace-pre-wrap leading-relaxed" style={{ color: 'var(--ink)' }}> <p style={{ fontSize: '0.875rem', whiteSpace: 'pre-wrap', lineHeight: 1.7, color: 'var(--ink)', margin: 0 }}>
{generated.text_content} {generated.text_content}
</p> </p>
</div> </div>
)} )}
{generated.hashtags && generated.hashtags.length > 0 && ( {generated.hashtags?.length > 0 && (
<div> <div style={{ marginBottom: '1rem' }}>
<p className="text-xs font-medium mb-1.5" style={{ color: 'var(--muted)' }}>Hashtag</p> <span style={{ ...labelStyle, display: 'block', marginBottom: '0.4rem' }}>Hashtag</span>
<div className="flex flex-wrap gap-1.5"> <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem' }}>
{generated.hashtags.map((tag, i) => ( {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} {tag}
</span> </span>
))} ))}
@@ -293,42 +340,49 @@ export default function ContentPage() {
</div> </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' && ( {generated.status !== 'approved' && (
<button <button onClick={handleApprove} style={{ ...btnPrimary, backgroundColor: 'var(--success)' }}>Approva</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>
)} )}
{!editing && ( {!editing && <button onClick={() => setEditing(true)} style={btnSecondary}>Modifica</button>}
<button <button onClick={handleDelete} style={{ ...btnSecondary, marginLeft: 'auto', color: 'var(--error)' }}>Elimina</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>
</div> </div>
</div> </div>
) : ( ) : (
<div className="flex flex-col items-center justify-center py-16 text-center"> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '4rem 1rem', textAlign: 'center' }}>
<p className="text-4xl mb-3"></p> <div style={{ fontSize: '2.5rem', color: 'var(--border-strong)', marginBottom: '1rem' }}>✦</div>
<p className="font-medium" style={{ color: 'var(--ink)' }}>Nessun contenuto generato</p> <p style={{ fontFamily: "'Fraunces', serif", fontSize: '1rem', color: 'var(--ink)', margin: '0 0 0.5rem' }}>Nessun contenuto ancora</p>
<p className="text-sm mt-1" style={{ color: 'var(--muted)' }}> <p style={{ fontSize: '0.82rem', color: 'var(--ink-muted)', margin: 0 }}>
Compila il form e clicca "Genera" Scrivi un brief, seleziona personaggio e piattaforma, poi clicca "Genera"
</p> </p>
</div> </div>
)} )}
</div> </div>
</div> </div>
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
</div> </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 { Link } from 'react-router-dom'
import { api } from '../api' import { api } from '../api'
import { useAuth } from '../AuthContext' import { useAuth } from '../AuthContext'
import PlanBanner from './PlanBanner'
export default function Dashboard() { export default function Dashboard() {
const { user, isAdmin } = useAuth() const { user, isAdmin } = useAuth()
@@ -57,7 +57,7 @@ export default function Dashboard() {
color: 'var(--ink)', color: 'var(--ink)',
margin: '0.4rem 0 0.25rem', margin: '0.4rem 0 0.25rem',
}}> }}>
{greeting}{user?.display_name ? `, ${user.display_name}` : ''} {greeting}{user?.display_name ? `, ${user.display_name.split(' ')[0]}` : ''}
</h2> </h2>
<p style={{ fontSize: '0.875rem', color: 'var(--ink-muted)', margin: 0 }}> <p style={{ fontSize: '0.875rem', color: 'var(--ink-muted)', margin: 0 }}>
Panoramica del tuo studio editoriale AI Panoramica del tuo studio editoriale AI
@@ -82,7 +82,7 @@ export default function Dashboard() {
)} )}
</div> </div>
<PlanBanner />
{/* ── Stats grid ──────────────────────────────────────────── */} {/* ── Stats grid ──────────────────────────────────────────── */}
<div style={{ <div style={{

View File

@@ -1,36 +1,11 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../api' import { api } from '../api'
import PlanList from './PlanList'
const BASE_URL = '/leopost-full/api' // ─── Constants ────────────────────────────────────────────────────────────────
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',
}
const FORMATO_COLORS = { const FORMATO_COLORS = {
PAS: { bg: '#FFF0EC', color: 'var(--coral)' }, PAS: { bg: '#FFF0EC', color: '#E85A4F' },
AIDA: { bg: '#EFF6FF', color: '#3B82F6' }, AIDA: { bg: '#EFF6FF', color: '#3B82F6' },
BAB: { bg: '#F0FDF4', color: '#16A34A' }, BAB: { bg: '#F0FDF4', color: '#16A34A' },
Storytelling: { bg: '#FDF4FF', color: '#9333EA' }, Storytelling: { bg: '#FDF4FF', color: '#9333EA' },
@@ -46,15 +21,233 @@ const AWARENESS_COLORS = {
5: { bg: '#EFF6FF', color: '#2563EB' }, 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() { 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 [formats, setFormats] = useState([])
const [awarenessLevels, setAwarenessLevels] = useState([]) const [awarenessLevels, setAwarenessLevels] = useState([])
const [characters, setCharacters] = useState([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [calendar, setCalendar] = useState(null) const [calendar, setCalendar] = useState(null)
const [exporting, setExporting] = useState(false) const [exporting, setExporting] = useState(false)
const [form, setForm] = useState({ const [form, setForm] = useState({
character_id: '',
topics: '', topics: '',
format_narrativo: '', format_narrativo: '',
awareness_level: '', awareness_level: '',
@@ -63,293 +256,191 @@ export default function EditorialCalendar() {
}) })
useEffect(() => { useEffect(() => {
api.get('/editorial/formats') api.get('/editorial/formats').then(data => {
.then((data) => {
setFormats(data.formats || []) setFormats(data.formats || [])
setAwarenessLevels(data.awareness_levels || []) setAwarenessLevels(data.awareness_levels || [])
}) }).catch(() => {})
.catch(() => {}) api.get('/characters/').then(setCharacters).catch(() => {})
}, []) }, [])
const handleChange = (field, value) => { const buildBrief = () => {
setForm((prev) => ({ ...prev, [field]: value })) 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) => { const handleGenerate = async (e) => {
e.preventDefault() e.preventDefault()
const topicsList = form.topics const topicsList = form.topics.split(',').map(t => t.trim()).filter(Boolean)
.split(',') if (topicsList.length === 0) { setError('Inserisci almeno un topic/keyword'); return }
.map((t) => t.trim())
.filter(Boolean)
if (topicsList.length === 0) {
setError('Inserisci almeno un topic/keyword')
return
}
setError('') setError('')
setLoading(true) setLoading(true)
setCalendar(null) setCalendar(null)
try { try {
const payload = { const payload = {
topics: topicsList, topics: topicsList,
num_posts: parseInt(form.num_posts) || 7, num_posts: parseInt(form.num_posts) || 7,
start_date: form.start_date || null, 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.format_narrativo) payload.format_narrativo = form.format_narrativo
if (form.awareness_level) payload.awareness_level = parseInt(form.awareness_level) 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) const data = await api.post('/editorial/generate-calendar', payload)
setCalendar(data) setCalendar(data)
} catch (err) { } catch (err) {
setError(err.message || 'Errore nella generazione del calendario') setError(err.message || 'Errore nella generazione del calendario')
} finally { } finally { setLoading(false) }
setLoading(false)
}
} }
const handleExportCsv = async () => { const handleExportCsv = async () => {
if (!calendar?.slots?.length) return if (!calendar?.slots?.length) return
setExporting(true) setExporting(true)
try { try {
const token = localStorage.getItem('token') const token = localStorage.getItem('token')
const res = await fetch(`${BASE_URL}/editorial/export-csv`, { const res = await fetch('/api/editorial/export-csv', {
method: 'POST', method: 'POST',
headers: { headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ slots: calendar.slots }), body: JSON.stringify({ slots: calendar.slots }),
}) })
if (!res.ok) throw new Error('Export fallito') if (!res.ok) throw new Error('Export fallito')
const blob = await res.blob() const blob = await res.blob()
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const a = document.createElement('a') const a = document.createElement('a')
a.href = url a.href = url; a.download = 'calendario_editoriale.csv'
a.download = 'calendario_editoriale.csv' document.body.appendChild(a); a.click()
document.body.appendChild(a) document.body.removeChild(a); URL.revokeObjectURL(url)
a.click() } catch (err) { setError(err.message || 'Errore export CSV')
document.body.removeChild(a) } finally { setExporting(false) }
URL.revokeObjectURL(url)
} catch (err) {
setError(err.message || 'Errore nell\'export CSV')
} finally {
setExporting(false)
}
} }
const strategySet = strategy.obiettivo || strategy.tecnica || strategy.brief
return ( return (
<div> <div style={{ display: 'grid', gridTemplateColumns: 'minmax(280px, 320px) 1fr', gap: '1.5rem', alignItems: 'flex-start' }}>
<div className="mb-6"> {/* Form */}
<h2 className="text-2xl font-bold" style={{ color: 'var(--ink)' }}> <div style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)', borderTop: '4px solid var(--accent)', padding: '1.5rem' }}>
Calendario Editoriale AI <span style={labelStyle}>Parametri del Calendario</span>
</h2>
<p className="mt-1 text-sm" style={{ color: 'var(--muted)' }}>
Genera un piano editoriale con format narrativi e awareness levels (Schwartz)
</p>
</div>
{error && ( {/* Strategy inherited indicator */}
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm"> {strategySet && (
{error} <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>
)} )}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> {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 */}
<div style={cardStyle}>
<h3 className="font-semibold text-sm uppercase tracking-wider mb-4" style={{ color: 'var(--muted)' }}>
Parametri
</h3>
<form onSubmit={handleGenerate} className="space-y-4"> <form onSubmit={handleGenerate} style={{ display: 'flex', flexDirection: 'column', gap: '1rem', marginTop: '1rem' }}>
{/* Character */}
{characters.length > 0 && (
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}> <label style={labelStyle}>Personaggio <small style={{ fontWeight: 400, textTransform: 'none', letterSpacing: 0, color: 'var(--ink-muted)' }}>(opzionale)</small></label>
Topics / Keywords <select value={form.character_id} onChange={e => setForm(p => ({ ...p, character_id: e.target.value }))} style={inputStyle}>
</label> <option value="">Nessuno (generico)</option>
<textarea {characters.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
value={form.topics} </select>
onChange={(e) => handleChange('topics', e.target.value)} </div>
placeholder="Es. marketing digitale, social media, content strategy" )}
rows={3}
<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' }} style={{ ...inputStyle, resize: 'vertical' }}
/> onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
<p className="text-xs mt-1" style={{ color: 'var(--muted)' }}> <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>
Separati da virgola </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> </p>
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}> <label style={labelStyle}>N. post (130)</label>
Formato Narrativo <input type="number" min={1} max={30} value={form.num_posts} onChange={e => setForm(p => ({ ...p, num_posts: e.target.value }))} style={inputStyle}
<span className="font-normal ml-1" style={{ color: 'var(--muted)' }}>(opzionale)</span> onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
</label> </div>
<select <div>
value={form.format_narrativo} <label style={labelStyle}>Data inizio</label>
onChange={(e) => handleChange('format_narrativo', e.target.value)} <input type="date" value={form.start_date} onChange={e => setForm(p => ({ ...p, start_date: e.target.value }))} style={inputStyle}
style={inputStyle} onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
> </div>
<option value="">Distribuzione automatica</option>
{formats.map((f) => (
<option key={f.value} value={f.value}>
{f.label}
</option>
))}
</select>
</div> </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 }}>
<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 ? ( {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" /> <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... Generazione in corso…
</span> </>
) : 'Genera Calendario'} ) : ' Genera Calendario AI'}
</button> </button>
</form> </form>
</div> </div>
{/* Results */} {/* Results */}
<div className="lg:col-span-2"> <div>
{calendar ? ( {calendar ? (
<div> <div>
<div className="flex items-center justify-between mb-4"> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.25rem', flexWrap: 'wrap', gap: '0.75rem' }}>
<div> <div>
<h3 className="font-semibold text-sm uppercase tracking-wider" style={{ color: 'var(--muted)' }}> <span style={labelStyle}>Calendario Generato</span>
Calendario Generato <p style={{ fontSize: '0.82rem', color: 'var(--ink-muted)', margin: '0.2rem 0 0' }}>{calendar.totale_post} post pianificati</p>
</h3>
<p className="text-xs mt-0.5" style={{ color: 'var(--muted)' }}>
{calendar.totale_post} post pianificati
</p>
</div> </div>
<button <button onClick={handleExportCsv} disabled={exporting} style={{ ...btnPrimary, opacity: exporting ? 0.6 : 1 }}>
onClick={handleExportCsv} {exporting ? 'Export' : 'Esporta CSV per Canva'}
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> </button>
</div> </div>
<div className="space-y-3"> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{calendar.slots.map((slot) => { {calendar.slots.map((slot) => {
const fmtColor = FORMATO_COLORS[slot.formato_narrativo] || { bg: '#F8F8F8', color: 'var(--ink)' } const fc = FORMATO_COLORS[slot.formato_narrativo] || { bg: 'var(--cream-dark)', color: 'var(--ink-muted)' }
const awColor = AWARENESS_COLORS[slot.awareness_level] || { bg: '#F8F8F8', color: 'var(--ink)' } const ac = AWARENESS_COLORS[slot.awareness_level] || { bg: 'var(--cream-dark)', color: 'var(--ink-muted)' }
return ( return (
<div <div key={slot.indice} style={{ display: 'flex', gap: '1rem', padding: '1rem 1.25rem', backgroundColor: 'var(--surface)', border: '1px solid var(--border)', alignItems: 'flex-start' }}>
key={slot.indice} <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 }}>
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} {slot.indice + 1}
</div> </div>
<div style={{ flex: 1, minWidth: 0 }}>
{/* Content */} <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem', marginBottom: '0.5rem' }}>
<div className="flex-1 min-w-0"> <span style={{ fontSize: '0.72rem', fontWeight: 600, padding: '0.15rem 0.5rem', backgroundColor: 'var(--cream-dark)', color: 'var(--ink-muted)' }}>
<div className="flex flex-wrap items-center gap-2 mb-2"> {new Date(slot.data_pubblicazione).toLocaleDateString('it-IT', { weekday: 'short', day: '2-digit', month: 'short' })}
{/* 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> </span>
<span style={{ fontSize: '0.72rem', fontWeight: 600, padding: '0.15rem 0.5rem', backgroundColor: fc.bg, color: fc.color }}>
{/* Format */}
<span
className="text-xs font-medium px-2 py-0.5 rounded"
style={{ backgroundColor: fmtColor.bg, color: fmtColor.color }}
>
{slot.formato_narrativo.replace('_', ' ')} {slot.formato_narrativo.replace('_', ' ')}
</span> </span>
<span style={{ fontSize: '0.72rem', fontWeight: 600, padding: '0.15rem 0.5rem', backgroundColor: ac.bg, color: ac.color }}>
{/* 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} L{slot.awareness_level} — {slot.awareness_label}
</span> </span>
</div> </div>
<p style={{ fontSize: '0.875rem', fontWeight: 500, color: 'var(--ink)', margin: '0 0 0.2rem' }}>{slot.topic}</p>
{/* Topic */} {slot.note && <p style={{ fontSize: '0.78rem', color: 'var(--ink-muted)', margin: 0 }}>{slot.note}</p>}
<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>
) )
@@ -357,47 +448,51 @@ export default function EditorialCalendar() {
</div> </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' }}>
className="flex flex-col items-center justify-center py-20 rounded-xl text-center" <div style={{ fontSize: '3rem', color: 'var(--border-strong)', marginBottom: '1rem' }}>◰</div>
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }} <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 }}>
<p className="text-5xl mb-4"></p> 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 className="font-semibold text-lg font-serif" style={{ color: 'var(--ink)' }}>
Nessun calendario generato
</p> </p>
<p className="text-sm mt-2 max-w-xs" style={{ color: 'var(--muted)' }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem', width: '100%', maxWidth: 420, textAlign: 'left' }}>
Inserisci i topic e scegli le impostazioni, poi clicca "Genera Calendario" <InfoBox title="Format narrativi" items={['PAS — Problema/Soluzione', 'AIDA — Funnel classico', 'BAB — Trasformazione', 'Storytelling', 'Listicle', 'Dato + Implicazione']} />
</p> <InfoBox title="Awareness levels" items={['1 — Unaware', '2 — Problem aware', '3 — Solution aware', '4 — Product aware', '5 — Most Aware']} />
{/* 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>
)} )}
</div> </div>
</div> <style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
</div> </div>
) )
} }
function InfoBox({ title, items }) { function InfoBox({ title, items }) {
return ( return (
<div <div style={{ padding: '0.875rem', backgroundColor: 'var(--cream)', border: '1px solid var(--border)' }}>
className="p-3 rounded-lg" <p style={{ fontSize: '0.68rem', fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--ink-muted)', margin: '0 0 0.5rem' }}>{title}</p>
style={{ backgroundColor: 'var(--cream)', border: '1px solid var(--border)' }} <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>)}
<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>
))}
</ul> </ul>
</div> </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' import { useAuth } from '../AuthContext'
const nav = [ const nav = [
{ to: '/', label: 'Dashboard', icon: '◉' }, { to: '/', label: 'Dashboard' },
{ to: '/characters',label: 'Personaggi', icon: '◎' }, { to: '/characters',label: 'Personaggi' },
{ to: '/content', label: 'Contenuti', icon: '✦' }, { to: '/content', label: 'Contenuti' },
{ to: '/affiliates',label: 'Link Affiliati', icon: '⟁' }, { to: '/affiliates',label: 'Link Affiliati' },
{ to: '/plans', label: 'Piano Editoriale', icon: '▦' }, { to: '/editorial', label: 'Pianificazione' },
{ to: '/schedule', label: 'Schedulazione', icon: '◈' }, { to: '/schedule', label: 'Schedulazione' },
{ to: '/social', label: 'Social', icon: '◇' }, { to: '/social', label: 'Social' },
{ to: '/comments', label: 'Commenti', icon: '◌' }, { to: '/comments', label: 'Commenti' },
{ to: '/editorial', label: 'Calendario AI', icon: '◰' },
{ to: '/settings', label: 'Impostazioni', icon: '⚙' },
] ]
export default function Layout() { export default function Layout() {
const { user, logout, isPro, isAdmin } = useAuth() 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 ( 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 ─────────────────────────────────────────────── */} {/* ── Sidebar ─────────────────────────────────────────────── */}
{isMobile && sidebarOpen && (
<div onClick={() => setSidebarOpen(false)} style={{
position: 'fixed', inset: 0, backgroundColor: 'rgba(26,26,26,0.5)', zIndex: 200,
}} />
)}
<aside style={{ <aside style={{
width: 240, width: 220,
flexShrink: 0, flexShrink: 0,
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
backgroundColor: 'var(--cream-dark)', backgroundColor: 'var(--cream-dark)',
borderRight: '1px solid var(--border)', borderRight: '1px solid var(--border)',
...(isMobile ? {
position: 'fixed', top: 0, left: sidebarOpen ? 0 : -220,
height: '100dvh', zIndex: 300, transition: 'left 0.25s ease',
} : {}),
}}> }}>
{/* Logo */} {/* Logo */}
<div style={{ <div style={{ padding: '1.5rem 1.25rem 1.25rem', borderBottom: '1px solid var(--border)' }}>
padding: '1.5rem 1.25rem 1.25rem', <h1 style={{ fontFamily: "'Fraunces', serif", fontWeight: 700, fontSize: '1.4rem', letterSpacing: '-0.02em', color: 'var(--ink)', margin: 0 }}>
borderBottom: '1px solid var(--border)',
}}>
<h1 style={{
fontFamily: "'Fraunces', serif",
fontWeight: 700,
fontSize: '1.4rem',
letterSpacing: '-0.02em',
color: 'var(--ink)',
margin: 0,
}}>
Leopost Leopost
</h1> </h1>
<div style={{ width: 40, height: 3, backgroundColor: 'var(--accent)', marginTop: '0.4rem' }} /> <div style={{ width: 40, height: 3, backgroundColor: 'var(--accent)', marginTop: '0.4rem' }} />
@@ -51,16 +69,15 @@ export default function Layout() {
{/* Nav */} {/* Nav */}
<nav style={{ flex: 1, padding: '0.75rem 0.5rem', overflowY: 'auto' }}> <nav style={{ flex: 1, padding: '0.75rem 0.5rem', overflowY: 'auto' }}>
{nav.map(({ to, label, icon }) => ( {nav.map(({ to, label }) => (
<NavLink <NavLink
key={to} key={to}
to={to} to={to}
end={to === '/'} end={to === '/'}
onClick={() => isMobile && setSidebarOpen(false)}
style={({ isActive }) => ({ style={({ isActive }) => ({
display: 'flex', display: 'block',
alignItems: 'center', padding: '0.6rem 0.875rem',
gap: '0.625rem',
padding: '0.625rem 0.875rem',
fontSize: '0.84rem', fontSize: '0.84rem',
fontWeight: isActive ? 600 : 400, fontWeight: isActive ? 600 : 400,
color: isActive ? 'var(--accent)' : 'var(--ink-light)', 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} {label}
</NavLink> </NavLink>
))} ))}
</nav> </nav>
{/* User footer */} {/* 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={{ <div style={{
padding: '1rem 1.25rem', width: 34, height: 34, borderRadius: '50%',
borderTop: '1px solid var(--border)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.625rem', marginBottom: '0.5rem' }}>
<div style={{
width: 32,
height: 32,
borderRadius: '50%',
backgroundColor: 'var(--accent)', backgroundColor: 'var(--accent)',
display: 'flex', display: 'flex', alignItems: 'center', justifyContent: 'center',
alignItems: 'center', color: 'white', fontWeight: 700, fontSize: '0.9rem', flexShrink: 0,
justifyContent: 'center',
color: 'white',
fontWeight: 700,
fontSize: '0.85rem',
flexShrink: 0,
}}> }}>
{(user?.display_name || user?.username || '?')[0].toUpperCase()} {(user?.display_name || user?.username || '?')[0].toUpperCase()}
</div> </div>
<div style={{ overflow: 'hidden' }}> <div style={{ minWidth: 0, flex: 1 }}>
<p style={{ fontSize: '0.8rem', fontWeight: 600, color: 'var(--ink)', margin: 0, truncate: true, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <p style={{ fontSize: '0.8rem', fontWeight: 600, color: 'var(--ink)', margin: '0 0 0.15rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{user?.display_name || user?.username} {user?.display_name?.split(' ')[0] || user?.username}
</p> </p>
<span style={{ <span style={{
display: 'inline-block', display: 'inline-block', fontSize: '0.62rem', fontWeight: 700,
fontSize: '0.65rem', letterSpacing: '0.08em', textTransform: 'uppercase',
fontWeight: 700, padding: '0.1rem 0.35rem',
letterSpacing: '0.08em',
textTransform: 'uppercase',
padding: '0.1rem 0.4rem',
backgroundColor: isPro ? 'var(--success)' : 'var(--border-strong)', backgroundColor: isPro ? 'var(--success)' : 'var(--border-strong)',
color: isPro ? 'white' : 'var(--ink-muted)', color: isPro ? 'white' : 'var(--ink-muted)',
}}> }}>
{isPro ? 'PRO' : 'FREEMIUM'} {isPro ? 'PRO' : 'FREEMIUM'}
</span> </span>
</div> </div>
</div> <span style={{ fontSize: '0.65rem', color: 'var(--ink-muted)', flexShrink: 0 }}></span>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> </button>
{isAdmin && (
<NavLink to="/admin" style={{ fontSize: '0.72rem', color: '#D97706', fontWeight: 600, textDecoration: 'none' }}> {/* Logout — ben visibile */}
Admin
</NavLink>
)}
<button <button
onClick={logout} onClick={logout}
style={{ style={{
marginLeft: 'auto', width: '100%', padding: '0.7rem 1.25rem',
background: 'none', background: '#FFF0EE', border: 'none', borderTop: '1px solid var(--border)',
border: 'none', fontFamily: "'DM Sans', sans-serif", fontSize: '0.82rem', fontWeight: 700,
fontSize: '0.75rem', color: 'var(--accent)', cursor: 'pointer', textAlign: 'left',
color: 'var(--ink-muted)', transition: 'background-color 0.15s, color 0.15s',
cursor: 'pointer', letterSpacing: '0.02em',
padding: 0,
fontFamily: "'DM Sans', sans-serif",
}} }}
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)' }}
> >
Logout Esci
</button> </button>
</div> </div>
</div>
</aside> </aside>
{/* ── Main content ──────────────────────────────────────── */} {/* ── Right column ─────────────────────────────── */}
<main style={{ flex: 1, overflowY: 'auto' }}> <div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
<div style={{ maxWidth: 1100, margin: '0 auto', padding: '2rem 2.5rem' }}> {/* 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 /> <Outlet />
</div> </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> </main>
</div> </div>
</div>
) )
} }

View File

@@ -1,5 +1,5 @@
import { useState } from 'react' import { useState, useEffect } from 'react'
import { useNavigate, Link } from 'react-router-dom' import { useNavigate, Link, useSearchParams } from 'react-router-dom'
import { useAuth } from '../AuthContext' import { useAuth } from '../AuthContext'
import { BASE_URL } from '../api' import { BASE_URL } from '../api'
@@ -7,7 +7,7 @@ export default function LoginPage() {
const [mode, setMode] = useState('login') // 'login' | 'register' const [mode, setMode] = useState('login') // 'login' | 'register'
return ( 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 ─────────────────────── */} {/* ── LEFT — dark ink branding panel ─────────────────────── */}
<div style={{ <div style={{
@@ -145,6 +145,13 @@ function LoginForm({ onSwitchMode }) {
const { login } = useAuth() const { login } = useAuth()
const navigate = useNavigate() 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) => { const handleSubmit = async (e) => {
e.preventDefault() 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 const expires = user.subscription_expires_at
? new Date(user.subscription_expires_at).toLocaleDateString('it-IT') ? new Date(user.subscription_expires_at).toLocaleDateString('it-IT')
: null : null
// PRO users: minimal bar, non invasivo
return ( return (
<div style={{ <div style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '0.75rem', gap: '0.5rem',
padding: '0.75rem 1rem', padding: '0.4rem 1.5rem',
backgroundColor: 'var(--success-light, #F0F9F4)', backgroundColor: 'var(--success-light, #F0F9F4)',
border: '1px solid #A7F3D0', borderBottom: '1px solid #A7F3D0',
marginBottom: '1.5rem', fontSize: '0.72rem',
}}> fontWeight: 600,
<div style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: 'var(--success)', flexShrink: 0 }} /> letterSpacing: '0.06em',
<div>
<span style={{
fontSize: '0.75rem',
fontWeight: 700,
letterSpacing: '0.08em',
textTransform: 'uppercase', textTransform: 'uppercase',
color: 'var(--success)', color: 'var(--success)',
}}> }}>
<span style={{ width: 6, height: 6, borderRadius: '50%', backgroundColor: 'var(--success)', flexShrink: 0 }} />
Piano Pro Piano Pro
</span>
{expires && ( {expires && (
<span style={{ fontSize: '0.78rem', color: 'var(--success)', marginLeft: '0.5rem', opacity: 0.8 }}> <span style={{ fontWeight: 400, letterSpacing: 0, textTransform: 'none', opacity: 0.75 }}>
Attivo fino al {expires} Attivo fino al {expires}
</span> </span>
)} )}
</div> </div>
</div>
) )
} }
// Freemium users: usage bar con CTA
const postsUsed = user.posts_generated_this_month || 0 const postsUsed = user.posts_generated_this_month || 0
const postsMax = 15 const postsMax = 15
const pct = Math.min(100, (postsUsed / postsMax) * 100) const pct = Math.min(100, (postsUsed / postsMax) * 100)
const critical = pct >= 80
return ( return (
<> <>
@@ -53,31 +50,27 @@ export default function PlanBanner() {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
padding: '0.75rem 1rem', padding: '0.55rem 1.5rem',
backgroundColor: 'var(--accent-light)', backgroundColor: critical ? '#FFF5F3' : 'var(--cream-dark)',
border: '1px solid #FECCC8', borderBottom: `1px solid ${critical ? '#FECCC8' : 'var(--border)'}`,
marginBottom: '1.5rem',
flexWrap: 'wrap', flexWrap: 'wrap',
gap: '0.5rem', 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={{ <span style={{
fontSize: '0.72rem', fontSize: '0.68rem', fontWeight: 700, letterSpacing: '0.1em', textTransform: 'uppercase',
fontWeight: 700, color: critical ? 'var(--accent)' : 'var(--ink-muted)',
letterSpacing: '0.08em', flexShrink: 0,
textTransform: 'uppercase',
color: 'var(--accent)',
}}> }}>
Freemium Freemium
</span> </span>
<span style={{ fontSize: '0.8rem', color: 'var(--ink-light)' }}> <span style={{ fontSize: '0.78rem', color: 'var(--ink-muted)', flexShrink: 0 }}>
{postsUsed} / {postsMax} post questo mese {postsUsed}/{postsMax} post
</span> </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={{ <div style={{
height: '100%', height: '100%', width: `${pct}%`,
width: `${pct}%`, backgroundColor: critical ? 'var(--accent)' : 'var(--border-strong)',
backgroundColor: 'var(--accent)',
transition: 'width 0.6s ease', transition: 'width 0.6s ease',
}} /> }} />
</div> </div>
@@ -86,20 +79,16 @@ export default function PlanBanner() {
<button <button
onClick={() => setShowUpgrade(true)} onClick={() => setShowUpgrade(true)}
style={{ style={{
padding: '0.4rem 0.9rem', padding: '0.3rem 0.75rem',
backgroundColor: 'var(--ink)', backgroundColor: 'var(--ink)', color: 'white',
color: 'white', border: 'none', cursor: 'pointer',
border: 'none',
borderRadius: 0,
cursor: 'pointer',
fontFamily: "'DM Sans', sans-serif", fontFamily: "'DM Sans', sans-serif",
fontWeight: 600, fontWeight: 600, fontSize: '0.75rem',
fontSize: '0.78rem',
letterSpacing: '0.03em', 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)' }} onMouseEnter={e => e.currentTarget.style.backgroundColor = 'var(--accent)'}
onMouseLeave={(e) => { e.target.style.backgroundColor = 'var(--ink)'; e.target.style.transform = 'translateY(0)' }} onMouseLeave={e => e.currentTarget.style.backgroundColor = 'var(--ink)'}
> >
Passa a Pro Passa a Pro
</button> </button>

View File

@@ -46,11 +46,7 @@ export default function PlanForm() {
const [error, setError] = useState('') const [error, setError] = useState('')
const [loading, setLoading] = useState(isEdit) const [loading, setLoading] = useState(isEdit)
useEffect(() => { useEffect(() => { api.get('/characters/').then(setCharacters).catch(() => {}) }, [])
api.get('/characters/')
.then(setCharacters)
.catch(() => {})
}, [])
useEffect(() => { useEffect(() => {
if (isEdit) { if (isEdit) {
@@ -63,9 +59,7 @@ export default function PlanForm() {
posts_per_day: data.posts_per_day || 1, posts_per_day: data.posts_per_day || 1,
platforms: data.platforms || [], platforms: data.platforms || [],
content_types: data.content_types || [], content_types: data.content_types || [],
posting_times: data.posting_times && data.posting_times.length > 0 posting_times: data.posting_times?.length > 0 ? data.posting_times : ['09:00'],
? data.posting_times
: ['09:00'],
start_date: data.start_date ? data.start_date.split('T')[0] : '', start_date: data.start_date ? data.start_date.split('T')[0] : '',
end_date: data.end_date ? data.end_date.split('T')[0] : '', end_date: data.end_date ? data.end_date.split('T')[0] : '',
is_active: data.is_active ?? true, is_active: data.is_active ?? true,
@@ -76,61 +70,25 @@ export default function PlanForm() {
} }
}, [id, isEdit]) }, [id, isEdit])
const handleChange = (field, value) => { const handleChange = (field, value) => setForm((prev) => ({ ...prev, [field]: value }))
setForm((prev) => ({ ...prev, [field]: value }))
}
const toggleArrayItem = (field, value) => { const toggleArrayItem = (field, value) => {
setForm((prev) => { setForm((prev) => {
const arr = prev[field] || [] const arr = prev[field] || []
return { return { ...prev, [field]: arr.includes(value) ? arr.filter((v) => v !== value) : [...arr, value] }
...prev,
[field]: arr.includes(value)
? arr.filter((v) => v !== value)
: [...arr, value],
}
}) })
} }
const addPostingTime = () => { const addPostingTime = () => setForm((prev) => ({ ...prev, posting_times: [...prev.posting_times, '12:00'] }))
setForm((prev) => ({ const updatePostingTime = (index, value) => setForm((prev) => { const t = [...prev.posting_times]; t[index] = value; return { ...prev, posting_times: t } })
...prev, const removePostingTime = (index) => setForm((prev) => ({ ...prev, posting_times: prev.posting_times.filter((_, i) => i !== index) }))
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 handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault() e.preventDefault()
setError('') setError('')
if (!form.character_id) { setError('Seleziona un personaggio'); return }
if (!form.character_id) { if (form.platforms.length === 0) { setError('Seleziona almeno una piattaforma'); return }
setError('Seleziona un personaggio') if (form.content_types.length === 0) { setError('Seleziona almeno un tipo di contenuto'); return }
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) setSaving(true)
try { try {
const payload = { const payload = {
@@ -140,269 +98,222 @@ export default function PlanForm() {
start_date: form.start_date || null, start_date: form.start_date || null,
end_date: form.end_date || null, end_date: form.end_date || null,
} }
if (isEdit) { if (isEdit) await api.put(`/plans/${id}`, payload)
await api.put(`/plans/${id}`, payload) else await api.post('/plans/', payload)
} else {
await api.post('/plans/', payload)
}
navigate('/plans') navigate('/plans')
} catch (err) { } catch (err) {
setError(err.message || 'Errore nel salvataggio') setError(err.message || 'Errore nel salvataggio')
} finally { } finally { setSaving(false) }
setSaving(false)
}
} }
if (loading) { if (loading) return (
return ( <div style={{ display: 'flex', justifyContent: 'center', padding: '3rem 0' }}>
<div className="flex justify-center py-12"> <div style={{ width: 28, height: 28, border: '2px solid var(--border)', borderTopColor: 'var(--accent)', borderRadius: '50%', animation: 'spin 0.8s linear infinite' }} />
<div className="animate-spin rounded-full h-8 w-8 border-2 border-brand-500 border-t-transparent" /> <style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
</div> </div>
) )
}
return ( return (
<div> <div style={{ animation: 'fade-up 0.5s ease-out both' }}>
<div className="mb-6"> {/* Header */}
<h2 className="text-2xl font-bold text-slate-800"> <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'} {isEdit ? 'Modifica piano editoriale' : 'Nuovo piano editoriale'}
</h2> </h2>
<p className="text-slate-500 mt-1 text-sm"> <p style={{ fontSize: '0.875rem', color: 'var(--ink-muted)', margin: 0 }}>
{isEdit ? 'Aggiorna la configurazione del piano' : 'Configura un nuovo piano di pubblicazione'} Configura un piano di pubblicazione automatica per un personaggio specifico.
</p> </p>
</div> </div>
<form onSubmit={handleSubmit} className="max-w-2xl space-y-6">
{error && ( {error && (
<div className="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: '1.25rem' }}>
{error} {error}
</div> </div>
)} )}
{/* Basic info */} <form onSubmit={handleSubmit} style={{ maxWidth: 680, display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
<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> {/* ── Informazioni base ─────────────────────────────────── */}
<label className="block text-sm font-medium text-slate-700 mb-1"> <Section title="Informazioni base">
Personaggio <Field label="Personaggio">
</label> <select value={form.character_id} onChange={(e) => handleChange('character_id', e.target.value)} style={selectStyle} required>
<select <option value="">Seleziona personaggio</option>
value={form.character_id} {characters.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
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> </select>
</div> </Field>
<div> <Field label="Nome piano">
<label className="block text-sm font-medium text-slate-700 mb-1"> <input type="text" value={form.name} onChange={(e) => handleChange('name', e.target.value)}
Nome piano placeholder="Es. Piano Instagram Giornaliero…" style={inputStyle} required
</label> onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
<input </Field>
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"> <div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<label className="relative inline-flex items-center cursor-pointer"> <button type="button" onClick={() => handleChange('is_active', !form.is_active)} style={{
<input width: 40, height: 22, borderRadius: 11, border: 'none', cursor: 'pointer',
type="checkbox" backgroundColor: form.is_active ? 'var(--accent)' : 'var(--border-strong)',
checked={form.is_active} position: 'relative', transition: 'background-color 0.2s', flexShrink: 0,
onChange={(e) => handleChange('is_active', e.target.checked)} }}>
className="sr-only peer" <span style={{
/> position: 'absolute', top: 2, left: form.is_active ? 20 : 2,
<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> width: 18, height: 18, borderRadius: '50%', backgroundColor: 'white', transition: 'left 0.2s',
</label> }} />
<span className="text-sm text-slate-700">Attivo</span> </button>
</div> <span style={{ fontSize: '0.875rem', color: 'var(--ink)' }}>Attivo</span>
</div> </div>
</Section>
{/* Frequency */} {/* ── Frequenza ─────────────────────────────────────────── */}
<div className="bg-white rounded-xl border border-slate-200 p-6 space-y-4"> <Section title="Frequenza di pubblicazione">
<h3 className="font-semibold text-slate-700 text-sm uppercase tracking-wider"> <Field label="Frequenza">
Frequenza pubblicazione <select value={form.frequency} onChange={(e) => handleChange('frequency', e.target.value)} style={selectStyle}>
</h3> {FREQUENCY_OPTIONS.map((opt) => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
<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>
))}
</select> </select>
</div> </Field>
{form.frequency === 'custom' && ( {form.frequency === 'custom' && (
<div> <Field label="Post al giorno">
<label className="block text-sm font-medium text-slate-700 mb-1"> <input type="number" min="1" max="20" value={form.posts_per_day}
Post al giorno
</label>
<input
type="number"
min="1"
max="20"
value={form.posts_per_day}
onChange={(e) => handleChange('posts_per_day', e.target.value)} 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" style={{ ...inputStyle, width: 100 }}
/> onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
</div> </Field>
)} )}
</div> </Section>
{/* Platforms & Content Types */} {/* ── Piattaforme e tipi ────────────────────────────────── */}
<div className="bg-white rounded-xl border border-slate-200 p-6 space-y-4"> <Section title="Piattaforme e tipi di contenuto">
<h3 className="font-semibold text-slate-700 text-sm uppercase tracking-wider"> <Field label="Piattaforme">
Piattaforme e tipi di contenuto <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem', marginTop: '0.25rem' }}>
</h3> {PLATFORM_OPTIONS.map((opt) => {
const active = form.platforms.includes(opt.value)
<div> return (
<label className="block text-sm font-medium text-slate-700 mb-2"> <button key={opt.value} type="button" onClick={() => toggleArrayItem('platforms', opt.value)} style={{
Piattaforme padding: '0.35rem 0.875rem', fontSize: '0.82rem', fontFamily: "'DM Sans', sans-serif",
</label> border: 'none', cursor: 'pointer',
<div className="flex flex-wrap gap-3"> backgroundColor: active ? 'var(--ink)' : 'var(--cream-dark)',
{PLATFORM_OPTIONS.map((opt) => ( color: active ? 'white' : 'var(--ink-muted)',
<label key={opt.value} className="flex items-center gap-2 cursor-pointer"> transition: 'background-color 0.15s',
<input }}>
type="checkbox" {active ? '✓ ' : ''}{opt.label}
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>
</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"
>
+ Aggiungi orario
</button> </button>
)
})}
</div> </div>
</Field>
<div className="space-y-2"> <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>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{form.posting_times.map((time, i) => ( {form.posting_times.map((time, i) => (
<div key={i} className="flex items-center gap-2"> <div key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<input <input type="time" value={time} onChange={(e) => updatePostingTime(i, e.target.value)}
type="time" style={{ ...inputStyle, width: 'auto' }}
value={time} onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
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 && ( {form.posting_times.length > 1 && (
<button <button type="button" onClick={() => removePostingTime(i)} style={{
type="button" padding: '0.35rem 0.75rem', fontSize: '0.78rem',
onClick={() => removePostingTime(i)} backgroundColor: 'var(--cream-dark)', color: 'var(--error)',
className="text-xs px-2 py-1 text-red-500 hover:bg-red-50 rounded transition-colors" border: 'none', cursor: 'pointer', fontFamily: "'DM Sans', sans-serif",
> }}>
Rimuovi Rimuovi
</button> </button>
)} )}
</div> </div>
))} ))}
</div> </div>
</div> <button type="button" onClick={addPostingTime} style={{ ...btnSecondary, marginTop: '0.5rem', fontSize: '0.82rem', padding: '0.4rem 0.875rem' }}>
+ Aggiungi orario
{/* 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>
<button </div>
type="button" </Section>
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" {/* ── Periodo ───────────────────────────────────────────── */}
> <Section title="Periodo">
Annulla <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>
</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 piano'}
</button> </button>
<button type="button" onClick={() => navigate('/plans')} style={btnSecondary}>Annulla</button>
</div> </div>
</form> </form>
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
</div> </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 { Link } from 'react-router-dom'
import { api } from '../api' import { api } from '../api'
const frequencyLabels = { const FREQUENCY_LABELS = {
daily: 'Giornaliero', daily: 'Giornaliero', twice_daily: '2× al giorno',
twice_daily: 'Due volte al giorno', weekly: 'Settimanale', custom: 'Personalizzato',
weekly: 'Settimanale',
custom: 'Personalizzato',
} }
const PLATFORM_LABELS = {
const platformLabels = { instagram: 'Instagram', facebook: 'Facebook',
instagram: 'Instagram', youtube: 'YouTube', tiktok: 'TikTok',
facebook: 'Facebook',
youtube: 'YouTube',
tiktok: 'TikTok',
} }
export default function PlanList() { export default function PlanList() {
@@ -21,193 +16,173 @@ export default function PlanList() {
const [characters, setCharacters] = useState([]) const [characters, setCharacters] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
useEffect(() => { useEffect(() => { loadData() }, [])
loadData()
}, [])
const loadData = async () => { const loadData = async () => {
setLoading(true) setLoading(true)
try { try {
const [plansData, charsData] = await Promise.all([ const [plansData, charsData] = await Promise.all([api.get('/plans/'), api.get('/characters/')])
api.get('/plans/'),
api.get('/characters/'),
])
setPlans(plansData) setPlans(plansData)
setCharacters(charsData) setCharacters(charsData)
} catch { } catch {} finally { setLoading(false) }
// silent
} finally {
setLoading(false)
}
} }
const getCharacterName = (id) => { const getCharacterName = (id) => characters.find(c => c.id === id)?.name || '—'
const c = characters.find((ch) => ch.id === id)
return c ? c.name : '—'
}
const handleToggle = async (plan) => { const handleToggle = async (plan) => {
try { await api.post(`/plans/${plan.id}/toggle`).catch(() => {})
await api.post(`/plans/${plan.id}/toggle`)
loadData() loadData()
} catch {
// silent
}
} }
const handleDelete = async (id, name) => { const handleDelete = async (id, name) => {
if (!confirm(`Eliminare il piano "${name}"?`)) return if (!confirm(`Eliminare il piano "${name}"?`)) return
try { await api.delete(`/plans/${id}`).catch(() => {})
await api.delete(`/plans/${id}`)
loadData() loadData()
} catch {
// silent
}
} }
const formatDate = (dateStr) => { const formatDate = (d) => d ? new Date(d).toLocaleDateString('it-IT', { day: '2-digit', month: 'short', year: 'numeric' }) : '—'
if (!dateStr) return '—'
const d = new Date(dateStr)
return d.toLocaleDateString('it-IT', { day: '2-digit', month: 'short', year: 'numeric' })
}
return ( return (
<div style={{ animation: 'fade-up 0.5s ease-out both' }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: '2rem' }}>
<div> <div>
<div className="flex items-center justify-between mb-6"> <span className="editorial-tag">Piano Editoriale</span>
<div> <div className="editorial-line" />
<h2 className="text-2xl font-bold text-slate-800">Piano Editoriale</h2> <h2 style={{ fontFamily: "'Fraunces', serif", fontSize: '1.75rem', fontWeight: 600, letterSpacing: '-0.02em', color: 'var(--ink)', margin: '0.5rem 0 0.3rem' }}>
<p className="text-slate-500 mt-1 text-sm"> Piani di Pubblicazione
Gestisci i piani di pubblicazione automatica </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> </p>
</div> </div>
<Link <Link to="/plans/new" style={btnPrimary}>+ Nuovo Piano</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>
</div> </div>
{loading ? ( {loading ? (
<div className="flex justify-center py-12"> <Spinner />
<div className="animate-spin rounded-full h-8 w-8 border-2 border-brand-500 border-t-transparent" />
</div>
) : plans.length === 0 ? ( ) : plans.length === 0 ? (
<div className="text-center py-16 bg-white rounded-xl border border-slate-200"> <EmptyState
<p className="text-4xl mb-3"></p> icon="▦"
<p className="text-slate-500 font-medium">Nessun piano editoriale</p> title="Nessun piano di pubblicazione"
<p className="text-slate-400 text-sm mt-1"> description="Un piano editoriale definisce quando e dove ogni personaggio pubblica. Crea il primo piano per iniziare la schedulazione automatica dei contenuti."
Crea un piano per automatizzare la pubblicazione dei contenuti cta="+ Crea il primo Piano"
</p>
<Link
to="/plans/new" 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>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: '1rem' }}>
{plans.map((plan) => ( {plans.map(plan => (
<div <PlanCard key={plan.id} plan={plan} characterName={getCharacterName(plan.character_id)}
key={plan.id} onToggle={handleToggle} onDelete={handleDelete} formatDate={formatDate} />
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> </div>
)} )}
</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 { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../api' import { api } from '../api'
import { useAuth } from '../AuthContext'
const platformLabels = { const PLATFORMS = {
instagram: 'Instagram', instagram: {
facebook: 'Facebook', label: 'Instagram', color: '#E1306C', bg: '#FFF0F5',
youtube: 'YouTube', steps: [
tiktok: 'TikTok', '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 = { const EMPTY_FORM = { platform: 'instagram', account_name: '', access_token: '', page_id: '' }
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: '',
}
export default function SocialAccounts() { export default function SocialAccounts() {
const { isPro } = useAuth()
const [characters, setCharacters] = useState([]) const [characters, setCharacters] = useState([])
const [accounts, setAccounts] = useState([]) const [accounts, setAccounts] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(null) // character_id or null const [showForm, setShowForm] = useState(null)
const [form, setForm] = useState(EMPTY_ACCOUNT) const [guideOpen, setGuideOpen] = useState({})
const [form, setForm] = useState(EMPTY_FORM)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [testing, setTesting] = useState(null) const [testing, setTesting] = useState(null)
const [testResult, setTestResult] = useState({}) const [testResult, setTestResult] = useState({})
useEffect(() => { useEffect(() => { loadData() }, [])
loadData()
}, [])
const loadData = async () => { const loadData = async () => {
setLoading(true) setLoading(true)
try { try {
const [charsData, accsData] = await Promise.all([ const [chars, accs] = await Promise.all([api.get('/characters/'), api.get('/social/accounts')])
api.get('/characters/'), setCharacters(chars)
api.get('/social/accounts'), setAccounts(accs)
]) } catch {} finally { setLoading(false) }
setCharacters(charsData)
setAccounts(accsData)
} catch {
// silent
} finally {
setLoading(false)
}
} }
const getAccountsForCharacter = (characterId) => { const getCharAccounts = (charId) => accounts.filter(a => a.character_id === charId)
return accounts.filter((a) => a.character_id === characterId)
}
const handleFormChange = (field, value) => { const handleAdd = async (charId) => {
setForm((prev) => ({ ...prev, [field]: value }))
}
const handleAddAccount = async (characterId) => {
setError('') setError('')
setSaving(true) setSaving(true)
try { try {
await api.post('/social/accounts', { await api.post('/social/accounts', { character_id: charId, ...form, page_id: form.page_id || null })
character_id: characterId,
platform: form.platform,
account_name: form.account_name,
access_token: form.access_token,
page_id: form.page_id || null,
})
setShowForm(null) setShowForm(null)
setForm(EMPTY_ACCOUNT) setForm(EMPTY_FORM)
loadData() loadData()
} catch (err) { } catch (e) { setError(e.message || 'Errore nel salvataggio')
setError(err.message || 'Errore nel salvataggio') } finally { setSaving(false) }
} finally {
setSaving(false)
}
} }
const handleTest = async (accountId) => { const handleTest = async (id) => {
setTesting(accountId) setTesting(id)
setTestResult((prev) => ({ ...prev, [accountId]: null }))
try { try {
const result = await api.post(`/social/accounts/${accountId}/test`) const r = await api.post(`/social/accounts/${id}/test`)
setTestResult((prev) => ({ ...prev, [accountId]: { success: true, message: result.message || 'Connessione OK' } })) setTestResult(p => ({ ...p, [id]: { ok: true, msg: r.message || 'OK' } }))
} catch (err) { } catch (e) {
setTestResult((prev) => ({ ...prev, [accountId]: { success: false, message: err.message || 'Test fallito' } })) setTestResult(p => ({ ...p, [id]: { ok: false, msg: e.message || 'Fallito' } }))
} finally { } finally { setTesting(null) }
setTesting(null)
}
} }
const handleToggle = async (account) => { const handleToggle = async (acc) => {
try { await api.put(`/social/accounts/${acc.id}`, { is_active: !acc.is_active }).catch(() => {})
await api.put(`/social/accounts/${account.id}`, { is_active: !account.is_active })
loadData() loadData()
} catch {
// silent
}
} }
const handleRemove = async (accountId) => { const handleRemove = async (id) => {
if (!confirm('Rimuovere questo account social?')) return if (!confirm('Rimuovere questo account?')) return
try { await api.delete(`/social/accounts/${id}`).catch(() => {})
await api.delete(`/social/accounts/${accountId}`)
loadData() loadData()
} catch {
// silent
}
} }
if (loading) { const isProLocked = (platform) => PLATFORMS[platform]?.proOnly && !isPro
return (
<div> if (loading) return <Spinner />
<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>
)
}
return ( return (
<div> <div style={{ animation: 'fade-up 0.5s ease-out both' }}>
<div className="mb-6"> {/* Header */}
<h2 className="text-2xl font-bold text-slate-800">Account Social</h2> <div style={{ marginBottom: '2rem' }}>
<p className="text-slate-500 mt-1 text-sm"> <span className="editorial-tag">Social</span>
Gestisci le connessioni ai social network per ogni personaggio <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> </p>
</div> </div>
{/* Info box */} {/* No characters gate */}
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-xl"> {characters.length === 0 && (
<div className="flex gap-3"> <div style={{ padding: '3rem 2rem', textAlign: 'center', backgroundColor: 'var(--surface)', border: '1px solid var(--border)', borderTop: '4px solid var(--accent)', marginBottom: '1.5rem' }}>
<span className="text-blue-500 text-lg shrink-0">i</span> <div style={{ fontSize: '2rem', color: 'var(--accent)', marginBottom: '1rem' }}></div>
<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 className="text-sm text-blue-700 font-medium">Configurazione OAuth</p> <p style={{ fontSize: '0.875rem', color: 'var(--ink-muted)', maxWidth: 360, margin: '0 auto 1.25rem', lineHeight: 1.6 }}>
<p className="text-xs text-blue-600 mt-0.5"> Gli account social si collegano ai personaggi. Crea prima un personaggio, poi torna qui per collegare i suoi account.
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> </p>
</div> <Link to="/characters/new" style={btnPrimary}>Crea il tuo primo Personaggio </Link>
</div>
</div>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">
{error}
</div> </div>
)} )}
{characters.length === 0 ? ( {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>}
<div className="text-center py-16 bg-white rounded-xl border border-slate-200">
<p className="text-4xl mb-3">◇</p> {/* Platform guides */}
<p className="text-slate-500 font-medium">Nessun personaggio</p> <div style={{ marginBottom: '2rem' }}>
<p className="text-slate-400 text-sm mt-1"> <p style={{ fontSize: '0.78rem', fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--ink-muted)', marginBottom: '0.75rem' }}>Guide per piattaforma</p>
Crea un personaggio per poi collegare gli account social <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
</p> {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> </div>
) : ( <span style={{ fontSize: '0.75rem', color: 'var(--ink-muted)' }}>{guideOpen[key] ? '▲ Chiudi' : '▼ Come ottenere il token'}</span>
<div className="space-y-6"> </button>
{characters.map((character) => { {guideOpen[key] && (
const charAccounts = getAccountsForCharacter(character.id) <div style={{ padding: '1rem 1.25rem 1.25rem', borderTop: '1px solid var(--border)', backgroundColor: p.bg }}>
const isFormOpen = showForm === character.id <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>
{/* 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 ( return (
<div key={character.id} className="bg-white rounded-xl border border-slate-200 overflow-hidden"> <div key={char.id} style={{ border: '1px solid var(--border)', backgroundColor: 'var(--surface)', overflow: 'hidden' }}>
{/* Character header */} {/* Char header */}
<div className="p-5 border-b border-slate-100"> <div style={{ padding: '1rem 1.25rem', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem' }}>
<div className="flex items-center justify-between"> <div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<div className="flex items-center gap-3"> <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 }}>
<div {char.name?.charAt(0).toUpperCase()}
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>
<div> <div>
<h3 className="font-semibold text-slate-800">{character.name}</h3> <p style={{ fontWeight: 600, color: 'var(--ink)', margin: 0, fontSize: '0.9rem' }}>{char.name}</p>
<p className="text-xs text-slate-400">{character.niche}</p> <p style={{ fontSize: '0.75rem', color: 'var(--ink-muted)', margin: 0 }}>{char.niche}</p>
</div> </div>
</div> </div>
<button <button onClick={() => { setShowForm(formOpen ? null : char.id); setForm(EMPTY_FORM); setError('') }} style={formOpen ? btnSecondary : btnPrimary}>
onClick={() => { {formOpen ? 'Annulla' : '+ Connetti Account'}
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> </button>
</div> </div>
</div>
{/* Inline form */} {/* Add form */}
{isFormOpen && ( {formOpen && (
<div className="p-5 bg-slate-50 border-b border-slate-100"> <div style={{ padding: '1.25rem', backgroundColor: 'var(--cream)', borderBottom: '1px solid var(--border)' }}>
<div className="max-w-md space-y-3"> <div style={{ maxWidth: 480 }}>
<div> <p style={{ fontSize: '0.78rem', fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--ink-muted)', marginBottom: '1rem' }}>
<label className="block text-sm font-medium text-slate-700 mb-1">Piattaforma</label> Aggiungi account social per {char.name}
<select </p>
value={form.platform}
onChange={(e) => handleFormChange('platform', e.target.value)} <Field label="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 bg-white" <div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap' }}>
> {Object.entries(PLATFORMS).map(([key, p]) => (
{Object.entries(platformLabels).map(([val, label]) => ( <button key={key} type="button" onClick={() => setForm(prev => ({ ...prev, platform: key }))} style={{
<option key={val} value={val}>{label}</option> 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>
</Field>
{/* 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>
<div> <Field label="Nome account">
<label className="block text-sm font-medium text-slate-700 mb-1">Nome account</label> <input type="text" value={form.account_name} onChange={e => setForm(p => ({ ...p, account_name: e.target.value }))}
<input placeholder="Es. @mio_profilo o Nome Pagina" style={inputStyle}
type="text" onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
value={form.account_name} </Field>
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>
<div> <Field label={PLATFORMS[form.platform].tokenLabel}>
<label className="block text-sm font-medium text-slate-700 mb-1">Access Token</label> <input type="password" value={form.access_token} onChange={e => setForm(p => ({ ...p, access_token: e.target.value }))}
<input placeholder={PLATFORMS[form.platform].tokenPlaceholder} style={{ ...inputStyle, fontFamily: 'monospace', fontSize: '0.82rem' }}
type="password" onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
value={form.access_token} </Field>
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>
<div> {PLATFORMS[form.platform].pageIdLabel && (
<label className="block text-sm font-medium text-slate-700 mb-1"> <Field label={PLATFORMS[form.platform].pageIdLabel}>
Page ID <input type="text" value={form.page_id} onChange={e => setForm(p => ({ ...p, page_id: e.target.value }))}
<span className="text-slate-400 font-normal ml-1">(opzionale)</span> placeholder={PLATFORMS[form.platform].pageIdPlaceholder} style={{ ...inputStyle, fontFamily: 'monospace', fontSize: '0.82rem' }}
</label> onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
<input </Field>
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>
<button <button onClick={() => handleAdd(char.id)} disabled={saving || !form.account_name || !form.access_token} style={{
onClick={() => handleAddAccount(character.id)} ...btnPrimary, opacity: (saving || !form.account_name || !form.access_token) ? 0.6 : 1,
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'}
>
{saving ? 'Salvataggio...' : 'Salva Account'}
</button> </button>
</>
)}
</div> </div>
</div> </div>
)} )}
{/* Accounts list */} {/* Accounts list */}
<div className="divide-y divide-slate-50"> <div>
{charAccounts.length === 0 ? ( {charAccs.length === 0 ? (
<div className="px-5 py-8 text-center"> <div style={{ padding: '1.5rem', textAlign: 'center', color: 'var(--ink-muted)', fontSize: '0.85rem' }}>
<p className="text-sm text-slate-400">Nessun account collegato</p> Nessun account collegato clicca "+ Connetti Account" per iniziare.
</div> </div>
) : ( ) : (
charAccounts.map((account) => ( charAccs.map(acc => {
<div key={account.id} className="px-5 py-3 flex items-center gap-3"> const plat = PLATFORMS[acc.platform] || { label: acc.platform, color: 'var(--ink-muted)' }
{/* Platform badge */} const tr = testResult[acc.id]
<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'}`}> return (
{platformLabels[account.platform] || account.platform} <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> <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>
{/* Account name */} <span style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: acc.is_active ? 'var(--success)' : 'var(--border-strong)', flexShrink: 0 }} />
<span className="text-sm font-medium text-slate-700 flex-1 min-w-0 truncate"> {tr && <span style={{ fontSize: '0.75rem', color: tr.ok ? 'var(--success)' : 'var(--error)' }}>{tr.msg}</span>}
{account.account_name} <div style={{ display: 'flex', gap: '0.35rem', flexShrink: 0 }}>
</span> <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>
{/* Status */} <button onClick={() => handleRemove(acc.id)} style={{ ...btnTiny, color: 'var(--error)' }}>Rimuovi</button>
<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>
</div> </div>
</div> </div>
)) )
})
)} )}
</div> </div>
</div> </div>
@@ -329,3 +335,42 @@ export default function SocialAccounts() {
</div> </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>
)
}

View File

@@ -29,8 +29,26 @@
* { * {
box-sizing: border-box; box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
} }
/* Prevent buttons from inheriting unexpected colors */
button {
color: inherit;
font-family: inherit;
}
/* ─── Base layout — mobile-first ───────────────────────────────── */
html, body {
height: 100%;
overflow-x: hidden;
}
#root {
min-height: 100dvh;
}
body { body {
background-color: var(--cream); background-color: var(--cream);
color: var(--ink); color: var(--ink);
@@ -175,3 +193,48 @@ h1, h2, h3, h4 {
::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 2px; } ::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 2px; }
::-webkit-scrollbar-thumb:hover { background: var(--ink-muted); } ::-webkit-scrollbar-thumb:hover { background: var(--ink-muted); }
/* ─── Mobile / Responsive ───────────────────────────────────── */
@media (max-width: 767px) {
/* Minimum touch target size */
button, a, [role="button"] {
min-height: 44px;
min-width: 44px;
}
/* Prevent iOS font size zoom on inputs */
input, select, textarea {
font-size: 16px !important;
}
/* Cards stack nicely on mobile */
.card-editorial {
padding: 1.25rem !important;
}
/* Legal pages readable on small screens */
.legal-content table {
font-size: 0.78rem;
display: block;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
/* Fix ghost navbar space on mobile */
#root {
min-height: 100dvh;
min-height: -webkit-fill-available;
}
/* Ensure secondary buttons always look like buttons on mobile */
.btn-outline, .btn-primary {
min-height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
}
}
/* ─── Mobile UX improvements ───────────────────────────────────── */
/* ─── Print ─────────────────────────────────────────────────── */
@media print {
aside, footer, .cookie-banner { display: none !important; }
}