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:
Michele
2026-03-08 02:25:17 +01:00
parent 738a877d39
commit a2ebd72041
4 changed files with 324 additions and 0 deletions

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}