- Backend FastAPI con multi-LLM (Claude/OpenAI/Gemini) - Publishing su Facebook, Instagram, YouTube, TikTok - Calendario editoriale con awareness levels (PAS, AIDA, BAB...) - Design system Editorial Fresh (Fraunces + DM Sans) - Scheduler automatico, gestione commenti AI, affiliate links Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
219 lines
8.9 KiB
JavaScript
219 lines
8.9 KiB
JavaScript
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',
|
|
}
|
|
|
|
export default function AffiliateList() {
|
|
const [links, setLinks] = useState([])
|
|
const [characters, setCharacters] = useState([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [filterCharacter, setFilterCharacter] = useState('')
|
|
|
|
useEffect(() => {
|
|
loadData()
|
|
}, [])
|
|
|
|
const loadData = async () => {
|
|
setLoading(true)
|
|
try {
|
|
const [linksData, charsData] = await Promise.all([
|
|
api.get('/affiliates/'),
|
|
api.get('/characters/'),
|
|
])
|
|
setLinks(linksData)
|
|
setCharacters(charsData)
|
|
} catch {
|
|
// silent
|
|
} 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'
|
|
}
|
|
|
|
const handleToggle = async (link) => {
|
|
try {
|
|
await api.put(`/affiliates/${link.id}`, { is_active: !link.is_active })
|
|
loadData()
|
|
} catch {
|
|
// silent
|
|
}
|
|
}
|
|
|
|
const handleDelete = async (id, name) => {
|
|
if (!confirm(`Eliminare "${name}"?`)) return
|
|
try {
|
|
await api.delete(`/affiliates/${id}`)
|
|
loadData()
|
|
} catch {
|
|
// silent
|
|
}
|
|
}
|
|
|
|
const truncateUrl = (url) => {
|
|
if (!url) return '—'
|
|
if (url.length <= 50) return url
|
|
return url.substring(0, 50) + '...'
|
|
}
|
|
|
|
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>
|
|
<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
|
|
</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>
|
|
</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>
|
|
|
|
{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>
|
|
) : 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>
|
|
) : (
|
|
<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>
|
|
</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>
|
|
<td className="px-4 py-3 text-slate-500 hidden lg:table-cell">
|
|
<span className="font-mono text-xs">{link.tag || '—'}</span>
|
|
</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>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|