From a2ebd720416e0742d69262c73c9b1c67ff99180e Mon Sep 17 00:00:00 2001 From: Michele Date: Sun, 8 Mar 2026 02:25:17 +0100 Subject: [PATCH] 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 --- frontend/src/components/BadgePN.tsx | 59 +++++++ frontend/src/components/BadgeSchwartz.tsx | 65 ++++++++ frontend/src/components/PostCard.tsx | 180 ++++++++++++++++++++++ frontend/src/components/SlideViewer.tsx | 20 +++ 4 files changed, 324 insertions(+) create mode 100644 frontend/src/components/BadgePN.tsx create mode 100644 frontend/src/components/BadgeSchwartz.tsx create mode 100644 frontend/src/components/PostCard.tsx create mode 100644 frontend/src/components/SlideViewer.tsx diff --git a/frontend/src/components/BadgePN.tsx b/frontend/src/components/BadgePN.tsx new file mode 100644 index 0000000..5179c8f --- /dev/null +++ b/frontend/src/components/BadgePN.tsx @@ -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 = { + 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 ( + + {config.label} + + ) +} diff --git a/frontend/src/components/BadgeSchwartz.tsx b/frontend/src/components/BadgeSchwartz.tsx new file mode 100644 index 0000000..6d11332 --- /dev/null +++ b/frontend/src/components/BadgeSchwartz.tsx @@ -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 = { + 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 ( + + {config.label} + + ) +} diff --git a/frontend/src/components/PostCard.tsx b/frontend/src/components/PostCard.tsx new file mode 100644 index 0000000..73ae9da --- /dev/null +++ b/frontend/src/components/PostCard.tsx @@ -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 ( +
+ +

Post {result.slot_index + 1} — in attesa...

+
+ ) + } + + // ----- FAILED ----- + if (result.status === 'failed') { + return ( +
+
+ +
+

+ Post {result.slot_index + 1} — Generazione fallita +

+ {result.error && ( +

{result.error}

+ )} + {slot && ( +
+ + + {slot.target_nicchia} +
+ )} +
+
+ + {generateSingle.error && ( +

{generateSingle.error.message}

+ )} +
+ ) + } + + // ----- SUCCESS ----- + if (!post) return null + + return ( +
+ {/* Header card — sempre visibile, click per espandere */} + + + {/* Expanded: SlideViewer */} + {expanded && ( +
+ +
+ )} +
+ ) +} diff --git a/frontend/src/components/SlideViewer.tsx b/frontend/src/components/SlideViewer.tsx new file mode 100644 index 0000000..258b8a1 --- /dev/null +++ b/frontend/src/components/SlideViewer.tsx @@ -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 ( +
+ Slide viewer — {result.post.slides.length} slide +
+ ) +}