feat(01-04): badge PN/Schwartz e PostCard con stati success/failed/pending
- BadgePN.tsx: 6 colori distinti per tipi PN (valore/storytelling/news/riprova_sociale/coinvolgimento/promozione) - BadgeSchwartz.tsx: 5 livelli L1-L5 con tooltip descriptivo, colori progressivi - PostCard.tsx: stati success (espandibile con SlideViewer) / failed (errore + pulsante Riprova) / pending (loader) - SlideViewer.tsx: stub per compilazione (completato nel task successivo) - PostCard usa useGenerateSingle() per rigenerazione inline post falliti
This commit is contained in:
59
frontend/src/components/BadgePN.tsx
Normal file
59
frontend/src/components/BadgePN.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Badge per tipo Persuasion Nurturing (PN).
|
||||||
|
* 6 tipi distinti con colori semantici.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { TipoContenuto } from '../types'
|
||||||
|
|
||||||
|
interface BadgePNProps {
|
||||||
|
tipo: TipoContenuto | string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const PN_CONFIG: Record<string, { label: string; classes: string }> = {
|
||||||
|
valore: {
|
||||||
|
label: 'Valore',
|
||||||
|
classes: 'bg-blue-500/15 text-blue-300 border-blue-500/30',
|
||||||
|
},
|
||||||
|
storytelling: {
|
||||||
|
label: 'Storytelling',
|
||||||
|
classes: 'bg-violet-500/15 text-violet-300 border-violet-500/30',
|
||||||
|
},
|
||||||
|
news: {
|
||||||
|
label: 'News',
|
||||||
|
classes: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30',
|
||||||
|
},
|
||||||
|
riprova_sociale: {
|
||||||
|
label: 'Social Proof',
|
||||||
|
classes: 'bg-orange-500/15 text-orange-300 border-orange-500/30',
|
||||||
|
},
|
||||||
|
coinvolgimento: {
|
||||||
|
label: 'Engagement',
|
||||||
|
classes: 'bg-yellow-500/15 text-yellow-300 border-yellow-500/30',
|
||||||
|
},
|
||||||
|
promozione: {
|
||||||
|
label: 'Promo',
|
||||||
|
classes: 'bg-red-500/15 text-red-300 border-red-500/30',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const FALLBACK = {
|
||||||
|
label: 'Sconosciuto',
|
||||||
|
classes: 'bg-stone-500/15 text-stone-400 border-stone-500/30',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BadgePN({ tipo, className = '' }: BadgePNProps) {
|
||||||
|
const config = PN_CONFIG[tipo] ?? FALLBACK
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
'inline-flex items-center px-2 py-0.5 rounded-full border text-xs font-medium',
|
||||||
|
config.classes,
|
||||||
|
className,
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
65
frontend/src/components/BadgeSchwartz.tsx
Normal file
65
frontend/src/components/BadgeSchwartz.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Badge per livello di consapevolezza Schwartz (L1-L5).
|
||||||
|
* Colori progressivi dal basso (L1 = pronto all'acquisto) all'alto (L5 = inconsapevole).
|
||||||
|
*
|
||||||
|
* L5 → top of funnel (inconsapevole del problema)
|
||||||
|
* L1 → bottom of funnel (pronto all'acquisto)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { LivelloSchwartz } from '../types'
|
||||||
|
|
||||||
|
interface BadgeSchwartzProps {
|
||||||
|
livello: LivelloSchwartz | string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCHWARTZ_CONFIG: Record<string, { label: string; desc: string; classes: string }> = {
|
||||||
|
L5: {
|
||||||
|
label: 'L5',
|
||||||
|
desc: 'Inconsapevole del problema',
|
||||||
|
classes: 'bg-sky-500/15 text-sky-300 border-sky-500/30',
|
||||||
|
},
|
||||||
|
L4: {
|
||||||
|
label: 'L4',
|
||||||
|
desc: 'Consapevole del problema',
|
||||||
|
classes: 'bg-teal-500/15 text-teal-300 border-teal-500/30',
|
||||||
|
},
|
||||||
|
L3: {
|
||||||
|
label: 'L3',
|
||||||
|
desc: 'Consapevole della soluzione',
|
||||||
|
classes: 'bg-lime-500/15 text-lime-300 border-lime-500/30',
|
||||||
|
},
|
||||||
|
L2: {
|
||||||
|
label: 'L2',
|
||||||
|
desc: 'Consapevole del prodotto',
|
||||||
|
classes: 'bg-amber-500/15 text-amber-300 border-amber-500/30',
|
||||||
|
},
|
||||||
|
L1: {
|
||||||
|
label: 'L1',
|
||||||
|
desc: 'Pronto all\'acquisto',
|
||||||
|
classes: 'bg-orange-500/15 text-orange-300 border-orange-500/30',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const FALLBACK = {
|
||||||
|
label: '?',
|
||||||
|
desc: 'Livello sconosciuto',
|
||||||
|
classes: 'bg-stone-500/15 text-stone-400 border-stone-500/30',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BadgeSchwartz({ livello, className = '' }: BadgeSchwartzProps) {
|
||||||
|
const config = SCHWARTZ_CONFIG[livello] ?? FALLBACK
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
title={config.desc}
|
||||||
|
className={[
|
||||||
|
'inline-flex items-center px-2 py-0.5 rounded-full border text-xs font-medium cursor-help',
|
||||||
|
config.classes,
|
||||||
|
className,
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
180
frontend/src/components/PostCard.tsx
Normal file
180
frontend/src/components/PostCard.tsx
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
/**
|
||||||
|
* Card singolo post nella griglia Output Review.
|
||||||
|
*
|
||||||
|
* - status="success": mostra cover_title, badge PN + Schwartz, click per espandere SlideViewer
|
||||||
|
* - status="failed": card rossa con messaggio errore e pulsante Riprova
|
||||||
|
* - status="pending": card grigia con loader
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { AlertTriangle, ChevronDown, ChevronUp, Loader2, RefreshCw } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import type { GenerateRequest, PostResult } from '../types'
|
||||||
|
import { useGenerateSingle } from '../api/hooks'
|
||||||
|
import { BadgePN } from './BadgePN'
|
||||||
|
import { BadgeSchwartz } from './BadgeSchwartz'
|
||||||
|
import { SlideViewer } from './SlideViewer'
|
||||||
|
|
||||||
|
interface PostCardProps {
|
||||||
|
result: PostResult
|
||||||
|
/** Obiettivo campagna — necessario per rigenerare */
|
||||||
|
obiettivoCampagna: string
|
||||||
|
/** Brand name opzionale per rigenerare */
|
||||||
|
brandName?: string | null
|
||||||
|
/** Tono opzionale per rigenerare */
|
||||||
|
tono?: string | null
|
||||||
|
/** Callback quando il post viene rigenerato con successo */
|
||||||
|
onRegenerated: (updated: PostResult) => void
|
||||||
|
/** Callback quando le slide vengono modificate inline */
|
||||||
|
onEdit: (updated: PostResult) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PostCard({
|
||||||
|
result,
|
||||||
|
obiettivoCampagna,
|
||||||
|
brandName,
|
||||||
|
tono,
|
||||||
|
onRegenerated,
|
||||||
|
onEdit,
|
||||||
|
}: PostCardProps) {
|
||||||
|
const [expanded, setExpanded] = useState(false)
|
||||||
|
const generateSingle = useGenerateSingle()
|
||||||
|
|
||||||
|
const slot = result.slot
|
||||||
|
const post = result.post
|
||||||
|
|
||||||
|
async function handleRetry() {
|
||||||
|
if (!slot) return
|
||||||
|
const req: GenerateRequest = {
|
||||||
|
slot,
|
||||||
|
obiettivo_campagna: obiettivoCampagna,
|
||||||
|
brand_name: brandName,
|
||||||
|
tono: tono,
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const newResult = await generateSingle.mutateAsync(req)
|
||||||
|
// Mantieni il slot nel risultato rigenerato
|
||||||
|
onRegenerated({ ...newResult, slot })
|
||||||
|
} catch {
|
||||||
|
// L'errore verrà mostrato via generateSingle.error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- PENDING -----
|
||||||
|
if (result.status === 'pending') {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-stone-700 bg-stone-800/50 p-4 flex items-center gap-3 opacity-60">
|
||||||
|
<Loader2 size={16} className="animate-spin text-stone-500 flex-shrink-0" />
|
||||||
|
<p className="text-sm text-stone-500">Post {result.slot_index + 1} — in attesa...</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- FAILED -----
|
||||||
|
if (result.status === 'failed') {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-red-500/30 bg-red-500/5 p-4 space-y-3">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<AlertTriangle size={16} className="text-red-400 flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-red-300">
|
||||||
|
Post {result.slot_index + 1} — Generazione fallita
|
||||||
|
</p>
|
||||||
|
{result.error && (
|
||||||
|
<p className="text-xs text-red-400/70 mt-0.5 line-clamp-2">{result.error}</p>
|
||||||
|
)}
|
||||||
|
{slot && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||||
|
<BadgePN tipo={slot.tipo_contenuto} />
|
||||||
|
<BadgeSchwartz livello={slot.livello_schwartz} />
|
||||||
|
<span className="text-xs text-stone-600">{slot.target_nicchia}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleRetry}
|
||||||
|
disabled={generateSingle.isPending}
|
||||||
|
className="flex items-center gap-1.5 text-xs text-red-300 hover:text-red-200 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{generateSingle.isPending ? (
|
||||||
|
<Loader2 size={12} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw size={12} />
|
||||||
|
)}
|
||||||
|
{generateSingle.isPending ? 'Rigenerazione...' : 'Riprova'}
|
||||||
|
</button>
|
||||||
|
{generateSingle.error && (
|
||||||
|
<p className="text-xs text-red-400">{generateSingle.error.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- SUCCESS -----
|
||||||
|
if (!post) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'rounded-xl border transition-all',
|
||||||
|
expanded
|
||||||
|
? 'border-amber-500/40 bg-stone-800'
|
||||||
|
: 'border-stone-700 bg-stone-800 hover:border-stone-600',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{/* Header card — sempre visibile, click per espandere */}
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
className="w-full px-4 py-4 text-left"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{/* Numero slot + badge */}
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5 mb-2">
|
||||||
|
<span className="text-xs font-mono text-stone-500">
|
||||||
|
#{result.slot_index + 1}
|
||||||
|
</span>
|
||||||
|
{slot && (
|
||||||
|
<>
|
||||||
|
<BadgePN tipo={slot.tipo_contenuto} />
|
||||||
|
<BadgeSchwartz livello={slot.livello_schwartz} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cover title */}
|
||||||
|
<p className="text-sm font-medium text-stone-100 line-clamp-2 leading-snug">
|
||||||
|
{post.cover_title}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Metadati secondari */}
|
||||||
|
{slot && (
|
||||||
|
<div className="flex flex-wrap gap-x-3 gap-y-0.5 mt-2">
|
||||||
|
<span className="text-xs text-stone-500">{slot.formato_narrativo}</span>
|
||||||
|
<span className="text-xs text-stone-600">·</span>
|
||||||
|
<span className="text-xs text-stone-500">{slot.target_nicchia}</span>
|
||||||
|
<span className="text-xs text-stone-600">·</span>
|
||||||
|
<span className="text-xs text-stone-500">{slot.data_pub_suggerita}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expand/collapse icon */}
|
||||||
|
<span className="text-stone-500 flex-shrink-0 mt-0.5">
|
||||||
|
{expanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Expanded: SlideViewer */}
|
||||||
|
{expanded && (
|
||||||
|
<div className="border-t border-stone-700 px-4 pb-4 pt-3">
|
||||||
|
<SlideViewer
|
||||||
|
result={result}
|
||||||
|
onEdit={onEdit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
20
frontend/src/components/SlideViewer.tsx
Normal file
20
frontend/src/components/SlideViewer.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* SlideViewer — stub per Task 2a.
|
||||||
|
* Completato in Task 2b.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { PostResult } from '../types'
|
||||||
|
|
||||||
|
interface SlideViewerProps {
|
||||||
|
result: PostResult
|
||||||
|
onEdit: (updated: PostResult) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SlideViewer({ result, onEdit: _onEdit }: SlideViewerProps) {
|
||||||
|
if (!result.post) return null
|
||||||
|
return (
|
||||||
|
<div className="text-sm text-stone-400 italic">
|
||||||
|
Slide viewer — {result.post.slides.length} slide
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user