fix: replace raw ai_consent_required code with translatable message and consent prompt
- Centralize AI consent 403 responses in server-consent helpers - Return human-readable message + code + i18n errorKey from all AI APIs - Update dashboard and AI clients to detect code/errorKey and translate - Add consent.ai.requiredToast i18n keys (fr/en)
This commit is contained in:
@@ -100,7 +100,7 @@ export function AiNotebookWizard({ onClose, onComplete }: { onClose: () => void;
|
||||
} else if (data.errorKey === 'ai.quotaExceeded') {
|
||||
toast.error(t('ai.quotaExceeded') || 'Quota IA dépassé')
|
||||
} else {
|
||||
toast.error(data.error || 'Erreur')
|
||||
toast.error(data.errorKey ? t(data.errorKey) : (data.error || 'Erreur'))
|
||||
}
|
||||
setLoading(false)
|
||||
return
|
||||
|
||||
@@ -100,7 +100,7 @@ export function NotebookSiteDialog({ notebookId, notebookName, onClose }: Notebo
|
||||
body: JSON.stringify({ selectedNoteIds: selected, template, description }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) { toast.error(data.error || 'Erreur'); setStep('selection'); return }
|
||||
if (!res.ok) { toast.error(data.errorKey ? t(data.errorKey) : (data.error || 'Erreur')); setStep('selection'); return }
|
||||
setExistingSlug(data.slug)
|
||||
setSiteUrl(data.url)
|
||||
setStep('done')
|
||||
|
||||
237
memento-note/components/wizard/notebook-slides-dialog.tsx
Normal file
237
memento-note/components/wizard/notebook-slides-dialog.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Presentation, X } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { toast } from 'sonner'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
const THEMES = [
|
||||
'auto',
|
||||
'Architectural SaaS',
|
||||
'Midnight Cathedral',
|
||||
'Aurora Borealis',
|
||||
'Tokyo Neon',
|
||||
'Sunlit Gallery',
|
||||
'Clinical Precision',
|
||||
'Venture Pitch',
|
||||
'Forest Floor',
|
||||
'Steel & Glass',
|
||||
'Cyberpunk Terminal',
|
||||
'Editorial Ink',
|
||||
'Coastal Morning',
|
||||
'Paper Studio',
|
||||
]
|
||||
|
||||
const PURPOSES = [
|
||||
{ value: 'auto', key: 'ai.generate.purposeAuto' },
|
||||
{ value: 'course', key: 'ai.generate.purposeCourse' },
|
||||
{ value: 'board', key: 'ai.generate.purposeBoard' },
|
||||
{ value: 'project', key: 'ai.generate.purposeProject' },
|
||||
{ value: 'strategy', key: 'ai.generate.purposeStrategy' },
|
||||
{ value: 'pitch', key: 'ai.generate.purposePitch' },
|
||||
{ value: 'summary', key: 'ai.generate.purposeSummary' },
|
||||
]
|
||||
|
||||
const STATUS_KEYS = [
|
||||
'notebook.slidesStatus1',
|
||||
'notebook.slidesStatus2',
|
||||
'notebook.slidesStatus3',
|
||||
'notebook.slidesStatus4',
|
||||
]
|
||||
|
||||
export function NotebookSlidesDialog({
|
||||
notebookId,
|
||||
notebookName,
|
||||
onClose,
|
||||
}: {
|
||||
notebookId: string
|
||||
notebookName: string
|
||||
onClose: () => void
|
||||
}) {
|
||||
const { t, language } = useLanguage()
|
||||
const router = useRouter()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [theme, setTheme] = useState('auto')
|
||||
const [purpose, setPurpose] = useState('auto')
|
||||
const [progress, setProgress] = useState(0)
|
||||
const timerRef = useRef<ReturnType<typeof setInterval>>(null)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setLoading(true)
|
||||
setProgress(0)
|
||||
|
||||
let elapsed = 0
|
||||
timerRef.current = setInterval(() => {
|
||||
elapsed += 0.6
|
||||
setProgress(() => {
|
||||
const target = 85 * (1 - Math.exp(-elapsed / 15))
|
||||
return Math.min(target, 85)
|
||||
})
|
||||
}, 600)
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/ai/notebook-slides', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
notebookId,
|
||||
theme,
|
||||
purpose,
|
||||
language: language === 'fr' ? 'fr' : 'en',
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
|
||||
if (timerRef.current) clearInterval(timerRef.current)
|
||||
setProgress(100)
|
||||
|
||||
if (!res.ok) {
|
||||
toast.error(
|
||||
data.errorKey === 'ai.featureLocked'
|
||||
? (t('ai.featureLocked') || 'Plan requis')
|
||||
: (data.error || 'Erreur'),
|
||||
)
|
||||
} else if (data.success && data.canvasId) {
|
||||
window.dispatchEvent(new Event('ai-usage-changed'))
|
||||
toast.success(t('notebook.slidesReady') || `Présentation générée (${data.slideCount} slides)`)
|
||||
setTimeout(() => {
|
||||
router.push(`/lab?canvas=${data.canvasId}`)
|
||||
onClose()
|
||||
}, 400)
|
||||
} else {
|
||||
toast.error(data.errorKey ? t(data.errorKey) : (data.error || 'Erreur lors de la génération'))
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (timerRef.current) clearInterval(timerRef.current)
|
||||
toast.error(e.message || 'Erreur')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const statusIdx = Math.min(Math.floor(progress / 22), STATUS_KEYS.length - 1)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[300] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
|
||||
dir="auto"
|
||||
onClick={loading ? undefined : onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-lg rounded-2xl border border-border bg-card shadow-2xl overflow-hidden flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border/50 bg-brand-accent/5 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Presentation className="h-5 w-5 text-brand-accent" />
|
||||
<h2 className="text-base font-semibold">
|
||||
{t('notebook.slides') || 'Présentation'}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
className="p-1 rounded-lg hover:bg-muted text-muted-foreground disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-5">
|
||||
{!loading && (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('notebook.slidesDescription') || `Génère une présentation à partir des notes du carnet « ${notebookName} ».`}
|
||||
</p>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[10px] uppercase tracking-[0.2em] font-bold text-muted-foreground px-1">
|
||||
{t('ai.generate.theme') || 'Thème'}
|
||||
</span>
|
||||
<select
|
||||
value={theme}
|
||||
onChange={(e) => setTheme(e.target.value)}
|
||||
className="w-full bg-card/60 border border-border rounded-lg px-3 py-2 text-sm outline-none focus:ring-1 ring-brand-accent/10 transition-all cursor-pointer text-foreground"
|
||||
>
|
||||
{THEMES.map((th) => (
|
||||
<option key={th} value={th}>
|
||||
{th === 'auto' ? (t('ai.generate.themeAuto') || 'Auto') : th}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[10px] uppercase tracking-[0.2em] font-bold text-muted-foreground px-1">
|
||||
{t('ai.generate.purpose') || 'Type de deck'}
|
||||
</span>
|
||||
<select
|
||||
value={purpose}
|
||||
onChange={(e) => setPurpose(e.target.value)}
|
||||
className="w-full bg-card/60 border border-border rounded-lg px-3 py-2 text-sm outline-none focus:ring-1 ring-brand-accent/10 transition-all cursor-pointer text-foreground"
|
||||
>
|
||||
{PURPOSES.map((p) => (
|
||||
<option key={p.value} value={p.value}>
|
||||
{t(p.key) || p.value}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 text-sm rounded-lg bg-brand-accent text-white hover:bg-brand-accent/90 transition-colors font-medium"
|
||||
>
|
||||
<Presentation className="h-4 w-4" />
|
||||
{t('notebook.slidesGenerate') || 'Générer la présentation'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="space-y-5 py-4">
|
||||
<div className="space-y-2">
|
||||
<div className="w-full h-2.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-brand-accent to-brand-accent/70 rounded-full transition-all duration-700 ease-out"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground animate-pulse">
|
||||
{t(STATUS_KEYS[statusIdx])}
|
||||
</span>
|
||||
<span className="text-xs font-mono font-bold text-brand-accent tabular-nums">
|
||||
{Math.round(progress)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between px-2">
|
||||
{STATUS_KEYS.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`h-1 flex-1 mx-0.5 rounded-full transition-colors duration-300 ${
|
||||
i <= statusIdx ? 'bg-brand-accent' : 'bg-muted'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-muted-foreground/60 text-center">
|
||||
{t('notebook.slidesGeneratingHint') || 'Outline + rédaction des slides'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user