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:
@@ -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="/" />} />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
344
frontend/src/components/AdminSettings.jsx
Normal file
344
frontend/src/components/AdminSettings.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
37
frontend/src/components/AuthCallback.jsx
Normal file
37
frontend/src/components/AuthCallback.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)" />
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
93
frontend/src/components/PlanBanner.jsx
Normal file
93
frontend/src/components/PlanBanner.jsx
Normal 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)} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
247
frontend/src/components/UpgradeModal.jsx
Normal file
247
frontend/src/components/UpgradeModal.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user