feat: multi-user SaaS, piani Freemium/Pro, Google OAuth, admin panel

BLOCCO 1 - Multi-user data model:
- User: email, display_name, avatar_url, auth_provider, google_id
- User: subscription_plan, subscription_expires_at, is_admin, post counters
- SubscriptionCode table per redeem codes
- user_id FK su Character, Post, AffiliateLink, EditorialPlan, SocialAccount, SystemSetting
- Migrazione SQLite-safe (ALTER TABLE) + preserva dati esistenti

BLOCCO 2 - Auth completo:
- Registrazione email/password + login multi-user
- Google OAuth 2.0 (httpx, no deps esterne)
- Callback flow: Google -> /auth/callback?token=JWT -> frontend
- Backward compat login admin con username

BLOCCO 3 - Piani e abbonamenti:
- Freemium: 1 character, 15 post/mese, FB+IG only, no auto-plans
- Pro: illimitato, tutte le piattaforme, tutte le feature
- Enforcement automatico in tutti i router
- Redeem codes con durate 1/3/6/12 mesi
- Admin panel: genera codici, lista utenti

BLOCCO 4 - Frontend completo:
- Login page design Leopost (split coral/cream, Google, social coming soon)
- AuthCallback per OAuth redirect
- PlanBanner, UpgradeModal con pricing
- AdminSettings per generazione codici
- CharacterForm con tab Account Social + guide setup

Deploy:
- Dockerfile con ARG VITE_BASE_PATH/VITE_API_BASE
- docker-compose.prod.yml per leopost.it (no subpath)
- docker-compose.yml aggiornato per lab

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Michele
2026-03-31 20:01:07 +02:00
parent 2c16407f96
commit 77ca70cd48
31 changed files with 2818 additions and 449 deletions

View File

