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:
@@ -1,20 +1,23 @@
|
|||||||
"""Editorial Calendar router.
|
"""Editorial Calendar router.
|
||||||
|
|
||||||
Espone endpoint per il calendario editoriale con awareness levels e formati narrativi.
|
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 csv
|
||||||
import io
|
import io
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..auth import get_current_user
|
from ..auth import get_current_user
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
|
from ..models import Character, SystemSetting, User
|
||||||
from ..services.calendar_service import CalendarService, FORMATI_NARRATIVI, AWARENESS_LEVELS
|
from ..services.calendar_service import CalendarService, FORMATI_NARRATIVI, AWARENESS_LEVELS
|
||||||
|
from ..services.llm import get_llm_provider
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/api/editorial",
|
prefix="/api/editorial",
|
||||||
@@ -25,6 +28,19 @@ router = APIRouter(
|
|||||||
_calendar_service = CalendarService()
|
_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 ===
|
# === Schemas locali ===
|
||||||
|
|
||||||
class CalendarGenerateRequest(BaseModel):
|
class CalendarGenerateRequest(BaseModel):
|
||||||
@@ -34,6 +50,7 @@ class CalendarGenerateRequest(BaseModel):
|
|||||||
num_posts: int = 7
|
num_posts: int = 7
|
||||||
start_date: Optional[str] = None
|
start_date: Optional[str] = None
|
||||||
character_id: Optional[int] = None
|
character_id: Optional[int] = None
|
||||||
|
brief: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class ExportCsvRequest(BaseModel):
|
class ExportCsvRequest(BaseModel):
|
||||||
@@ -55,11 +72,16 @@ def get_formats():
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/generate-calendar")
|
@router.post("/generate-calendar")
|
||||||
def generate_calendar(request: CalendarGenerateRequest, db: Session = Depends(get_db)):
|
def generate_calendar(
|
||||||
"""Genera un calendario editoriale con awareness levels."""
|
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:
|
if not request.topics:
|
||||||
return {"slots": [], "totale_post": 0}
|
return {"slots": [], "totale_post": 0}
|
||||||
|
|
||||||
|
# Generate structural slots
|
||||||
slots = _calendar_service.generate_calendar(
|
slots = _calendar_service.generate_calendar(
|
||||||
topics=request.topics,
|
topics=request.topics,
|
||||||
num_posts=request.num_posts,
|
num_posts=request.num_posts,
|
||||||
@@ -68,6 +90,82 @@ def generate_calendar(request: CalendarGenerateRequest, db: Session = Depends(ge
|
|||||||
start_date=request.start_date,
|
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 {
|
return {
|
||||||
"slots": slots,
|
"slots": slots,
|
||||||
"totale_post": len(slots),
|
"totale_post": len(slots),
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ 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 LandingPage from './components/LandingPage'
|
||||||
import CookieBanner from './components/CookieBanner'
|
import CookieBanner from './components/CookieBanner'
|
||||||
import PrivacyPolicy from './components/legal/PrivacyPolicy'
|
import PrivacyPolicy from './components/legal/PrivacyPolicy'
|
||||||
import TermsOfService from './components/legal/TermsOfService'
|
import TermsOfService from './components/legal/TermsOfService'
|
||||||
@@ -35,6 +36,7 @@ export default function App() {
|
|||||||
<CookieBanner />
|
<CookieBanner />
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Public routes */}
|
{/* Public routes */}
|
||||||
|
<Route path="/landing" element={<LandingPage />} />
|
||||||
<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="/privacy" element={<PrivacyPolicy />} />
|
||||||
@@ -42,7 +44,7 @@ export default function App() {
|
|||||||
<Route path="/cookie" element={<CookiePolicy />} />
|
<Route path="/cookie" element={<CookiePolicy />} />
|
||||||
|
|
||||||
{/* Protected routes */}
|
{/* Protected routes */}
|
||||||
<Route element={<ProtectedRoute />}>
|
<Route element={<ProtectedRoute fallback="/landing" />}>
|
||||||
<Route element={<Layout />}>
|
<Route element={<Layout />}>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
<Route path="/characters" element={<CharacterList />} />
|
<Route path="/characters" element={<CharacterList />} />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import { Link, useSearchParams } from 'react-router-dom'
|
import { Link, useSearchParams } from 'react-router-dom'
|
||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
import { useAuth } from '../AuthContext'
|
import { useAuth } from '../AuthContext'
|
||||||
@@ -50,6 +51,9 @@ export default function ContentPage() {
|
|||||||
const [editing, setEditing] = useState(false)
|
const [editing, setEditing] = useState(false)
|
||||||
const [editText, setEditText] = useState('')
|
const [editText, setEditText] = useState('')
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||||
|
const [showScheduleModal, setShowScheduleModal] = useState(false)
|
||||||
|
const [scheduleDate, setScheduleDate] = useState('')
|
||||||
|
const [scheduleTime, setScheduleTime] = useState('09:00')
|
||||||
|
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
character_id: '',
|
character_id: '',
|
||||||
@@ -172,6 +176,21 @@ export default function ContentPage() {
|
|||||||
} catch (err) { setError(err.message || 'Errore eliminazione') }
|
} 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 (
|
return (
|
||||||
<div style={{ animation: 'fade-up 0.5s ease-out both' }}>
|
<div style={{ animation: 'fade-up 0.5s ease-out both' }}>
|
||||||
{/* Header */}
|
{/* 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' }}>
|
<div style={{ display: 'flex', gap: '0.5rem', paddingTop: '1rem', borderTop: '1px solid var(--border)', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
{generated.status !== 'approved' && (
|
{generated.status !== 'approved' && generated.status !== 'scheduled' && (
|
||||||
<button onClick={handleApprove} style={{ ...btnPrimary, backgroundColor: 'var(--success)' }}>Approva</button>
|
<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>}
|
{!editing && <button onClick={() => setEditing(true)} style={btnSecondary}>Modifica</button>}
|
||||||
<button onClick={() => setShowDeleteConfirm(true)} style={{ ...btnSecondary, marginLeft: 'auto', color: 'var(--error)' }}>Elimina</button>
|
<button onClick={() => setShowDeleteConfirm(true)} style={{ ...btnSecondary, marginLeft: 'auto', color: 'var(--error)' }}>Elimina</button>
|
||||||
</div>
|
</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>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '4rem 1rem', textAlign: 'center' }}>
|
<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}
|
onConfirm={handleDelete}
|
||||||
onCancel={() => setShowDeleteConfirm(false)}
|
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>
|
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
|
||||||
</div>
|
</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 }) {
|
function HashtagEditor({ hashtags, onChange, postId }) {
|
||||||
const [newTag, setNewTag] = useState('')
|
const [newTag, setNewTag] = useState('')
|
||||||
const [editIdx, setEditIdx] = useState(null)
|
const [editIdx, setEditIdx] = useState(null)
|
||||||
|
|||||||
@@ -440,7 +440,25 @@ function CalendarioTab({ strategy }) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p style={{ fontSize: '0.875rem', fontWeight: 500, color: 'var(--ink)', margin: '0 0 0.2rem' }}>{slot.topic}</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
140
frontend/src/components/LandingPage.jsx
Normal file
140
frontend/src/components/LandingPage.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,16 +1,24 @@
|
|||||||
import { Navigate, Outlet } from 'react-router-dom'
|
import { Navigate, Outlet } from 'react-router-dom'
|
||||||
import { useAuth } from '../AuthContext'
|
import { useAuth } from '../AuthContext'
|
||||||
|
|
||||||
export default function ProtectedRoute() {
|
export default function ProtectedRoute({ fallback = '/login' }) {
|
||||||
const { user, loading } = useAuth()
|
const { user, loading } = useAuth()
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-slate-50">
|
<div style={{
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-brand-500 border-t-transparent" />
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return user ? <Outlet /> : <Navigate to="/login" />
|
return user ? <Outlet /> : <Navigate to={fallback} />
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user