feat: sync all BRAIN mobile changes - onboarding, cookies, legal, mobile UX, settings
- Add OnboardingWizard, BetaBanner, CookieBanner components - Add legal pages (Privacy, Terms, Cookies) - Update Layout with mobile topbar, sidebar drawer, plan banner - Update SettingsPage with profile, API config, security - Update CharacterForm with topic suggestions, niche chips - Update EditorialCalendar with shared strategy card - Update ContentPage with narrative technique + brief - Update SocialAccounts with 4 platforms and token guides - Fix CSS button color inheritance, mobile responsive - Add backup script - Update .gitignore for pgdata and backups Co-Authored-By: Claude (BRAIN/StackOS) <noreply@anthropic.com>
This commit is contained in:
@@ -2,12 +2,12 @@ import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api } from '../api'
|
||||
|
||||
const networkColors = {
|
||||
Amazon: 'bg-amber-50 text-amber-700',
|
||||
ClickBank: 'bg-emerald-50 text-emerald-700',
|
||||
ShareASale: 'bg-blue-50 text-blue-700',
|
||||
CJ: 'bg-violet-50 text-violet-700',
|
||||
Impact: 'bg-rose-50 text-rose-700',
|
||||
const NETWORK_COLORS = {
|
||||
Amazon: { bg: '#FFF8E1', color: '#B45309' },
|
||||
ClickBank: { bg: '#ECFDF5', color: '#065F46' },
|
||||
ShareASale:{ bg: '#EFF6FF', color: '#1D4ED8' },
|
||||
CJ: { bg: '#F5F3FF', color: '#6D28D9' },
|
||||
Impact: { bg: '#FFF1F2', color: '#BE123C' },
|
||||
}
|
||||
|
||||
export default function AffiliateList() {
|
||||
@@ -16,203 +16,166 @@ export default function AffiliateList() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [filterCharacter, setFilterCharacter] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
useEffect(() => { loadData() }, [])
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [linksData, charsData] = await Promise.all([
|
||||
api.get('/affiliates/'),
|
||||
api.get('/characters/'),
|
||||
api.get('/affiliates/'), api.get('/characters/'),
|
||||
])
|
||||
setLinks(linksData)
|
||||
setCharacters(charsData)
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
} catch {} finally { setLoading(false) }
|
||||
}
|
||||
|
||||
const getCharacterName = (id) => {
|
||||
if (!id) return 'Globale'
|
||||
const c = characters.find((ch) => ch.id === id)
|
||||
return c ? c.name : '—'
|
||||
}
|
||||
|
||||
const getNetworkColor = (network) => {
|
||||
return networkColors[network] || 'bg-slate-100 text-slate-600'
|
||||
return characters.find(c => c.id === id)?.name || '—'
|
||||
}
|
||||
|
||||
const handleToggle = async (link) => {
|
||||
try {
|
||||
await api.put(`/affiliates/${link.id}`, { is_active: !link.is_active })
|
||||
loadData()
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
await api.put(`/affiliates/${link.id}`, { is_active: !link.is_active }).catch(() => {})
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleDelete = async (id, name) => {
|
||||
if (!confirm(`Eliminare "${name}"?`)) return
|
||||
try {
|
||||
await api.delete(`/affiliates/${id}`)
|
||||
loadData()
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
await api.delete(`/affiliates/${id}`).catch(() => {})
|
||||
loadData()
|
||||
}
|
||||
|
||||
const truncateUrl = (url) => {
|
||||
if (!url) return '—'
|
||||
if (url.length <= 50) return url
|
||||
return url.substring(0, 50) + '...'
|
||||
}
|
||||
|
||||
const filtered = links.filter((l) => {
|
||||
const filtered = links.filter(l => {
|
||||
if (filterCharacter === '') return true
|
||||
if (filterCharacter === 'global') return !l.character_id
|
||||
return String(l.character_id) === filterCharacter
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div style={{ animation: 'fade-up 0.5s ease-out both' }}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: '2rem' }}>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-slate-800">Link Affiliati</h2>
|
||||
<p className="text-slate-500 mt-1 text-sm">
|
||||
Gestisci i link affiliati per la monetizzazione
|
||||
<span className="editorial-tag">Link Affiliati</span>
|
||||
<div className="editorial-line" />
|
||||
<h2 style={{ fontFamily: "'Fraunces', serif", fontSize: '1.75rem', fontWeight: 600, letterSpacing: '-0.02em', color: 'var(--ink)', margin: '0.5rem 0 0.3rem' }}>
|
||||
Monetizzazione
|
||||
</h2>
|
||||
<p style={{ fontSize: '0.875rem', color: 'var(--ink-muted)', margin: 0 }}>
|
||||
Gestisci i link affiliati: Leopost li inserisce automaticamente nei contenuti generati.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/affiliates/new"
|
||||
className="px-4 py-2 bg-brand-600 hover:bg-brand-700 text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
+ Nuovo Link
|
||||
</Link>
|
||||
<Link to="/affiliates/new" style={btnPrimary}>+ Nuovo Link</Link>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-3 mb-6">
|
||||
<select
|
||||
value={filterCharacter}
|
||||
onChange={(e) => setFilterCharacter(e.target.value)}
|
||||
className="px-3 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm bg-white"
|
||||
>
|
||||
<option value="">Tutti</option>
|
||||
<option value="global">Globale</option>
|
||||
{characters.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<span className="flex items-center text-xs text-slate-400 ml-auto">
|
||||
{filtered.length} link
|
||||
</span>
|
||||
</div>
|
||||
{/* Filter */}
|
||||
{characters.length > 0 && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '1.5rem' }}>
|
||||
<label style={{ fontSize: '0.78rem', fontWeight: 600, color: 'var(--ink-muted)', textTransform: 'uppercase', letterSpacing: '0.08em' }}>Filtra per</label>
|
||||
<select value={filterCharacter} onChange={e => setFilterCharacter(e.target.value)} style={selectStyle}>
|
||||
<option value="">Tutti</option>
|
||||
<option value="global">Globale</option>
|
||||
{characters.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
<span style={{ fontSize: '0.78rem', color: 'var(--ink-muted)', marginLeft: 'auto' }}>{filtered.length} link</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-brand-500 border-t-transparent" />
|
||||
</div>
|
||||
<Spinner />
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="text-center py-16 bg-white rounded-xl border border-slate-200">
|
||||
<p className="text-4xl mb-3">⟁</p>
|
||||
<p className="text-slate-500 font-medium">Nessun link affiliato</p>
|
||||
<p className="text-slate-400 text-sm mt-1">
|
||||
Aggiungi i tuoi primi link affiliati per monetizzare i contenuti
|
||||
</p>
|
||||
<Link
|
||||
to="/affiliates/new"
|
||||
className="inline-block mt-4 px-4 py-2 bg-brand-600 hover:bg-brand-700 text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
+ Crea link affiliato
|
||||
</Link>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon="⟁"
|
||||
title="Nessun link affiliato"
|
||||
description="Aggiungi i link affiliati dei tuoi programmi (Amazon, ClickBank, ecc.) e Leopost li inserirà automaticamente nei contenuti pertinenti."
|
||||
cta="+ Aggiungi primo link"
|
||||
to="/affiliates/new"
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-100">
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wider">Network</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wider">Nome</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wider hidden md:table-cell">URL</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wider hidden lg:table-cell">Tag</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wider hidden lg:table-cell">Topic</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wider">Personaggio</th>
|
||||
<th className="text-center px-4 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wider">Stato</th>
|
||||
<th className="text-center px-4 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wider">Click</th>
|
||||
<th className="text-right px-4 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wider">Azioni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
{filtered.map((link) => (
|
||||
<tr key={link.id} className="hover:bg-slate-50 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${getNetworkColor(link.network)}`}>
|
||||
{link.network || '—'}
|
||||
</span>
|
||||
<div style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)', overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', fontSize: '0.85rem', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid var(--border)' }}>
|
||||
{['Network','Nome','URL','Personaggio','Stato','Click',''].map(h => (
|
||||
<th key={h} style={{ padding: '0.75rem 1rem', textAlign: 'left', fontSize: '0.68rem', fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--ink-muted)', backgroundColor: 'var(--cream-dark)' }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(link => {
|
||||
const nc = NETWORK_COLORS[link.network] || { bg: 'var(--cream-dark)', color: 'var(--ink-muted)' }
|
||||
return (
|
||||
<tr key={link.id} style={{ borderBottom: '1px solid var(--border)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.backgroundColor = 'var(--cream)'}
|
||||
onMouseLeave={e => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||
>
|
||||
<td style={{ padding: '0.75rem 1rem' }}>
|
||||
<span style={{ fontSize: '0.72rem', fontWeight: 600, padding: '0.2rem 0.5rem', backgroundColor: nc.bg, color: nc.color }}>{link.network || '—'}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-medium text-slate-700">{link.name}</td>
|
||||
<td className="px-4 py-3 text-slate-500 hidden md:table-cell">
|
||||
<span className="font-mono text-xs">{truncateUrl(link.url)}</span>
|
||||
<td style={{ padding: '0.75rem 1rem', fontWeight: 600, color: 'var(--ink)' }}>{link.name}</td>
|
||||
<td style={{ padding: '0.75rem 1rem', color: 'var(--ink-muted)', fontFamily: 'monospace', fontSize: '0.78rem', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{link.url?.substring(0, 45)}{link.url?.length > 45 ? '…' : ''}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-500 hidden lg:table-cell">
|
||||
<span className="font-mono text-xs">{link.tag || '—'}</span>
|
||||
<td style={{ padding: '0.75rem 1rem', color: 'var(--ink-muted)', fontSize: '0.82rem' }}>{getCharacterName(link.character_id)}</td>
|
||||
<td style={{ padding: '0.75rem 1rem' }}>
|
||||
<span style={{ display: 'inline-block', width: 8, height: 8, borderRadius: '50%', backgroundColor: link.is_active ? 'var(--success)' : 'var(--border-strong)' }} />
|
||||
</td>
|
||||
<td className="px-4 py-3 hidden lg:table-cell">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{link.topics && link.topics.slice(0, 2).map((t, i) => (
|
||||
<span key={i} className="text-xs px-1.5 py-0.5 bg-slate-100 text-slate-600 rounded">
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
{link.topics && link.topics.length > 2 && (
|
||||
<span className="text-xs text-slate-400">+{link.topics.length - 2}</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-500 text-xs">
|
||||
{getCharacterName(link.character_id)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className={`inline-block w-2 h-2 rounded-full ${link.is_active ? 'bg-emerald-500' : 'bg-slate-300'}`} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center text-slate-500">
|
||||
{link.click_count ?? 0}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Link
|
||||
to={`/affiliates/${link.id}/edit`}
|
||||
className="text-xs px-2 py-1 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded transition-colors"
|
||||
>
|
||||
Modifica
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => handleToggle(link)}
|
||||
className="text-xs px-2 py-1 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded transition-colors"
|
||||
>
|
||||
{link.is_active ? 'Disattiva' : 'Attiva'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(link.id, link.name)}
|
||||
className="text-xs px-2 py-1 text-red-500 hover:bg-red-50 rounded transition-colors"
|
||||
>
|
||||
Elimina
|
||||
</button>
|
||||
<td style={{ padding: '0.75rem 1rem', color: 'var(--ink-muted)' }}>{link.click_count ?? 0}</td>
|
||||
<td style={{ padding: '0.75rem 1rem' }}>
|
||||
<div style={{ display: 'flex', gap: '0.4rem', justifyContent: 'flex-end' }}>
|
||||
<Link to={`/affiliates/${link.id}/edit`} style={btnSmall}>Modifica</Link>
|
||||
<button onClick={() => handleToggle(link)} style={btnSmall}>{link.is_active ? 'Disattiva' : 'Attiva'}</button>
|
||||
<button onClick={() => handleDelete(link.id, link.name)} style={{ ...btnSmall, color: 'var(--error)' }}>Elimina</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState({ icon, title, description, cta, to }) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '4rem 2rem', backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}>
|
||||
<div style={{ fontSize: '2.5rem', marginBottom: '1rem', color: 'var(--accent)' }}>{icon}</div>
|
||||
<h3 style={{ fontFamily: "'Fraunces', serif", fontSize: '1.2rem', fontWeight: 600, color: 'var(--ink)', margin: '0 0 0.75rem' }}>{title}</h3>
|
||||
<p style={{ fontSize: '0.875rem', color: 'var(--ink-muted)', maxWidth: 400, margin: '0 auto 1.5rem', lineHeight: 1.6 }}>{description}</p>
|
||||
<Link to={to} style={btnPrimary}>{cta}</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Spinner() {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem 0' }}>
|
||||
<div style={{ width: 28, height: 28, border: '2px solid var(--border)', borderTopColor: 'var(--accent)', borderRadius: '50%', animation: 'spin 0.8s linear infinite' }} />
|
||||
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const btnPrimary = {
|
||||
display: 'inline-block', padding: '0.6rem 1.25rem',
|
||||
backgroundColor: 'var(--ink)', color: 'white',
|
||||
fontFamily: "'DM Sans', sans-serif", fontWeight: 600,
|
||||
fontSize: '0.875rem', textDecoration: 'none',
|
||||
border: 'none', cursor: 'pointer', whiteSpace: 'nowrap',
|
||||
}
|
||||
const btnSmall = {
|
||||
display: 'inline-block', padding: '0.35rem 0.75rem',
|
||||
backgroundColor: 'var(--cream-dark)', color: 'var(--ink-light)',
|
||||
fontFamily: "'DM Sans', sans-serif", fontWeight: 500,
|
||||
fontSize: '0.78rem', textDecoration: 'none',
|
||||
border: 'none', cursor: 'pointer',
|
||||
}
|
||||
const selectStyle = {
|
||||
padding: '0.45rem 0.75rem', border: '1px solid var(--border)',
|
||||
backgroundColor: 'var(--surface)', color: 'var(--ink)',
|
||||
fontSize: '0.85rem', fontFamily: "'DM Sans', sans-serif",
|
||||
outline: 'none', cursor: 'pointer',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user