@@ -3,6 +3,7 @@ import { AuthProvider } from './AuthContext'
import ProtectedRoute from './components/ProtectedRoute'
import Layout from './components/Layout'
import LoginPage from './components/LoginPage'
import AuthCallback from './components/AuthCallback'
import Dashboard from './components/Dashboard'
import CharacterList from './components/CharacterList'
import CharacterForm from './components/CharacterForm'
@@ -17,13 +18,19 @@ import SocialAccounts from './components/SocialAccounts'
import CommentsQueue from './components/CommentsQueue'
import SettingsPage from './components/SettingsPage'
import EditorialCalendar from './components/EditorialCalendar'
import AdminSettings from './components/AdminSettings'
const BASE_PATH = import.meta.env.VITE_BASE_PATH !== undefined
? (import.meta.env.VITE_BASE_PATH || '/')
: '/leopost-full'
export default function App() {
return (
<BrowserRouter basename="/leopost-full">
<BrowserRouter basename={BASE_PATH}>
<AuthProvider>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route element={<ProtectedRoute />}>
<Route element={<Layout />}>
<Route path="/" element={<Dashboard />} />
@@ -43,6 +50,7 @@ export default function App() {
<Route path="/comments" element={<CommentsQueue />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/editorial" element={<EditorialCalendar />} />
<Route path="/admin" element={<AdminSettings />} />
</Route>
</Route>
<Route path="*" element={<Navigate to="/" />} />

View File

@@ -3,27 +3,81 @@ import { api } from './api'
const AuthContext = createContext(null)
const PLAN_LIMITS = {
freemium: {
characters: 1,
posts: 15,
platforms: ['facebook', 'instagram'],
auto_plans: false,
comments_management: false,
affiliate_links: false,
},
pro: {
characters: null,
posts: null,
platforms: ['facebook', 'instagram', 'youtube', 'tiktok'],
auto_plans: true,
comments_management: true,
affiliate_links: true,
},
}
function computeIsPro(user) {
if (!user) return false
if (user.subscription_plan !== 'pro') return false
if (user.subscription_expires_at) {
return new Date(user.subscription_expires_at) > new Date()
}
return true
}
export function AuthProvider({ children }) {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
const loadUser = async () => {
try {
const data = await api.get('/auth/me')
setUser(data)
return data
} catch {
localStorage.removeItem('token')
setUser(null)
return null
}
}
useEffect(() => {
const token = localStorage.getItem('token')
if (token) {
api.get('/auth/me')
.then((data) => setUser(data))
.catch(() => localStorage.removeItem('token'))
.finally(() => setLoading(false))
loadUser().finally(() => setLoading(false))
} else {
setLoading(false)
}
}, [])
const login = async (username, password) => {
const data = await api.post('/auth/login', { username, password })
const login = async (emailOrUsername, password) => {
// Try email login first, fall back to username
const isEmail = emailOrUsername.includes('@')
const body = isEmail
? { email: emailOrUsername, password }
: { username: emailOrUsername, password }
const data = await api.post('/auth/login', body)
localStorage.setItem('token', data.access_token)
const me = await api.get('/auth/me')
setUser(me)
setUser(data.user)
return data.user
}
const register = async (email, password, displayName) => {
const data = await api.post('/auth/register', { email, password, display_name: displayName })
localStorage.setItem('token', data.access_token)
setUser(data.user)
return data.user
}
const loginWithToken = (token) => {
localStorage.setItem('token', token)
loadUser()
}
const logout = () => {
@@ -31,8 +85,23 @@ export function AuthProvider({ children }) {
setUser(null)
}
const isPro = computeIsPro(user)
const isAdmin = Boolean(user?.is_admin)
const planLimits = PLAN_LIMITS[isPro ? 'pro' : 'freemium']
return (
<AuthContext.Provider value={{ user, loading, login, logout }}>
<AuthContext.Provider value={{
user,
loading,
login,
register,
logout,
loginWithToken,
loadUser,
isPro,
isAdmin,
planLimits,
}}>
{children}
</AuthContext.Provider>
)

View File

@@ -1,4 +1,4 @@
const BASE_URL = '/leopost-full/api'
const BASE_URL = import.meta.env.VITE_API_BASE || '/api'
async function request(method, path, body = null) {
const headers = { 'Content-Type': 'application/json' }
@@ -13,13 +13,21 @@ async function request(method, path, body = null) {
if (res.status === 401) {
localStorage.removeItem('token')
window.location.href = '/leopost-full/login'
const basePath = import.meta.env.VITE_BASE_PATH || '/leopost-full'
window.location.href = basePath ? `${basePath}/login` : '/login'
return
}
if (!res.ok) {
const error = await res.json().catch(() => ({ detail: 'Request failed' }))
throw new Error(error.detail || 'Request failed')
// Pass through structured errors (upgrade_required etc.)
const detail = error.detail
if (typeof detail === 'object' && detail !== null) {
const err = new Error(detail.message || 'Request failed')
err.data = detail
throw err
}
throw new Error(detail || 'Request failed')
}
if (res.status === 204) return null
@@ -32,3 +40,5 @@ export const api = {
put: (path, body) => request('PUT', path, body),
delete: (path) => request('DELETE', path),
}
export { BASE_URL }

View File

@@ -0,0 +1,344 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '../api'
import { useAuth } from '../AuthContext'
const INK = '#1A1A2E'
const MUTED = '#888'
const BORDER = '#E8E4DC'
const CORAL = '#FF6B4A'
const DURATIONS = [
{ value: 1, label: '1 mese', price: '€14.95' },
{ value: 3, label: '3 mesi', price: '€39.95' },
{ value: 6, label: '6 mesi', price: '€64.95' },
{ value: 12, label: '12 mesi (1 anno)', price: '€119.95' },
]
export default function AdminSettings() {
const { isAdmin, user } = useAuth()
const navigate = useNavigate()
const [users, setUsers] = useState([])
const [codes, setCodes] = useState([])
const [loadingUsers, setLoadingUsers] = useState(true)
const [loadingCodes, setLoadingCodes] = useState(true)
const [duration, setDuration] = useState(1)
const [generating, setGenerating] = useState(false)
const [generatedCode, setGeneratedCode] = useState(null)
const [copied, setCopied] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
if (!isAdmin) {
navigate('/')
return
}
loadUsers()
loadCodes()
}, [isAdmin])
const loadUsers = async () => {
try {
const data = await api.get('/admin/users')
setUsers(data)
} catch {
// silently fail
} finally {
setLoadingUsers(false)
}
}
const loadCodes = async () => {
try {
const data = await api.get('/admin/codes')
setCodes(data)
} catch {
// silently fail
} finally {
setLoadingCodes(false)
}
}
const handleGenerateCode = async () => {
setGenerating(true)
setError('')
setGeneratedCode(null)
try {
const result = await api.post('/admin/codes/generate', { duration_months: duration })
setGeneratedCode(result)
loadCodes()
} catch (err) {
setError(err.message || 'Errore nella generazione del codice.')
} finally {
setGenerating(false)
}
}
const copyCode = async (code) => {
try {
await navigator.clipboard.writeText(code)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
// fallback
}
}
if (!isAdmin) return null
return (
<div>
<div style={{ marginBottom: '1.5rem' }}>
<h2 style={{ color: INK, fontSize: '1.5rem', fontWeight: 700, margin: 0 }}>
Admin Settings
</h2>
<p style={{ color: MUTED, fontSize: '0.875rem', margin: '0.25rem 0 0' }}>
Gestione utenti e codici abbonamento
</p>
</div>
{/* Generate Code Section */}
<div style={{
backgroundColor: 'white',
border: `1px solid ${BORDER}`,
borderRadius: '14px',
padding: '1.5rem',
marginBottom: '1.5rem',
}}>
<h3 style={{ margin: '0 0 1rem', fontSize: '1rem', color: INK, fontWeight: 600 }}>
Genera Codice Pro
</h3>
<div style={{ display: 'flex', alignItems: 'flex-end', gap: '0.75rem', flexWrap: 'wrap' }}>
<div>
<label style={{ display: 'block', fontSize: '0.8rem', fontWeight: 600, color: MUTED, marginBottom: '0.4rem' }}>
Durata abbonamento
</label>
<select
value={duration}
onChange={(e) => setDuration(Number(e.target.value))}
style={{
padding: '0.6rem 1rem',
border: `1px solid ${BORDER}`,
borderRadius: '8px',
fontSize: '0.875rem',
color: INK,
backgroundColor: 'white',
cursor: 'pointer',
outline: 'none',
}}
>
{DURATIONS.map((d) => (
<option key={d.value} value={d.value}>
{d.label} {d.price}
</option>
))}
</select>
</div>
<button
onClick={handleGenerateCode}
disabled={generating}
style={{
padding: '0.6rem 1.25rem',
backgroundColor: CORAL,
color: 'white',
border: 'none',
borderRadius: '8px',
fontWeight: 600,
fontSize: '0.875rem',
cursor: generating ? 'not-allowed' : 'pointer',
opacity: generating ? 0.7 : 1,
}}
>
{generating ? 'Generazione...' : 'Genera Codice'}
</button>
</div>
{error && (
<p style={{ marginTop: '0.75rem', color: '#DC2626', fontSize: '0.875rem' }}>{error}</p>
)}
{generatedCode && (
<div style={{
marginTop: '1rem',
padding: '1rem',
backgroundColor: '#F0FDF4',
border: '1px solid #86EFAC',
borderRadius: '10px',
}}>
<p style={{ margin: '0 0 0.5rem', fontSize: '0.8rem', color: '#065F46', fontWeight: 600 }}>
Codice generato ({DURATIONS.find(d => d.value === generatedCode.duration_months)?.label})
</p>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<code style={{
flex: 1,
padding: '0.5rem 0.75rem',
backgroundColor: 'white',
border: '1px solid #A7F3D0',
borderRadius: '6px',
fontFamily: 'monospace',
fontSize: '1rem',
letterSpacing: '0.1em',
color: '#065F46',
}}>
{generatedCode.code}
</code>
<button
onClick={() => copyCode(generatedCode.code)}
style={{
padding: '0.5rem 0.75rem',
backgroundColor: copied ? '#16A34A' : INK,
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '0.8rem',
fontWeight: 600,
transition: 'background-color 0.2s',
}}
>
{copied ? 'Copiato ✓' : 'Copia'}
</button>
</div>
</div>
)}
</div>
{/* Codes Table */}
<div style={{
backgroundColor: 'white',
border: `1px solid ${BORDER}`,
borderRadius: '14px',
padding: '1.5rem',
marginBottom: '1.5rem',
}}>
<h3 style={{ margin: '0 0 1rem', fontSize: '1rem', color: INK, fontWeight: 600 }}>
Codici Generati
</h3>
{loadingCodes ? (
<p style={{ color: MUTED, fontSize: '0.875rem' }}>Caricamento...</p>
) : codes.length === 0 ? (
<p style={{ color: MUTED, fontSize: '0.875rem' }}>Nessun codice generato ancora.</p>
) : (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.85rem' }}>
<thead>
<tr style={{ borderBottom: `2px solid ${BORDER}` }}>
{['Codice', 'Durata', 'Stato', 'Usato da', 'Data uso'].map((h) => (
<th key={h} style={{ textAlign: 'left', padding: '0.5rem 0.75rem', color: MUTED, fontWeight: 600, fontSize: '0.8rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{codes.map((code) => (
<tr key={code.id} style={{ borderBottom: `1px solid ${BORDER}` }}>
<td style={{ padding: '0.6rem 0.75rem', fontFamily: 'monospace', color: INK, fontWeight: 600 }}>
{code.code}
</td>
<td style={{ padding: '0.6rem 0.75rem', color: INK }}>
{DURATIONS.find(d => d.value === code.duration_months)?.label || `${code.duration_months}m`}
</td>
<td style={{ padding: '0.6rem 0.75rem' }}>
<span style={{
display: 'inline-block',
padding: '0.15rem 0.5rem',
borderRadius: '10px',
fontSize: '0.75rem',
fontWeight: 600,
backgroundColor: code.status === 'used' ? '#FEE2E2' : '#ECFDF5',
color: code.status === 'used' ? '#DC2626' : '#16A34A',
}}>
{code.status === 'used' ? 'Usato' : 'Attivo'}
</span>
</td>
<td style={{ padding: '0.6rem 0.75rem', color: MUTED }}>
{code.used_by || '—'}
</td>
<td style={{ padding: '0.6rem 0.75rem', color: MUTED }}>
{code.used_at ? new Date(code.used_at).toLocaleDateString('it-IT') : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Users Table */}
<div style={{
backgroundColor: 'white',
border: `1px solid ${BORDER}`,
borderRadius: '14px',
padding: '1.5rem',
}}>
<h3 style={{ margin: '0 0 1rem', fontSize: '1rem', color: INK, fontWeight: 600 }}>
Utenti ({users.length})
</h3>
{loadingUsers ? (
<p style={{ color: MUTED, fontSize: '0.875rem' }}>Caricamento...</p>
) : users.length === 0 ? (
<p style={{ color: MUTED, fontSize: '0.875rem' }}>Nessun utente.</p>
) : (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.85rem' }}>
<thead>
<tr style={{ borderBottom: `2px solid ${BORDER}` }}>
{['Email / Username', 'Piano', 'Scadenza', 'Provider', 'Registrazione'].map((h) => (
<th key={h} style={{ textAlign: 'left', padding: '0.5rem 0.75rem', color: MUTED, fontWeight: 600, fontSize: '0.8rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id} style={{ borderBottom: `1px solid ${BORDER}`, backgroundColor: u.is_admin ? '#FFFBEB' : 'white' }}>
<td style={{ padding: '0.6rem 0.75rem' }}>
<div style={{ color: INK, fontWeight: 500 }}>{u.email || u.username}</div>
{u.display_name && u.display_name !== u.email && (
<div style={{ color: MUTED, fontSize: '0.78rem' }}>{u.display_name}</div>
)}
{u.is_admin && (
<span style={{ fontSize: '0.7rem', backgroundColor: '#FEF3C7', color: '#D97706', padding: '0.1rem 0.4rem', borderRadius: '4px', fontWeight: 600 }}>
admin
</span>
)}
</td>
<td style={{ padding: '0.6rem 0.75rem' }}>
<span style={{
display: 'inline-block',
padding: '0.15rem 0.5rem',
borderRadius: '10px',
fontSize: '0.75rem',
fontWeight: 600,
backgroundColor: u.subscription_plan === 'pro' ? '#FFF5F3' : '#F5F5F5',
color: u.subscription_plan === 'pro' ? CORAL : MUTED,
}}>
{u.subscription_plan || 'freemium'}
</span>
</td>
<td style={{ padding: '0.6rem 0.75rem', color: MUTED, fontSize: '0.8rem' }}>
{u.subscription_expires_at
? new Date(u.subscription_expires_at).toLocaleDateString('it-IT')
: u.subscription_plan === 'pro' ? '∞' : '—'}
</td>
<td style={{ padding: '0.6rem 0.75rem', color: MUTED }}>
{u.auth_provider || 'local'}
</td>
<td style={{ padding: '0.6rem 0.75rem', color: MUTED, fontSize: '0.8rem' }}>
{u.created_at ? new Date(u.created_at).toLocaleDateString('it-IT') : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,37 @@
import { useEffect } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useAuth } from '../AuthContext'
export default function AuthCallback() {
const [params] = useSearchParams()
const navigate = useNavigate()
const { loginWithToken } = useAuth()
useEffect(() => {
const token = params.get('token')
if (token) {
loginWithToken(token)
navigate('/', { replace: true })
} else {
navigate('/login', { replace: true })
}
}, [])
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<div style={{ textAlign: 'center' }}>
<div style={{
width: 40,
height: 40,
border: '3px solid #FF6B4A',
borderTopColor: 'transparent',
borderRadius: '50%',
animation: 'spin 0.8s linear infinite',
margin: '0 auto 1rem',
}} />
<p style={{ color: '#666' }}>Accesso in corso...</p>
</div>
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</div>
)
}

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { api } from '../api'
import { useAuth } from '../AuthContext'
const EMPTY_FORM = {
name: '',
@@ -11,17 +12,87 @@ const EMPTY_FORM = {
is_active: true,
}
const PLATFORMS = [
{
id: 'facebook',
name: 'Facebook',
icon: '📘',
color: '#1877F2',
guide: [
'Vai su developers.facebook.com e accedi con il tuo account.',
'Crea una nuova App → scegli "Business".',
'Aggiungi il prodotto "Facebook Login" e "Pages API".',
'In "Graph API Explorer", seleziona la tua app e la tua Pagina.',
'Genera un Page Access Token con permessi: pages_manage_posts, pages_read_engagement.',
'Copia il Page ID dalla pagina Facebook (Info → ID pagina).',
],
proOnly: false,
},
{
id: 'instagram',
name: 'Instagram',
icon: '📸',
color: '#E1306C',
guide: [
'Instagram usa le API di Facebook (Meta).',
'Nella stessa app Meta, aggiungi il prodotto "Instagram Graph API".',
'Collega un profilo Instagram Business alla tua pagina Facebook.',
'In Graph API Explorer, genera un token con scope: instagram_basic, instagram_content_publish.',
'Trova l\'Instagram User ID tramite: GET /{page-id}?fields=instagram_business_account.',
'Inserisci il token e l\'IG User ID nei campi sottostanti.',
],
proOnly: false,
},
{
id: 'youtube',
name: 'YouTube',
icon: '▶️',
color: '#FF0000',
guide: [
'Vai su console.cloud.google.com e crea un progetto.',
'Abilita "YouTube Data API v3" nella sezione API & Services.',
'Crea credenziali OAuth 2.0 (tipo: Web application).',
'Autorizza l\'accesso al tuo canale YouTube seguendo il flusso OAuth.',
'Copia l\'Access Token e il Channel ID (visibile in YouTube Studio → Personalizzazione → Informazioni).',
],
proOnly: true,
},
{
id: 'tiktok',
name: 'TikTok',
icon: '🎵',
color: '#000000',
guide: [
'Vai su developers.tiktok.com e registra un account sviluppatore.',
'Crea una nuova app → seleziona "Content Posting API".',
'Richiedi i permessi: video.publish, video.upload.',
'Completa il processo di verifica app (può richiedere alcuni giorni).',
'Una volta approvata, genera un access token seguendo la documentazione OAuth 2.0.',
],
proOnly: true,
},
]
export default function CharacterForm() {
const { id } = useParams()
const isEdit = Boolean(id)
const navigate = useNavigate()
const { isPro } = useAuth()
const [activeTab, setActiveTab] = useState('profile')
const [form, setForm] = useState(EMPTY_FORM)
const [topicInput, setTopicInput] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(isEdit)
// Social accounts state
const [socialAccounts, setSocialAccounts] = useState({})
const [expandedGuide, setExpandedGuide] = useState(null)
const [savingToken, setSavingToken] = useState({})
const [tokenInputs, setTokenInputs] = useState({})
const [pageIdInputs, setPageIdInputs] = useState({})
useEffect(() => {
if (isEdit) {
api.get(`/characters/${id}`)
@@ -41,6 +112,15 @@ export default function CharacterForm() {
})
.catch(() => setError('Personaggio non trovato'))
.finally(() => setLoading(false))
// Load social accounts for this character
api.get(`/social/accounts?character_id=${id}`)
.then((accounts) => {
const map = {}
accounts.forEach((acc) => { map[acc.platform] = acc })
setSocialAccounts(map)
})
.catch(() => {})
}
}, [id, isEdit])
@@ -89,12 +169,69 @@ export default function CharacterForm() {
}
navigate('/characters')
} catch (err) {
setError(err.message || 'Errore nel salvataggio')
if (err.data?.upgrade_required) {
setError(err.data.message || 'Limite piano raggiunto. Passa a Pro.')
} else {
setError(err.message || 'Errore nel salvataggio')
}
} finally {
setSaving(false)
}
}
const handleSaveToken = async (platform) => {
if (!isEdit) return
const token = tokenInputs[platform] || ''
const pageId = pageIdInputs[platform] || ''
if (!token.trim()) return
setSavingToken((prev) => ({ ...prev, [platform]: true }))
try {
const existing = socialAccounts[platform]
if (existing) {
await api.put(`/social/accounts/${existing.id}`, {
access_token: token,
page_id: pageId || undefined,
})
} else {
await api.post('/social/accounts', {
character_id: Number(id),
platform,
access_token: token,
page_id: pageId || undefined,
account_name: platform,
})
}
// Reload
const accounts = await api.get(`/social/accounts?character_id=${id}`)
const map = {}
accounts.forEach((acc) => { map[acc.platform] = acc })
setSocialAccounts(map)
setTokenInputs((prev) => ({ ...prev, [platform]: '' }))
setPageIdInputs((prev) => ({ ...prev, [platform]: '' }))
} catch (err) {
alert(err.message || 'Errore nel salvataggio del token.')
} finally {
setSavingToken((prev) => ({ ...prev, [platform]: false }))
}
}
const handleDisconnect = async (platform) => {
const acc = socialAccounts[platform]
if (!acc) return
if (!window.confirm(`Disconnetti ${platform}?`)) return
try {
await api.delete(`/social/accounts/${acc.id}`)
setSocialAccounts((prev) => {
const next = { ...prev }
delete next[platform]
return next
})
} catch (err) {
alert(err.message || 'Errore nella disconnessione.')
}
}
if (loading) {
return (
<div className="flex justify-center py-12">
@@ -114,218 +251,382 @@ export default function CharacterForm() {
</p>
</div>
<form onSubmit={handleSubmit} className="max-w-2xl space-y-6">
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">
{error}
</div>
)}
{/* 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>
{/* Basic info */}
<div className="bg-white rounded-xl border border-slate-200 p-6 space-y-4">
<h3 className="font-semibold text-slate-700 text-sm uppercase tracking-wider">
Informazioni base
</h3>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Nome personaggio
</label>
<input
type="text"
value={form.name}
onChange={(e) => handleChange('name', e.target.value)}
placeholder="Es. TechGuru, FoodBlogger..."
className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Niche / Settore
</label>
<input
type="text"
value={form.niche}
onChange={(e) => handleChange('niche', e.target.value)}
placeholder="Es. Tecnologia, Food, Fitness..."
className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Tono di comunicazione
</label>
<textarea
value={form.tone}
onChange={(e) => handleChange('tone', e.target.value)}
placeholder="Descrivi lo stile di comunicazione: informale, professionale, ironico, motivazionale..."
rows={3}
className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm resize-none"
/>
</div>
<div className="flex items-center gap-3">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={form.is_active}
onChange={(e) => handleChange('is_active', e.target.checked)}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-slate-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-brand-500"></div>
</label>
<span className="text-sm text-slate-700">Attivo</span>
</div>
</div>
{/* Topics */}
<div className="bg-white rounded-xl border border-slate-200 p-6 space-y-4">
<h3 className="font-semibold text-slate-700 text-sm uppercase tracking-wider">
Topic ricorrenti
</h3>
<div className="flex gap-2">
<input
type="text"
value={topicInput}
onChange={(e) => setTopicInput(e.target.value)}
onKeyDown={handleTopicKeyDown}
placeholder="Scrivi un topic e premi Invio"
className="flex-1 px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm"
/>
<button
type="button"
onClick={addTopic}
className="px-4 py-2.5 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-lg text-sm font-medium transition-colors"
>
Aggiungi
</button>
</div>
{form.topics.length > 0 && (
<div className="flex flex-wrap gap-2">
{form.topics.map((topic) => (
<span
key={topic}
className="inline-flex items-center gap-1.5 px-3 py-1 bg-brand-50 text-brand-700 rounded-lg text-sm"
>
{topic}
<button
type="button"
onClick={() => removeTopic(topic)}
className="text-brand-400 hover:text-brand-600"
>
×
</button>
</span>
))}
{activeTab === 'profile' && (
<form onSubmit={handleSubmit} className="max-w-2xl space-y-6">
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">
{error}
</div>
)}
</div>
{/* Visual style */}
<div className="bg-white rounded-xl border border-slate-200 p-6 space-y-4">
<h3 className="font-semibold text-slate-700 text-sm uppercase tracking-wider">
Stile visivo
</h3>
{/* Basic info */}
<div className="bg-white rounded-xl border border-slate-200 p-6 space-y-4">
<h3 className="font-semibold text-slate-700 text-sm uppercase tracking-wider">
Informazioni base
</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Colore primario
Nome personaggio
</label>
<div className="flex items-center gap-2">
<input
type="color"
value={form.visual_style.primary_color}
onChange={(e) => handleStyleChange('primary_color', e.target.value)}
className="w-10 h-10 rounded border border-slate-200 cursor-pointer"
/>
<input
type="text"
value={form.visual_style.primary_color}
onChange={(e) => handleStyleChange('primary_color', e.target.value)}
className="flex-1 px-3 py-2 border border-slate-200 rounded-lg text-sm font-mono"
/>
</div>
<input
type="text"
value={form.name}
onChange={(e) => handleChange('name', e.target.value)}
placeholder="Es. TechGuru, FoodBlogger..."
className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Colore secondario
Niche / Settore
</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>
<input
type="text"
value={form.niche}
onChange={(e) => handleChange('niche', e.target.value)}
placeholder="Es. Tecnologia, Food, Fitness..."
className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm"
required
/>
</div>
</div>
<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>
<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>
{/* 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 }}
<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"
>
{form.name?.charAt(0)?.toUpperCase() || '?'}
Aggiungi
</button>
</div>
{form.topics.length > 0 && (
<div className="flex flex-wrap gap-2">
{form.topics.map((topic) => (
<span
key={topic}
className="inline-flex items-center gap-1.5 px-3 py-1 bg-brand-50 text-brand-700 rounded-lg text-sm"
>
{topic}
<button
type="button"
onClick={() => removeTopic(topic)}
className="text-brand-400 hover:text-brand-600"
>
×
</button>
</span>
))}
</div>
)}
</div>
{/* Visual style */}
<div className="bg-white rounded-xl border border-slate-200 p-6 space-y-4">
<h3 className="font-semibold text-slate-700 text-sm uppercase tracking-wider">
Stile visivo
</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="font-semibold text-sm" style={{ color: form.visual_style.secondary_color }}>
{form.name || 'Nome personaggio'}
</p>
<p className="text-xs text-slate-400">{form.niche || 'Niche'}</p>
<label className="block text-sm font-medium text-slate-700 mb-1">
Colore primario
</label>
<div className="flex items-center gap-2">
<input
type="color"
value={form.visual_style.primary_color}
onChange={(e) => handleStyleChange('primary_color', e.target.value)}
className="w-10 h-10 rounded border border-slate-200 cursor-pointer"
/>
<input
type="text"
value={form.visual_style.primary_color}
onChange={(e) => handleStyleChange('primary_color', e.target.value)}
className="flex-1 px-3 py-2 border border-slate-200 rounded-lg text-sm font-mono"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Colore secondario
</label>
<div className="flex items-center gap-2">
<input
type="color"
value={form.visual_style.secondary_color}
onChange={(e) => handleStyleChange('secondary_color', e.target.value)}
className="w-10 h-10 rounded border border-slate-200 cursor-pointer"
/>
<input
type="text"
value={form.visual_style.secondary_color}
onChange={(e) => handleStyleChange('secondary_color', e.target.value)}
className="flex-1 px-3 py-2 border border-slate-200 rounded-lg text-sm font-mono"
/>
</div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Font preferito
</label>
<input
type="text"
value={form.visual_style.font}
onChange={(e) => handleStyleChange('font', e.target.value)}
placeholder="Es. Montserrat, Poppins, Inter..."
className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm"
/>
</div>
{/* Preview */}
<div className="mt-4 p-4 rounded-lg border border-dashed border-slate-300">
<p className="text-xs text-slate-400 mb-2">Anteprima</p>
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold"
style={{ backgroundColor: form.visual_style.primary_color }}
>
{form.name?.charAt(0)?.toUpperCase() || '?'}
</div>
<div>
<p className="font-semibold text-sm" style={{ color: form.visual_style.secondary_color }}>
{form.name || 'Nome personaggio'}
</p>
<p className="text-xs text-slate-400">{form.niche || 'Niche'}</p>
</div>
</div>
</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-3">
<button
type="submit"
disabled={saving}
className="px-6 py-2.5 bg-brand-600 hover:bg-brand-700 disabled:bg-brand-300 text-white font-medium rounded-lg transition-colors text-sm"
>
{saving ? 'Salvataggio...' : isEdit ? 'Salva modifiche' : 'Crea personaggio'}
</button>
<button
type="button"
onClick={() => navigate('/characters')}
className="px-6 py-2.5 bg-white hover:bg-slate-50 text-slate-600 font-medium rounded-lg border border-slate-200 transition-colors text-sm"
>
Annulla
</button>
{/* Actions */}
<div className="flex items-center gap-3">
<button
type="submit"
disabled={saving}
className="px-6 py-2.5 bg-brand-600 hover:bg-brand-700 disabled:bg-brand-300 text-white font-medium rounded-lg transition-colors text-sm"
>
{saving ? 'Salvataggio...' : isEdit ? 'Salva modifiche' : 'Crea personaggio'}
</button>
<button
type="button"
onClick={() => navigate('/characters')}
className="px-6 py-2.5 bg-white hover:bg-slate-50 text-slate-600 font-medium rounded-lg border border-slate-200 transition-colors text-sm"
>
Annulla
</button>
</div>
</form>
)}
{activeTab === 'social' && isEdit && (
<div className="max-w-2xl space-y-4">
{PLATFORMS.map((platform) => {
const account = socialAccounts[platform.id]
const isConnected = Boolean(account?.access_token)
const locked = platform.proOnly && !isPro
const guideOpen = expandedGuide === platform.id
return (
<div
key={platform.id}
className="bg-white rounded-xl border border-slate-200 p-5"
>
{/* Platform header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<span style={{ fontSize: '1.5rem' }}>{platform.icon}</span>
<div>
<div className="flex items-center gap-2">
<span className="font-semibold text-slate-800">{platform.name}</span>
{locked && (
<span className="text-xs px-2 py-0.5 rounded-full font-semibold" style={{ backgroundColor: '#FFF5F3', color: '#FF6B4A' }}>
🔒 Piano Pro
</span>
)}
{!locked && (
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${isConnected ? 'bg-emerald-50 text-emerald-700' : 'bg-slate-100 text-slate-400'}`}>
{isConnected ? '● Connesso' : '○ Non connesso'}
</span>
)}
</div>
{account?.account_name && (
<p className="text-xs text-slate-400">{account.account_name}</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
{locked ? (
<button
disabled
className="px-3 py-1.5 text-xs font-medium rounded-lg opacity-40 cursor-not-allowed"
style={{ backgroundColor: '#F1F5F9', color: '#64748B' }}
>
Disponibile con Pro
</button>
) : isConnected ? (
<button
onClick={() => handleDisconnect(platform.id)}
className="px-3 py-1.5 text-xs font-medium rounded-lg text-red-600 hover:bg-red-50 border border-red-200"
>
Disconnetti
</button>
) : null}
{!locked && (
<button
type="button"
onClick={() => setExpandedGuide(guideOpen ? null : platform.id)}
className="px-3 py-1.5 text-xs font-medium rounded-lg text-slate-500 hover:bg-slate-100 border border-slate-200"
>
{guideOpen ? '▲ Nascondi guida' : '▼ Guida setup'}
</button>
)}
</div>
</div>
{/* Guide accordion */}
{guideOpen && !locked && (
<div className="mb-4 p-4 rounded-lg" style={{ backgroundColor: '#F8FAFC', border: '1px solid #E2E8F0' }}>
<h4 className="text-xs font-bold text-slate-600 uppercase tracking-wide mb-2">
Come connettere {platform.name}
</h4>
<ol className="space-y-1.5">
{platform.guide.map((step, i) => (
<li key={i} className="text-xs text-slate-600 flex gap-2">
<span className="font-bold text-slate-400 flex-shrink-0">{i + 1}.</span>
<span>{step}</span>
</li>
))}
</ol>
<div className="mt-3 p-2.5 rounded" style={{ backgroundColor: '#FFF8E1', border: '1px solid #FFE082' }}>
<p className="text-xs text-amber-700">
<strong>Nota:</strong> L'integrazione OAuth diretta è in arrivo. Per ora, copia manualmente il token nei campi sottostanti.
</p>
</div>
</div>
)}
{/* Manual token input */}
{!locked && (
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">
Access Token{platform.id === 'facebook' || platform.id === 'instagram' ? ' (Page/User Access Token)' : ''}
</label>
<input
type="password"
value={tokenInputs[platform.id] || ''}
onChange={(e) => setTokenInputs((prev) => ({ ...prev, [platform.id]: e.target.value }))}
placeholder={isConnected ? ' (token già salvato)' : 'Incolla il token qui...'}
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-xs font-mono focus:outline-none"
/>
</div>
{(platform.id === 'facebook' || platform.id === 'youtube') && (
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">
{platform.id === 'facebook' ? 'Page ID' : 'Channel ID'}
</label>
<input
type="text"
value={pageIdInputs[platform.id] || ''}
onChange={(e) => setPageIdInputs((prev) => ({ ...prev, [platform.id]: e.target.value }))}
placeholder={isConnected && account?.page_id ? account.page_id : 'Es. 123456789'}
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-xs font-mono focus:outline-none"
/>
</div>
)}
<button
type="button"
onClick={() => handleSaveToken(platform.id)}
disabled={savingToken[platform.id] || !tokenInputs[platform.id]?.trim()}
className="px-4 py-2 text-xs font-medium rounded-lg text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
style={{ backgroundColor: '#FF6B4A' }}
>
{savingToken[platform.id] ? 'Salvataggio...' : 'Salva Token'}
</button>
</div>
)}
</div>
)
})}
</div>
</form>
)}
</div>
)
}

View File

@@ -1,8 +1,11 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../api'
import { useAuth } from '../AuthContext'
import PlanBanner from './PlanBanner'
export default function Dashboard() {
const { user, isAdmin } = useAuth()
const [stats, setStats] = useState({
characters: 0,
active: 0,
@@ -43,13 +46,26 @@ export default function Dashboard() {
return (
<div>
<h2 className="text-2xl font-bold mb-1" style={{ color: 'var(--ink)' }}>
Dashboard
</h2>
<p className="text-sm mb-5" style={{ color: 'var(--muted)' }}>
Panoramica Leopost Full
<div className="flex items-center justify-between mb-1">
<h2 className="text-2xl font-bold" style={{ color: 'var(--ink)' }}>
Dashboard
</h2>
{isAdmin && (
<Link
to="/admin"
className="px-3 py-1 text-xs font-semibold rounded-lg"
style={{ backgroundColor: '#FEF3C7', color: '#D97706', border: '1px solid #FDE68A' }}
>
Admin Settings
</Link>
)}
</div>
<p className="text-sm mb-3" style={{ color: 'var(--muted)' }}>
{user?.display_name ? `Ciao, ${user.display_name}` : ''}Panoramica Leopost Full
</p>
<PlanBanner />
{/* Stats grid */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mt-5">
<StatCard label="Personaggi" value={loading ? '—' : stats.characters} sub={`${stats.active} attivi`} accentColor="var(--coral)" />

View File

@@ -1,13 +1,24 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../AuthContext'
import { BASE_URL } from '../api'
const CORAL = '#FF6B4A'
const CREAM = '#FAF8F3'
const INK = '#1A1A2E'
const MUTED = '#888'
const BORDER = '#E8E4DC'
export default function LoginPage() {
const [username, setUsername] = useState('')
const [mode, setMode] = useState('login') // 'login' | 'register'
const [email, setEmail] = useState('')
const [displayName, setDisplayName] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const { login } = useAuth()
const [showRedeemModal, setShowRedeemModal] = useState(false)
const { login, register } = useAuth()
const navigate = useNavigate()
const handleSubmit = async (e) => {
@@ -15,95 +26,427 @@ export default function LoginPage() {
setError('')
setLoading(true)
try {
await login(username, password)
if (mode === 'login') {
await login(email, password)
} else {
await register(email, password, displayName)
}
navigate('/')
} catch (err) {
setError(err.message || 'Login failed')
setError(err.message || (mode === 'login' ? 'Credenziali non valide' : 'Errore durante la registrazione'))
} finally {
setLoading(false)
}
}
const handleGoogleLogin = () => {
window.location.href = `${BASE_URL}/auth/oauth/google`
}
const comingSoon = (e) => {
e.preventDefault()
// tooltip handled via title attr
}
return (
<div
className="min-h-screen flex items-center justify-center px-4"
style={{ backgroundColor: 'var(--ink)' }}
>
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-white tracking-tight font-serif">
Leopost <span style={{ color: 'var(--coral)' }}>Full</span>
<div style={{ display: 'flex', height: '100vh', fontFamily: 'Inter, sans-serif' }}>
{/* LEFT SIDE */}
<div style={{
width: '40%',
backgroundColor: CORAL,
padding: '3rem',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
color: 'white',
}}>
<div>
<h1 style={{
fontFamily: 'Georgia, serif',
fontSize: '2.8rem',
fontWeight: 700,
margin: 0,
letterSpacing: '-1px',
}}>
Leopost
</h1>
<p className="mt-2 text-sm" style={{ color: 'var(--muted)' }}>
Content Automation Platform
<p style={{ fontSize: '1.05rem', marginTop: '0.5rem', opacity: 0.9, lineHeight: 1.4 }}>
Il tuo studio editoriale AI per i social
</p>
<ul style={{ marginTop: '2.5rem', listStyle: 'none', padding: 0, lineHeight: 2 }}>
<li style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
<span style={{ fontSize: '1.2rem' }}></span> Genera contenuti AI per ogni piattaforma
</li>
<li style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
<span style={{ fontSize: '1.2rem' }}></span> Pubblica su Facebook, Instagram, YouTube, TikTok
</li>
<li style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
<span style={{ fontSize: '1.2rem' }}></span> Schedula in automatico con piani editoriali
</li>
<li style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
<span style={{ fontSize: '1.2rem' }}></span> Gestisci commenti con risposte AI
</li>
<li style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
<span style={{ fontSize: '1.2rem' }}></span> Link affiliati integrati nei post
</li>
</ul>
</div>
<form
onSubmit={handleSubmit}
className="rounded-xl p-8 shadow-xl"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">
{error}
</div>
)}
<div style={{
backgroundColor: 'rgba(255,255,255,0.15)',
borderRadius: '12px',
padding: '1rem 1.5rem',
fontSize: '0.85rem',
backdropFilter: 'blur(4px)',
}}>
<strong>Early Adopter Beta</strong> Unisciti ora e ottieni un accesso esclusivo al piano Pro a prezzo speciale.
</div>
</div>
<div className="space-y-4">
<div>
<label
className="block text-sm font-medium mb-1"
style={{ color: 'var(--ink)' }}
{/* RIGHT SIDE */}
<div style={{
flex: 1,
backgroundColor: CREAM,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '2rem',
}}>
<div style={{ width: '100%', maxWidth: '420px' }}>
{/* Toggle */}
<div style={{
display: 'flex',
backgroundColor: 'white',
borderRadius: '10px',
border: `1px solid ${BORDER}`,
padding: '4px',
marginBottom: '1.5rem',
}}>
{['login', 'register'].map((m) => (
<button
key={m}
onClick={() => { setMode(m); setError('') }}
style={{
flex: 1,
padding: '0.5rem',
border: 'none',
borderRadius: '7px',
cursor: 'pointer',
fontWeight: 600,
fontSize: '0.875rem',
transition: 'all 0.2s',
backgroundColor: mode === m ? CORAL : 'transparent',
color: mode === m ? 'white' : MUTED,
}}
>
Username
{m === 'login' ? 'Accedi' : 'Registrati'}
</button>
))}
</div>
<form onSubmit={handleSubmit} style={{
backgroundColor: 'white',
borderRadius: '16px',
padding: '2rem',
border: `1px solid ${BORDER}`,
boxShadow: '0 4px 24px rgba(0,0,0,0.06)',
}}>
{error && (
<div style={{
marginBottom: '1rem',
padding: '0.75rem 1rem',
backgroundColor: '#FEE2E2',
border: '1px solid #FECACA',
borderRadius: '8px',
color: '#DC2626',
fontSize: '0.875rem',
}}>
{error}
</div>
)}
{mode === 'register' && (
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'block', fontSize: '0.8rem', fontWeight: 600, color: INK, marginBottom: '0.4rem' }}>
Nome visualizzato
</label>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Il tuo nome o nickname"
style={inputStyle}
/>
</div>
)}
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'block', fontSize: '0.8rem', fontWeight: 600, color: INK, marginBottom: '0.4rem' }}>
Email
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full px-4 py-2.5 rounded-lg text-sm focus:outline-none"
style={{
border: '1px solid var(--border)',
color: 'var(--ink)',
backgroundColor: 'var(--cream)',
}}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="tu@esempio.it"
required
style={inputStyle}
/>
</div>
<div>
<label
className="block text-sm font-medium mb-1"
style={{ color: 'var(--ink)' }}
>
<div style={{ marginBottom: '1.5rem' }}>
<label style={{ display: 'block', fontSize: '0.8rem', fontWeight: 600, color: INK, marginBottom: '0.4rem' }}>
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-2.5 rounded-lg text-sm focus:outline-none"
style={{
border: '1px solid var(--border)',
color: 'var(--ink)',
backgroundColor: 'var(--cream)',
}}
placeholder="••••••••"
required
style={inputStyle}
/>
</div>
<button
type="submit"
disabled={loading}
style={{
width: '100%',
padding: '0.75rem',
backgroundColor: CORAL,
color: 'white',
border: 'none',
borderRadius: '8px',
fontWeight: 600,
fontSize: '0.9rem',
cursor: loading ? 'not-allowed' : 'pointer',
opacity: loading ? 0.7 : 1,
transition: 'opacity 0.2s',
}}
>
{loading ? 'Caricamento...' : mode === 'login' ? 'Accedi' : 'Crea account'}
</button>
</form>
{/* Divider */}
<div style={{ display: 'flex', alignItems: 'center', margin: '1.25rem 0', gap: '1rem' }}>
<div style={{ flex: 1, height: 1, backgroundColor: BORDER }} />
<span style={{ color: MUTED, fontSize: '0.8rem' }}>oppure</span>
<div style={{ flex: 1, height: 1, backgroundColor: BORDER }} />
</div>
<button
type="submit"
disabled={loading}
className="mt-6 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 ? 'Accesso...' : 'Accedi'}
</button>
</form>
{/* Social login buttons */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
{/* Google */}
<button
onClick={handleGoogleLogin}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.75rem',
width: '100%',
padding: '0.7rem',
backgroundColor: 'white',
border: `1px solid ${BORDER}`,
borderRadius: '8px',
cursor: 'pointer',
fontWeight: 500,
fontSize: '0.875rem',
color: INK,
transition: 'box-shadow 0.2s',
}}
>
<GoogleIcon />
Continua con Google
</button>
{/* Coming soon row */}
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'center' }}>
{[
{ name: 'Facebook', icon: '📘' },
{ name: 'Microsoft', icon: '🪟' },
{ name: 'Apple', icon: '🍎' },
{ name: 'Instagram', icon: '📸' },
{ name: 'TikTok', icon: '🎵' },
].map(({ name, icon }) => (
<button
key={name}
onClick={comingSoon}
title={`${name} — Disponibile a breve`}
style={{
padding: '0.5rem 0.75rem',
backgroundColor: '#F5F5F5',
border: `1px solid ${BORDER}`,
borderRadius: '8px',
cursor: 'not-allowed',
fontSize: '1rem',
opacity: 0.5,
position: 'relative',
}}
>
{icon}
</button>
))}
</div>
<p style={{ textAlign: 'center', fontSize: '0.75rem', color: MUTED, margin: '0.25rem 0 0' }}>
Altri provider disponibili a breve
</p>
</div>
{/* Redeem code link */}
<p style={{ textAlign: 'center', marginTop: '1.5rem', fontSize: '0.85rem', color: MUTED }}>
Hai un codice Pro?{' '}
<button
onClick={() => setShowRedeemModal(true)}
style={{
background: 'none',
border: 'none',
color: CORAL,
cursor: 'pointer',
fontWeight: 600,
fontSize: '0.85rem',
padding: 0,
}}
>
Riscattalo
</button>
</p>
{showRedeemModal && (
<RedeemModal onClose={() => setShowRedeemModal(false)} />
)}
</div>
</div>
</div>
)
}
function RedeemModal({ onClose }) {
const [code, setCode] = useState('')
const [loading, setLoading] = useState(false)
const [message, setMessage] = useState('')
const [error, setError] = useState('')
const { login } = useAuth()
const handleRedeem = async (e) => {
e.preventDefault()
setLoading(true)
setError('')
setMessage('')
try {
const { api } = await import('../api')
const result = await api.post('/auth/redeem', { code })
setMessage(`Codice riscattato! Piano Pro attivo fino al ${new Date(result.subscription_expires_at).toLocaleDateString('it-IT')}.`)
} catch (err) {
setError(err.message || 'Codice non valido o già utilizzato.')
} finally {
setLoading(false)
}
}
return (
<div style={{
position: 'fixed', inset: 0,
backgroundColor: 'rgba(0,0,0,0.4)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 1000,
}}>
<div style={{
backgroundColor: 'white',
borderRadius: '16px',
padding: '2rem',
width: '380px',
maxWidth: '90vw',
boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
}}>
<h3 style={{ margin: '0 0 0.5rem', fontSize: '1.1rem', color: INK }}>Riscatta Codice Pro</h3>
<p style={{ margin: '0 0 1.25rem', fontSize: '0.85rem', color: MUTED }}>
Inserisci il tuo codice di attivazione (es. LP-XXXXXXXX)
</p>
{message ? (
<div style={{ padding: '0.75rem', backgroundColor: '#F0FDF4', border: '1px solid #86EFAC', borderRadius: '8px', color: '#16A34A', fontSize: '0.875rem', marginBottom: '1rem' }}>
{message}
</div>
) : (
<form onSubmit={handleRedeem}>
{error && (
<div style={{ padding: '0.75rem', backgroundColor: '#FEE2E2', border: '1px solid #FECACA', borderRadius: '8px', color: '#DC2626', fontSize: '0.875rem', marginBottom: '0.75rem' }}>
{error}
</div>
)}
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
placeholder="LP-XXXXXXXXXXXXXXXX"
required
style={{ ...inputStyle, marginBottom: '0.75rem', fontFamily: 'monospace', letterSpacing: '0.05em' }}
/>
<button
type="submit"
disabled={loading}
style={{
width: '100%',
padding: '0.7rem',
backgroundColor: '#FF6B4A',
color: 'white',
border: 'none',
borderRadius: '8px',
fontWeight: 600,
cursor: loading ? 'not-allowed' : 'pointer',
opacity: loading ? 0.7 : 1,
}}
>
{loading ? 'Verifica...' : 'Riscatta'}
</button>
</form>
)}
<button
onClick={onClose}
style={{
marginTop: '1rem',
width: '100%',
padding: '0.6rem',
backgroundColor: 'transparent',
border: `1px solid ${BORDER}`,
borderRadius: '8px',
cursor: 'pointer',
color: MUTED,
fontSize: '0.875rem',
}}
>
Chiudi
</button>
</div>
</div>
)
}
function GoogleIcon() {
return (
<svg width="18" height="18" viewBox="0 0 48 48">
<path fill="#FFC107" d="M43.6 20H24v8h11.3C33.6 33.1 29.3 36 24 36c-6.6 0-12-5.4-12-12s5.4-12 12-12c3.1 0 5.8 1.2 8 3l5.7-5.7C34.2 6.6 29.3 4.5 24 4.5 12.7 4.5 3.5 13.7 3.5 25S12.7 45.5 24 45.5c10.5 0 19.5-7.6 19.5-21 0-1.2-.1-2.4-.4-3.5z" />
<path fill="#FF3D00" d="M6.3 14.7l6.6 4.8C14.6 15.1 19 12 24 12c3.1 0 5.8 1.2 8 3l5.7-5.7C34.2 6.6 29.3 4.5 24 4.5c-7.7 0-14.4 4.4-17.7 10.2z" />
<path fill="#4CAF50" d="M24 45.5c5.2 0 9.9-1.9 13.5-5l-6.2-5.2C29.3 37 26.8 38 24 38c-5.3 0-9.7-3-11.3-7.4l-6.6 5.1C9.6 41.1 16.3 45.5 24 45.5z" />
<path fill="#1976D2" d="M43.6 20H24v8h11.3c-.7 2.1-2 3.9-3.7 5.2l6.2 5.2c3.7-3.4 5.7-8.4 5.7-13.4 0-1.2-.1-2.4-.4-3.5z" />
</svg>
)
}
const inputStyle = {
width: '100%',
padding: '0.65rem 0.9rem',
border: `1px solid #E8E4DC`,
borderRadius: '8px',
fontSize: '0.875rem',
outline: 'none',
boxSizing: 'border-box',
color: '#1A1A2E',
backgroundColor: '#FAF8F3',
}

View File

@@ -0,0 +1,93 @@
import { useState } from 'react'
import { useAuth } from '../AuthContext'
import UpgradeModal from './UpgradeModal'
export default function PlanBanner() {
const { user, isPro } = useAuth()
const [showUpgrade, setShowUpgrade] = useState(false)
if (!user) return null
if (isPro) {
const expires = user.subscription_expires_at
? new Date(user.subscription_expires_at).toLocaleDateString('it-IT')
: null
return (
<div style={{
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
padding: '0.75rem 1rem',
backgroundColor: '#ECFDF5',
border: '1px solid #A7F3D0',
borderRadius: '10px',
marginBottom: '1.25rem',
}}>
<span style={{ fontSize: '1.1rem' }}></span>
<div>
<span style={{ fontWeight: 600, fontSize: '0.875rem', color: '#065F46' }}>Piano Pro</span>
{expires && (
<span style={{ fontSize: '0.8rem', color: '#059669', marginLeft: '0.5rem' }}>
Attivo fino al {expires}
</span>
)}
</div>
</div>
)
}
const postsUsed = user.posts_generated_this_month || 0
const postsMax = 15
return (
<>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0.75rem 1rem',
backgroundColor: '#FFF7F5',
border: '1px solid #FFCBB8',
borderRadius: '10px',
marginBottom: '1.25rem',
flexWrap: 'wrap',
gap: '0.5rem',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ fontSize: '1rem' }}>🆓</span>
<span style={{ fontWeight: 600, fontSize: '0.875rem', color: '#C2410C' }}>
Piano Freemium
</span>
<span style={{ fontSize: '0.8rem', color: '#9A3412' }}>
{postsUsed} post su {postsMax} usati questo mese
</span>
{/* progress bar */}
<div style={{ width: 80, height: 6, backgroundColor: '#FED7AA', borderRadius: 3, overflow: 'hidden', marginLeft: 4 }}>
<div style={{
height: '100%',
width: `${Math.min(100, (postsUsed / postsMax) * 100)}%`,
backgroundColor: '#F97316',
borderRadius: 3,
}} />
</div>
</div>
<button
onClick={() => setShowUpgrade(true)}
style={{
padding: '0.4rem 0.9rem',
backgroundColor: '#FF6B4A',
color: 'white',
border: 'none',
borderRadius: '7px',
cursor: 'pointer',
fontWeight: 600,
fontSize: '0.8rem',
}}
>
Passa a Pro
</button>
</div>
{showUpgrade && <UpgradeModal onClose={() => setShowUpgrade(false)} />}
</>
)
}

View File

@@ -0,0 +1,247 @@
import { useState } from 'react'
import { api } from '../api'
import { useAuth } from '../AuthContext'
const CORAL = '#FF6B4A'
const INK = '#1A1A2E'
const MUTED = '#888'
const BORDER = '#E8E4DC'
const PLANS = [
{ months: 1, label: '1 mese', price: '€14.95', pricePerMonth: '€14.95/mese' },
{ months: 3, label: '3 mesi', price: '€39.95', pricePerMonth: '€13.32/mese', badge: 'Risparmia 15%' },
{ months: 6, label: '6 mesi', price: '€64.95', pricePerMonth: '€10.83/mese', badge: 'Risparmia 28%' },
{ months: 12, label: '1 anno', price: '€119.95', pricePerMonth: '€9.99/mese', badge: 'Best Value ✦' },
]
const COMPARISON = [
{ feature: 'Personaggi', free: '1', pro: 'Illimitati' },
{ feature: 'Post al mese', free: '15', pro: 'Illimitati' },
{ feature: 'Piattaforme', free: 'FB + IG', pro: 'FB + IG + YT + TT' },
{ feature: 'Piani automatici', free: '✗', pro: '✓' },
{ feature: 'Gestione commenti AI', free: '✗', pro: '✓' },
{ feature: 'Link affiliati', free: '✗', pro: '✓' },
{ feature: 'Calendario editoriale', free: '5 slot', pro: 'Illimitato' },
{ feature: 'Priorità supporto', free: '✗', pro: '✓' },
]
export default function UpgradeModal({ onClose }) {
const [redeemCode, setRedeemCode] = useState('')
const [redeemLoading, setRedeemLoading] = useState(false)
const [redeemError, setRedeemError] = useState('')
const [redeemSuccess, setRedeemSuccess] = useState('')
const { loadUser } = useAuth()
const handleRedeem = async (e) => {
e.preventDefault()
setRedeemLoading(true)
setRedeemError('')
setRedeemSuccess('')
try {
const result = await api.post('/auth/redeem', { code: redeemCode })
setRedeemSuccess(`Piano Pro attivato fino al ${new Date(result.subscription_expires_at).toLocaleDateString('it-IT')}!`)
await loadUser()
} catch (err) {
setRedeemError(err.message || 'Codice non valido o già utilizzato.')
} finally {
setRedeemLoading(false)
}
}
return (
<div style={{
position: 'fixed', inset: 0,
backgroundColor: 'rgba(0,0,0,0.5)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 1000,
padding: '1rem',
overflowY: 'auto',
}}>
<div style={{
backgroundColor: 'white',
borderRadius: '20px',
padding: '2.5rem',
width: '100%',
maxWidth: '680px',
maxHeight: '90vh',
overflowY: 'auto',
boxShadow: '0 25px 80px rgba(0,0,0,0.25)',
position: 'relative',
}}>
{/* Header */}
<div style={{ textAlign: 'center', marginBottom: '1.5rem' }}>
<span style={{
display: 'inline-block',
padding: '0.3rem 0.8rem',
backgroundColor: '#FFF3E0',
color: '#E65100',
borderRadius: '20px',
fontSize: '0.75rem',
fontWeight: 700,
letterSpacing: '0.05em',
marginBottom: '0.75rem',
}}>
EARLY ADOPTER BETA
</span>
<h2 style={{ margin: 0, fontSize: '1.8rem', color: INK, fontWeight: 700 }}>
Passa a Leopost Pro
</h2>
<p style={{ margin: '0.5rem 0 0', color: MUTED, fontSize: '0.9rem' }}>
Sblocca tutto il potenziale del tuo studio editoriale AI
</p>
</div>
{/* Comparison table */}
<div style={{
border: `1px solid ${BORDER}`,
borderRadius: '12px',
overflow: 'hidden',
marginBottom: '1.5rem',
}}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr' }}>
<div style={{ padding: '0.75rem 1rem', backgroundColor: '#F9F9F9', fontWeight: 700, fontSize: '0.8rem', color: MUTED, textTransform: 'uppercase', letterSpacing: '0.05em' }}>Feature</div>
<div style={{ padding: '0.75rem 1rem', backgroundColor: '#F9F9F9', fontWeight: 700, fontSize: '0.8rem', color: MUTED, textTransform: 'uppercase', textAlign: 'center' }}>Freemium</div>
<div style={{ padding: '0.75rem 1rem', backgroundColor: '#FFF5F3', fontWeight: 700, fontSize: '0.8rem', color: CORAL, textTransform: 'uppercase', textAlign: 'center' }}>Pro </div>
</div>
{COMPARISON.map((row, i) => (
<div key={row.feature} style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr 1fr',
borderTop: `1px solid ${BORDER}`,
backgroundColor: i % 2 === 0 ? 'white' : '#FAFAFA',
}}>
<div style={{ padding: '0.6rem 1rem', fontSize: '0.85rem', color: INK }}>{row.feature}</div>
<div style={{ padding: '0.6rem 1rem', fontSize: '0.85rem', color: MUTED, textAlign: 'center' }}>{row.free}</div>
<div style={{ padding: '0.6rem 1rem', fontSize: '0.85rem', color: '#16A34A', fontWeight: 600, textAlign: 'center' }}>{row.pro}</div>
</div>
))}
</div>
{/* Pricing */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '0.75rem', marginBottom: '1.5rem' }}>
{PLANS.map((plan) => (
<div key={plan.months} style={{
border: `1px solid ${BORDER}`,
borderRadius: '10px',
padding: '1rem',
position: 'relative',
backgroundColor: plan.months === 12 ? '#FFF5F3' : 'white',
borderColor: plan.months === 12 ? CORAL : BORDER,
}}>
{plan.badge && (
<span style={{
position: 'absolute',
top: '-10px',
right: '10px',
backgroundColor: CORAL,
color: 'white',
padding: '0.15rem 0.5rem',
borderRadius: '10px',
fontSize: '0.7rem',
fontWeight: 700,
}}>
{plan.badge}
</span>
)}
<div style={{ fontWeight: 700, color: INK, marginBottom: '0.25rem' }}>{plan.label}</div>
<div style={{ fontSize: '1.4rem', fontWeight: 800, color: plan.months === 12 ? CORAL : INK }}>{plan.price}</div>
<div style={{ fontSize: '0.75rem', color: MUTED }}>{plan.pricePerMonth}</div>
</div>
))}
</div>
{/* CTA */}
<a
href="mailto:info@leopost.it?subject=Richiesta Piano Pro - Early Adopter&body=Salve, sono interessato al piano Pro di Leopost. Potete contattarmi per i dettagli?"
style={{
display: 'block',
textAlign: 'center',
padding: '0.9rem',
backgroundColor: CORAL,
color: 'white',
borderRadius: '10px',
fontWeight: 700,
fontSize: '0.95rem',
textDecoration: 'none',
marginBottom: '1.5rem',
}}
>
Contattaci per attivare Pro Early Adopter
</a>
{/* Redeem code */}
<div style={{
border: `1px solid ${BORDER}`,
borderRadius: '10px',
padding: '1.25rem',
backgroundColor: '#FAFAFA',
}}>
<h4 style={{ margin: '0 0 0.75rem', fontSize: '0.9rem', color: INK }}>Hai già un codice?</h4>
{redeemSuccess ? (
<div style={{ padding: '0.75rem', backgroundColor: '#F0FDF4', border: '1px solid #86EFAC', borderRadius: '8px', color: '#16A34A', fontSize: '0.875rem' }}>
{redeemSuccess}
</div>
) : (
<form onSubmit={handleRedeem} style={{ display: 'flex', gap: '0.5rem' }}>
<input
type="text"
value={redeemCode}
onChange={(e) => setRedeemCode(e.target.value.toUpperCase())}
placeholder="LP-XXXXXXXXXXXXXXXX"
style={{
flex: 1,
padding: '0.6rem 0.75rem',
border: `1px solid ${BORDER}`,
borderRadius: '7px',
fontFamily: 'monospace',
fontSize: '0.875rem',
outline: 'none',
}}
/>
<button
type="submit"
disabled={redeemLoading || !redeemCode.trim()}
style={{
padding: '0.6rem 1rem',
backgroundColor: INK,
color: 'white',
border: 'none',
borderRadius: '7px',
cursor: redeemLoading ? 'not-allowed' : 'pointer',
fontWeight: 600,
fontSize: '0.85rem',
opacity: redeemLoading ? 0.7 : 1,
whiteSpace: 'nowrap',
}}
>
{redeemLoading ? '...' : 'Riscatta'}
</button>
</form>
)}
{redeemError && (
<p style={{ margin: '0.5rem 0 0', color: '#DC2626', fontSize: '0.8rem' }}>{redeemError}</p>
)}
</div>
{/* Close button */}
<button
onClick={onClose}
style={{
position: 'absolute',
top: '1rem',
right: '1rem',
background: 'none',
border: 'none',
fontSize: '1.4rem',
cursor: 'pointer',
color: MUTED,
lineHeight: 1,
}}
>
×
</button>
</div>
</div>
)
}