feat: landing page, schedule action, editorial calendar LLM, Fase D foundations

Landing Page:
- Public landing page at /landing with hero, features grid, CTA
- ProtectedRoute redirects to /landing instead of /login when not auth'd
- Editorial Fresh design: Fraunces headings, clamp() responsive sizing

Schedule Action:
- "Schedula" button appears after approving a post
- ScheduleModal: date/time picker, creates ScheduledPost via API
- Reminder to connect social accounts for automatic publishing

Editorial Calendar LLM:
- Backend: generate-calendar now calls LLM to generate hook + brief for each slot
- Uses character profile (voice, target, niche) for contextual ideas
- Respects brief strategico from the UI
- Frontend: slots show AI-generated hook (Fraunces serif) + brief description
- Each slot has "Genera contenuto →" link for one-click content generation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michele
2026-04-05 03:38:35 +02:00
parent 67bc0d2980
commit 8c68004a5e
6 changed files with 362 additions and 11 deletions

View File

@@ -1,20 +1,23 @@
"""Editorial Calendar router.
Espone endpoint per il calendario editoriale con awareness levels e formati narrativi.
Integra LLM per generare idee di contenuto reali basate su profilo personaggio.
"""
import csv
import io
from typing import Optional
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session
from ..auth import get_current_user
from ..database import get_db
from ..models import Character, SystemSetting, User
from ..services.calendar_service import CalendarService, FORMATI_NARRATIVI, AWARENESS_LEVELS
from ..services.llm import get_llm_provider
router = APIRouter(
prefix="/api/editorial",
@@ -25,6 +28,19 @@ router = APIRouter(
_calendar_service = CalendarService()
def _get_setting(db: Session, key: str, user_id: int = None) -> str | None:
if user_id is not None:
setting = db.query(SystemSetting).filter(
SystemSetting.key == key, SystemSetting.user_id == user_id
).first()
if setting is not None:
return setting.value
setting = db.query(SystemSetting).filter(
SystemSetting.key == key, SystemSetting.user_id == None
).first()
return setting.value if setting else None
# === Schemas locali ===
class CalendarGenerateRequest(BaseModel):
@@ -34,6 +50,7 @@ class CalendarGenerateRequest(BaseModel):
num_posts: int = 7
start_date: Optional[str] = None
character_id: Optional[int] = None
brief: Optional[str] = None
class ExportCsvRequest(BaseModel):
@@ -55,11 +72,16 @@ def get_formats():
@router.post("/generate-calendar")
def generate_calendar(request: CalendarGenerateRequest, db: Session = Depends(get_db)):
"""Genera un calendario editoriale con awareness levels."""
def generate_calendar(
request: CalendarGenerateRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Genera un calendario editoriale con idee di contenuto AI."""
if not request.topics:
return {"slots": [], "totale_post": 0}
# Generate structural slots
slots = _calendar_service.generate_calendar(
topics=request.topics,
num_posts=request.num_posts,
@@ -68,6 +90,82 @@ def generate_calendar(request: CalendarGenerateRequest, db: Session = Depends(ge
start_date=request.start_date,
)
# Try to enrich with LLM-generated ideas
provider_name = _get_setting(db, "llm_provider", current_user.id)
api_key = _get_setting(db, "llm_api_key", current_user.id)
model = _get_setting(db, "llm_model", current_user.id)
if provider_name and api_key and slots:
try:
base_url = _get_setting(db, "llm_base_url", current_user.id)
llm = get_llm_provider(provider_name, api_key, model, base_url=base_url)
# Build context from character if available
char_context = ""
if request.character_id:
character = db.query(Character).filter(
Character.id == request.character_id,
Character.user_id == current_user.id,
).first()
if character:
char_context = (
f"Personaggio: {character.name}, nicchia: {character.niche}\n"
f"Target: {character.target_audience or 'generico'}\n"
f"Voce: {character.brand_voice or character.tone or 'professionale'}\n"
)
brief_context = f"\nBrief strategico: {request.brief}" if request.brief else ""
slot_descriptions = "\n".join(
f"- Slot {s['indice']+1}: topic={s['topic']}, formato={s['formato_narrativo']}, "
f"awareness={s['awareness_label']}, data={s['data_pubblicazione']}"
for s in slots
)
system_prompt = (
"Sei un social media strategist esperto. "
"Per ogni slot del calendario editoriale, genera un'idea di post concreta: "
"un titolo/hook (max 10 parole) e un mini-brief (max 25 parole) che descrive il contenuto. "
"Rispondi SOLO con il formato:\n"
"1. HOOK: ... | BRIEF: ...\n"
"2. HOOK: ... | BRIEF: ...\n"
"Una riga per slot, nient'altro."
)
prompt = (
f"{char_context}{brief_context}\n\n"
f"Calendario ({len(slots)} slot):\n{slot_descriptions}\n\n"
f"Genera hook e brief per ogni slot:"
)
result = llm.generate(prompt, system=system_prompt)
# Parse result and enrich slots
lines = [l.strip() for l in result.strip().splitlines() if l.strip()]
for i, line in enumerate(lines):
if i >= len(slots):
break
# Parse "N. HOOK: ... | BRIEF: ..."
hook = ""
brief = ""
if "HOOK:" in line and "BRIEF:" in line:
parts = line.split("BRIEF:")
hook_part = parts[0]
brief = parts[1].strip() if len(parts) > 1 else ""
hook = hook_part.split("HOOK:")[-1].strip().rstrip("|").strip()
elif "|" in line:
parts = line.split("|", 1)
hook = parts[0].strip().lstrip("0123456789. ")
brief = parts[1].strip() if len(parts) > 1 else ""
else:
hook = line.lstrip("0123456789. ").strip()
slots[i]["hook"] = hook
slots[i]["idea_brief"] = brief
except Exception:
pass # Fall back to structural-only slots
return {
"slots": slots,
"totale_post": len(slots),

View File

@@ -19,6 +19,7 @@ import CommentsQueue from './components/CommentsQueue'
import SettingsPage from './components/SettingsPage'
import EditorialCalendar from './components/EditorialCalendar'
import AdminSettings from './components/AdminSettings'
import LandingPage from './components/LandingPage'
import CookieBanner from './components/CookieBanner'
import PrivacyPolicy from './components/legal/PrivacyPolicy'
import TermsOfService from './components/legal/TermsOfService'
@@ -35,6 +36,7 @@ export default function App() {
<CookieBanner />
<Routes>
{/* Public routes */}
<Route path="/landing" element={<LandingPage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/privacy" element={<PrivacyPolicy />} />
@@ -42,7 +44,7 @@ export default function App() {
<Route path="/cookie" element={<CookiePolicy />} />
{/* Protected routes */}
<Route element={<ProtectedRoute />}>
<Route element={<ProtectedRoute fallback="/landing" />}>
<Route element={<Layout />}>
<Route path="/" element={<Dashboard />} />
<Route path="/characters" element={<CharacterList />} />

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { Link, useSearchParams } from 'react-router-dom'
import { api } from '../api'
import { useAuth } from '../AuthContext'
@@ -50,6 +51,9 @@ export default function ContentPage() {
const [editing, setEditing] = useState(false)
const [editText, setEditText] = useState('')
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [showScheduleModal, setShowScheduleModal] = useState(false)
const [scheduleDate, setScheduleDate] = useState('')
const [scheduleTime, setScheduleTime] = useState('09:00')
const [form, setForm] = useState({
character_id: '',
@@ -172,6 +176,21 @@ export default function ContentPage() {
} catch (err) { setError(err.message || 'Errore eliminazione') }
}
const handleSchedule = async () => {
if (!generated || !scheduleDate) return
try {
const scheduledAt = new Date(`${scheduleDate}T${scheduleTime}:00`).toISOString()
await api.post('/plans/schedule', {
post_id: generated.id,
platform: generated.platform_hint || 'instagram',
scheduled_at: scheduledAt,
})
updateActivePost({ status: 'scheduled' })
setShowScheduleModal(false)
setError('')
} catch (err) { setError(err.message || 'Errore nella schedulazione') }
}
return (
<div style={{ animation: 'fade-up 0.5s ease-out both' }}>
{/* Header */}
@@ -429,13 +448,21 @@ export default function ContentPage() {
/>
)}
<div style={{ display: 'flex', gap: '0.5rem', paddingTop: '1rem', borderTop: '1px solid var(--border)', flexWrap: 'wrap' }}>
{generated.status !== 'approved' && (
<div style={{ display: 'flex', gap: '0.5rem', paddingTop: '1rem', borderTop: '1px solid var(--border)', flexWrap: 'wrap', alignItems: 'center' }}>
{generated.status !== 'approved' && generated.status !== 'scheduled' && (
<button onClick={handleApprove} style={{ ...btnPrimary, backgroundColor: 'var(--success)' }}>Approva</button>
)}
{generated.status === 'approved' && (
<button onClick={() => setShowScheduleModal(true)} style={{ ...btnPrimary, backgroundColor: '#3B82F6' }}>Schedula</button>
)}
{!editing && <button onClick={() => setEditing(true)} style={btnSecondary}>Modifica</button>}
<button onClick={() => setShowDeleteConfirm(true)} style={{ ...btnSecondary, marginLeft: 'auto', color: 'var(--error)' }}>Elimina</button>
</div>
{generated.status === 'approved' && (
<p style={{ fontSize: '0.75rem', color: 'var(--ink-muted)', marginTop: '0.5rem', lineHeight: 1.5 }}>
Per pubblicare automaticamente, connetti i tuoi account social in <Link to="/social" style={{ color: 'var(--accent)' }}>Impostazioni Social</Link>.
</p>
)}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '4rem 1rem', textAlign: 'center' }}>
@@ -458,11 +485,69 @@ export default function ContentPage() {
onConfirm={handleDelete}
onCancel={() => setShowDeleteConfirm(false)}
/>
{showScheduleModal && (
<ScheduleModal
date={scheduleDate}
time={scheduleTime}
onDateChange={setScheduleDate}
onTimeChange={setScheduleTime}
onConfirm={handleSchedule}
onCancel={() => setShowScheduleModal(false)}
platform={generated?.platform_hint}
/>
)}
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
</div>
)
}
function ScheduleModal({ date, time, onDateChange, onTimeChange, onConfirm, onCancel, platform }) {
// Default to tomorrow
const tomorrow = new Date(Date.now() + 86400000).toISOString().split('T')[0]
if (!date) onDateChange(tomorrow)
return createPortal(
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, zIndex: 99999, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div onClick={onCancel} style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(26,26,26,0.5)', backdropFilter: 'blur(3px)' }} />
<div style={{
position: 'relative', backgroundColor: 'var(--surface)', border: '1px solid var(--border)',
borderTop: '4px solid #3B82F6', padding: '2rem', maxWidth: 400, width: '90%',
boxShadow: '0 20px 60px rgba(0,0,0,0.15)',
}}>
<h3 style={{ fontFamily: "'Fraunces', serif", fontSize: '1.15rem', fontWeight: 600, color: 'var(--ink)', margin: '0 0 0.5rem' }}>
Schedula pubblicazione
</h3>
<p style={{ fontSize: '0.82rem', color: 'var(--ink-muted)', margin: '0 0 1.25rem', lineHeight: 1.5 }}>
Scegli data e ora per la pubblicazione{platform ? ` su ${platform}` : ''}.
Il post verrà pubblicato automaticamente quando i social saranno connessi.
</p>
<div style={{ display: 'flex', gap: '0.75rem', marginBottom: '1.5rem' }}>
<div style={{ flex: 1 }}>
<label style={{ display: 'block', fontSize: '0.7rem', fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--ink-muted)', marginBottom: '0.3rem' }}>Data</label>
<input type="date" value={date || tomorrow} onChange={e => onDateChange(e.target.value)}
min={new Date().toISOString().split('T')[0]}
style={{ width: '100%', padding: '0.5rem', border: '1px solid var(--border)', fontSize: '0.875rem', fontFamily: "'DM Sans', sans-serif", outline: 'none' }} />
</div>
<div style={{ width: 100 }}>
<label style={{ display: 'block', fontSize: '0.7rem', fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--ink-muted)', marginBottom: '0.3rem' }}>Ora</label>
<input type="time" value={time} onChange={e => onTimeChange(e.target.value)}
style={{ width: '100%', padding: '0.5rem', border: '1px solid var(--border)', fontSize: '0.875rem', fontFamily: "'DM Sans', sans-serif", outline: 'none' }} />
</div>
</div>
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
<button onClick={onCancel} style={{ padding: '0.5rem 1rem', fontSize: '0.85rem', fontWeight: 600, fontFamily: "'DM Sans', sans-serif", backgroundColor: 'var(--cream-dark)', color: 'var(--ink-light)', border: 'none', cursor: 'pointer' }}>
Annulla
</button>
<button onClick={onConfirm} style={{ padding: '0.5rem 1rem', fontSize: '0.85rem', fontWeight: 600, fontFamily: "'DM Sans', sans-serif", backgroundColor: '#3B82F6', color: 'white', border: 'none', cursor: 'pointer' }}>
Schedula
</button>
</div>
</div>
</div>,
document.body
)
}
function HashtagEditor({ hashtags, onChange, postId }) {
const [newTag, setNewTag] = useState('')
const [editIdx, setEditIdx] = useState(null)

View File

@@ -440,7 +440,25 @@ function CalendarioTab({ strategy }) {
</span>
</div>
<p style={{ fontSize: '0.875rem', fontWeight: 500, color: 'var(--ink)', margin: '0 0 0.2rem' }}>{slot.topic}</p>
{slot.note && <p style={{ fontSize: '0.78rem', color: 'var(--ink-muted)', margin: 0 }}>{slot.note}</p>}
{slot.hook && (
<p style={{ fontSize: '0.88rem', fontWeight: 600, color: 'var(--ink)', margin: '0.4rem 0 0.15rem', fontFamily: "'Fraunces', serif" }}>
{slot.hook}
</p>
)}
{slot.idea_brief && (
<p style={{ fontSize: '0.8rem', color: 'var(--ink-light)', margin: '0 0 0.3rem', lineHeight: 1.5 }}>
{slot.idea_brief}
</p>
)}
{!slot.hook && slot.note && <p style={{ fontSize: '0.78rem', color: 'var(--ink-muted)', margin: 0 }}>{slot.note}</p>}
{slot.hook && (
<Link
to={`/content?topic=${encodeURIComponent(slot.hook)}${form.character_id ? `&character=${form.character_id}` : ''}`}
style={{ fontSize: '0.72rem', color: 'var(--accent)', fontWeight: 600, textDecoration: 'none', marginTop: '0.4rem', display: 'inline-block' }}
>
Genera contenuto →
</Link>
)}
</div>
</div>
)

View File

@@ -0,0 +1,140 @@
import { Link } from 'react-router-dom'
export default function LandingPage() {
return (
<div style={{ minHeight: '100dvh', backgroundColor: 'var(--cream)', display: 'flex', flexDirection: 'column' }}>
{/* Nav */}
<nav style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '1.25rem 2rem', maxWidth: 1100, width: '100%', margin: '0 auto',
}}>
<div>
<h1 style={{ fontFamily: "'Fraunces', serif", fontWeight: 700, fontSize: '1.5rem', color: 'var(--ink)', margin: 0, letterSpacing: '-0.02em' }}>
Leopost
</h1>
<div style={{ width: 30, height: 3, backgroundColor: 'var(--accent)', marginTop: '0.3rem' }} />
</div>
<Link to="/login" style={{
padding: '0.6rem 1.5rem', backgroundColor: 'var(--ink)', color: 'white',
textDecoration: 'none', fontFamily: "'DM Sans', sans-serif", fontWeight: 600, fontSize: '0.875rem',
}}>
Accedi
</Link>
</nav>
{/* Hero */}
<section style={{
flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center',
justifyContent: 'center', textAlign: 'center', padding: '3rem 1.5rem',
maxWidth: 760, margin: '0 auto',
}}>
<span style={{
display: 'inline-block', fontSize: '0.72rem', fontWeight: 600,
letterSpacing: '0.12em', textTransform: 'uppercase', color: 'var(--accent)',
marginBottom: '1rem',
}}>
Content Studio potenziato dall'AI
</span>
<h2 style={{
fontFamily: "'Fraunces', serif", fontSize: 'clamp(2rem, 5vw, 3.2rem)',
fontWeight: 700, color: 'var(--ink)', lineHeight: 1.15,
letterSpacing: '-0.03em', margin: '0 0 1.25rem',
}}>
Crea contenuti social<br />
<span style={{ color: 'var(--accent)' }}>senza pensarci.</span>
</h2>
<p style={{
fontSize: 'clamp(0.95rem, 2vw, 1.1rem)', color: 'var(--ink-light)',
lineHeight: 1.7, maxWidth: 540, margin: '0 0 2.5rem',
}}>
Definisci la tua voce una sola volta. L'AI genera, adatta e schedula i tuoi post
su Instagram, Facebook e oltre. Tu approvi con un click.
</p>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', justifyContent: 'center' }}>
<Link to="/login" style={{
padding: '0.85rem 2rem', backgroundColor: 'var(--accent)', color: 'white',
textDecoration: 'none', fontFamily: "'DM Sans', sans-serif",
fontWeight: 700, fontSize: '1rem', transition: 'background-color 0.2s',
}}>
Inizia gratis
</Link>
</div>
</section>
{/* Features */}
<section style={{
padding: '4rem 1.5rem', maxWidth: 1100, margin: '0 auto', width: '100%',
}}>
<div style={{
display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 280px), 1fr))',
gap: '1.5rem',
}}>
{[
{ title: 'Personaggi AI', desc: 'Definisci voce, tono, target e regole. L\'AI li ricorda per sempre — non ripeterti mai più.', icon: '◎' },
{ title: 'Multi-piattaforma', desc: 'Un click, contenuti adattati per Instagram, Facebook, YouTube e TikTok. Ognuno con il formato giusto.', icon: '◈' },
{ title: 'Suggerimenti smart', desc: 'La dashboard propone 3 idee ogni giorno basate sul tuo profilo. Click e genera.', icon: '✦' },
{ title: 'Impara da te', desc: 'Ogni post approvato insegna all\'AI il tuo stile. Più usi Leopost, più diventa bravo.', icon: '◇' },
{ title: 'Calendario editoriale', desc: 'L\'AI genera un piano settimanale con tecniche narrative (PAS, AIDA, Storytelling) e awareness levels.', icon: '▣' },
{ title: 'Hashtag intelligenti', desc: 'Set fissi per piattaforma + hashtag variabili generati dall\'AI per massimizzare la reach.', icon: '#' },
].map((f, i) => (
<div key={i} style={{
padding: '1.75rem', backgroundColor: 'var(--surface)',
border: '1px solid var(--border)', borderTop: '3px solid var(--accent)',
}}>
<span style={{ fontSize: '1.5rem', color: 'var(--accent)', display: 'block', marginBottom: '0.75rem' }}>{f.icon}</span>
<h3 style={{
fontFamily: "'Fraunces', serif", fontSize: '1.05rem', fontWeight: 600,
color: 'var(--ink)', margin: '0 0 0.5rem',
}}>
{f.title}
</h3>
<p style={{ fontSize: '0.875rem', color: 'var(--ink-light)', lineHeight: 1.6, margin: 0 }}>
{f.desc}
</p>
</div>
))}
</div>
</section>
{/* CTA */}
<section style={{
padding: '3rem 1.5rem', textAlign: 'center',
borderTop: '1px solid var(--border)',
}}>
<h3 style={{
fontFamily: "'Fraunces', serif", fontSize: '1.5rem', fontWeight: 600,
color: 'var(--ink)', margin: '0 0 0.75rem',
}}>
Pronto a semplificare i tuoi social?
</h3>
<p style={{ fontSize: '0.9rem', color: 'var(--ink-muted)', margin: '0 0 1.5rem' }}>
Piano gratuito con 15 post al mese. Nessuna carta richiesta.
</p>
<Link to="/login" style={{
padding: '0.75rem 2rem', backgroundColor: 'var(--ink)', color: 'white',
textDecoration: 'none', fontFamily: "'DM Sans', sans-serif", fontWeight: 600, fontSize: '0.9rem',
}}>
Crea il tuo account
</Link>
</section>
{/* Footer */}
<footer style={{
padding: '1.5rem 2rem', borderTop: '1px solid var(--border)',
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
flexWrap: 'wrap', gap: '0.5rem', maxWidth: 1100, margin: '0 auto', width: '100%',
}}>
<p style={{ fontSize: '0.72rem', color: 'var(--ink-muted)', margin: 0 }}>
© {new Date().getFullYear()} Leopost
</p>
<div style={{ display: 'flex', gap: '1rem' }}>
{[{ to: '/privacy', label: 'Privacy' }, { to: '/termini', label: 'Termini' }, { to: '/cookie', label: 'Cookie' }].map(l => (
<Link key={l.to} to={l.to} style={{ fontSize: '0.72rem', color: 'var(--ink-muted)', textDecoration: 'none' }}>
{l.label}
</Link>
))}
</div>
</footer>
</div>
)
}

View File

@@ -1,16 +1,24 @@
import { Navigate, Outlet } from 'react-router-dom'
import { useAuth } from '../AuthContext'
export default function ProtectedRoute() {
export default function ProtectedRoute({ fallback = '/login' }) {
const { user, loading } = useAuth()
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-brand-500 border-t-transparent" />
<div style={{
minHeight: '100dvh', display: 'flex', alignItems: 'center', justifyContent: 'center',
backgroundColor: 'var(--cream)',
}}>
<div style={{
width: 32, height: 32, border: '2px solid var(--border)',
borderTopColor: 'var(--accent)', borderRadius: '50%',
animation: 'spin 0.8s linear infinite',
}} />
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
</div>
)
}
return user ? <Outlet /> : <Navigate to="/login" />
return user ? <Outlet /> : <Navigate to={fallback} />
}