Files
postgenerator/frontend/src/pages/GenerateCalendar.tsx
Michele a5d1c15c3a feat(01-04): pagine GenerateCalendar, OutputReview, GenerateSingle complete
- GenerateCalendar.tsx: form con obiettivo+settimane+brand+tono+nicchie
  - Pulsante Genera disabilitato senza API key con banner link a Impostazioni
  - Async submit: mutation ritorna job_id, mostra ProgressIndicator
  - Auto-navigate a /risultati/:jobId quando job completato
- OutputReview.tsx: carica job results via useJobResults(jobId)
  - Griglia PostCard responsive con conteggio success/failed
  - Stato locale per edit inline (aggiornato da PostCard.onEdit)
  - Download CSV via useDownloadEditedCsv (POST con edits) con due pulsanti (header + footer)
- GenerateSingle.tsx: form con tipo PN, livello Schwartz, nicchia, formato narrativo
  - Topic opzionale (altrimenti generato dall'AI)
  - Anteprima risultato con PostCard+SlideViewer e download CSV singolo
2026-03-08 02:29:17 +01:00

248 lines
9.8 KiB
TypeScript

/**
* Pagina Genera Calendario.
*
* Flusso:
* 1. Utente compila form (obiettivo + settimane)
* 2. Submit chiama POST /api/generate/bulk → ritorna {job_id} (202 Accepted)
* 3. Mostra ProgressIndicator con polling ogni 2s su job_id
* 4. Quando il job completa → navigate a /risultati/:jobId
*
* Il pulsante Genera è disabilitato se API key non configurata.
*/
import { AlertTriangle, Loader2 } from 'lucide-react'
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useGenerateCalendar, useSettings, useSettingsStatus } from '../api/hooks'
import { ProgressIndicator } from '../components/ProgressIndicator'
import type { CalendarRequest } from '../types'
export function GenerateCalendar() {
const navigate = useNavigate()
const { data: status } = useSettingsStatus()
const { data: settings } = useSettings()
const generateMutation = useGenerateCalendar()
const apiKeyOk = status?.api_key_configured ?? false
// Stato del form
const [obiettivo, setObiettivo] = useState('')
const [settimane, setSettimane] = useState(2)
const [brandName, setBrandName] = useState('')
const [tono, setTono] = useState('')
const [customNicchie, setCustomNicchie] = useState(false)
// Job ID quando la generazione parte
const [jobId, setJobId] = useState<string | null>(null)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!apiKeyOk || !obiettivo.trim()) return
const req: CalendarRequest = {
obiettivo_campagna: obiettivo.trim(),
settimane,
frequenza_post: settings?.frequenza_post ?? 3,
nicchie: customNicchie ? settings?.nicchie_attive : undefined,
}
try {
const res = await generateMutation.mutateAsync(req)
setJobId(res.job_id)
} catch {
// Errore gestito da generateMutation.error
}
}
function handleComplete(completedJobId: string) {
navigate(`/risultati/${completedJobId}`)
}
// ---------------------------------------------------------------------------
// Se il job è stato avviato: mostra progress indicator
// ---------------------------------------------------------------------------
if (jobId) {
return (
<div className="max-w-2xl mx-auto px-6 py-10 space-y-6">
<div>
<h1 className="text-2xl font-bold text-stone-100">Generazione in corso</h1>
<p className="mt-1 text-stone-400 text-sm">
Claude sta generando i 13 caroselli Instagram. Ci vorranno alcuni minuti.
</p>
</div>
<div className="rounded-xl border border-stone-700 bg-stone-800 px-5 py-5">
<ProgressIndicator jobId={jobId} onComplete={handleComplete} />
</div>
<p className="text-xs text-stone-600 text-center">
Non chiudere questa pagina. Job ID: <span className="font-mono">{jobId}</span>
</p>
</div>
)
}
// ---------------------------------------------------------------------------
// Form generazione
// ---------------------------------------------------------------------------
return (
<div className="max-w-2xl mx-auto px-6 py-10 space-y-8">
<div>
<h1 className="text-2xl font-bold text-stone-100">Genera Calendario</h1>
<p className="mt-1 text-stone-400 text-sm">
Genera 13 caroselli Instagram strategici basati sul framework Persuasion Nurturing e Schwartz.
</p>
</div>
{/* Banner API key mancante */}
{!apiKeyOk && (
<div className="flex items-start gap-3 px-4 py-3 rounded-lg bg-amber-500/10 border border-amber-500/30">
<AlertTriangle size={18} className="text-amber-400 flex-shrink-0 mt-0.5" />
<div className="text-sm">
<p className="text-amber-300 font-medium">API key Claude non configurata</p>
<p className="text-stone-400 mt-0.5">
<Link to="/impostazioni" className="text-amber-400 underline underline-offset-2">
Configura la tua API key nelle Impostazioni
</Link>{' '}
per abilitare la generazione.
</p>
</div>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Obiettivo campagna */}
<div className="space-y-1.5">
<label className="block text-sm font-medium text-stone-300">
Obiettivo campagna <span className="text-red-400">*</span>
</label>
<textarea
value={obiettivo}
onChange={(e) => setObiettivo(e.target.value)}
rows={3}
required
minLength={10}
disabled={!apiKeyOk}
placeholder="Es. Acquisire nuovi clienti dentisti nel Nord Italia che vogliono modernizzare il loro studio"
className="w-full px-3 py-2 rounded-lg bg-stone-800 border border-stone-700 text-stone-100 text-sm placeholder-stone-600 focus:outline-none focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500/50 disabled:opacity-50 disabled:cursor-not-allowed resize-none"
/>
<p className="text-xs text-stone-600">
Descrivi il target e l'obiettivo principale (min 10 caratteri).
</p>
</div>
{/* Settimane */}
<div className="space-y-1.5">
<label className="block text-sm font-medium text-stone-300">Durata ciclo</label>
<div className="flex items-center gap-3">
<input
type="number"
value={settimane}
onChange={(e) => setSettimane(Math.max(1, Math.min(12, parseInt(e.target.value) || 2)))}
min={1}
max={12}
disabled={!apiKeyOk}
className="w-20 px-3 py-2 rounded-lg bg-stone-800 border border-stone-700 text-stone-100 text-sm focus:outline-none focus:ring-2 focus:ring-amber-500/50 disabled:opacity-50"
/>
<span className="text-sm text-stone-400">settimane</span>
<span className="text-xs text-stone-600">
({Math.ceil(settimane * (settings?.frequenza_post ?? 3))} post stimati, genera 13)
</span>
</div>
</div>
{/* Brand name (opzionale) */}
<div className="space-y-1.5">
<label className="block text-sm font-medium text-stone-300">
Brand / Studio{' '}
<span className="text-stone-600 font-normal">(opzionale — sovrascrive impostazioni)</span>
</label>
<input
type="text"
value={brandName}
onChange={(e) => setBrandName(e.target.value)}
disabled={!apiKeyOk}
placeholder={settings?.brand_name ?? 'Es. Studio Bianchi & Associati'}
className="w-full px-3 py-2 rounded-lg bg-stone-800 border border-stone-700 text-stone-100 text-sm placeholder-stone-600 focus:outline-none focus:ring-2 focus:ring-amber-500/50 disabled:opacity-50"
/>
</div>
{/* Tono (opzionale) */}
<div className="space-y-1.5">
<label className="block text-sm font-medium text-stone-300">
Tono di voce{' '}
<span className="text-stone-600 font-normal">(opzionale)</span>
</label>
<input
type="text"
value={tono}
onChange={(e) => setTono(e.target.value)}
disabled={!apiKeyOk}
placeholder={settings?.tono ?? 'Es. diretto e concreto'}
className="w-full px-3 py-2 rounded-lg bg-stone-800 border border-stone-700 text-stone-100 text-sm placeholder-stone-600 focus:outline-none focus:ring-2 focus:ring-amber-500/50 disabled:opacity-50"
/>
</div>
{/* Nicchie */}
<div className="space-y-1.5">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={customNicchie}
onChange={(e) => setCustomNicchie(e.target.checked)}
disabled={!apiKeyOk}
className="rounded border-stone-600"
/>
<span className="text-sm font-medium text-stone-300">
Usa nicchie da Impostazioni
</span>
</label>
{customNicchie && settings?.nicchie_attive && (
<div className="flex flex-wrap gap-1.5 pl-5">
{settings.nicchie_attive.map((n) => (
<span key={n} className="text-xs px-2 py-0.5 rounded-full bg-stone-700 text-stone-400 border border-stone-600">
{n}
</span>
))}
</div>
)}
{!customNicchie && (
<p className="text-xs text-stone-600 pl-5">
Verranno usate le nicchie default: generico, dentisti, avvocati, ecommerce, local_business, agenzie.
</p>
)}
</div>
{/* Errore */}
{generateMutation.error && (
<div className="px-4 py-3 rounded-lg bg-red-500/10 border border-red-500/30 text-sm text-red-400">
{generateMutation.error.message}
</div>
)}
{/* Submit */}
<div className="pt-2">
<button
type="submit"
disabled={!apiKeyOk || !obiettivo.trim() || generateMutation.isPending}
className="flex items-center gap-2 px-6 py-3 rounded-lg bg-amber-500 text-stone-950 text-sm font-semibold hover:bg-amber-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{generateMutation.isPending && <Loader2 size={15} className="animate-spin" />}
{generateMutation.isPending ? 'Avvio generazione...' : 'Genera Calendario'}
</button>
{!apiKeyOk && (
<p className="mt-2 text-xs text-stone-600">
Pulsante disabilitato — API key non configurata.{' '}
<Link to="/impostazioni" className="text-amber-400">Vai alle Impostazioni</Link>
</p>
)}
</div>
</form>
</div>
)
}