Rendre le dashboard actionnable (inbox, peek, carte mentale), aligner la facturation sur l’essai 7 jours, et bloquer le login e-mail tant que l’adresse n’est pas confirmée. Co-authored-by: Cursor <cursoragent@cursor.com>
413 lines
12 KiB
TypeScript
413 lines
12 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useState } from 'react'
|
||
import { Clapperboard, Loader2, Globe } from 'lucide-react'
|
||
import { toast } from 'sonner'
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from '@/components/ui/dialog'
|
||
import { Button } from '@/components/ui/button'
|
||
import { PageView } from '@/components/interactive-page/page-view'
|
||
import {
|
||
generateInteractivePage,
|
||
generateInteractivePagePlan,
|
||
generateInteractivePageSection,
|
||
type PagePlan,
|
||
} from '@/lib/ai/services/interactive-page-client.service'
|
||
import {
|
||
validateInteractivePage,
|
||
type PageSection,
|
||
type PageSpecV1,
|
||
} from '@/lib/interactive-page'
|
||
import { useLanguage } from '@/lib/i18n'
|
||
|
||
type Phase = 'idle' | 'generating' | 'preview' | 'publishing' | 'error'
|
||
|
||
function slugId(title: string): string {
|
||
const s = title
|
||
.toLowerCase()
|
||
.normalize('NFD')
|
||
.replace(/[\u0300-\u036f]/g, '')
|
||
.replace(/[^a-z0-9]+/g, '-')
|
||
.replace(/^-|-$/g, '')
|
||
.slice(0, 40)
|
||
return s ? `page.${s}` : 'page.generated'
|
||
}
|
||
|
||
/** Degraded section when its LLM call failed — page still completes. */
|
||
function fallbackSection(
|
||
sectionId: string,
|
||
plan: PagePlan['sections'][number],
|
||
lang: string
|
||
): PageSection {
|
||
const fr = lang.startsWith('fr')
|
||
return {
|
||
id: sectionId,
|
||
title: plan.title,
|
||
blocks: [
|
||
{ type: 'prose', md: plan.goal },
|
||
{
|
||
type: 'callout',
|
||
kind: 'note',
|
||
title: fr ? 'En bref' : 'In short',
|
||
md: plan.demoGoal || plan.goal,
|
||
},
|
||
],
|
||
}
|
||
}
|
||
|
||
function assemblePage(
|
||
plan: PagePlan,
|
||
sections: PageSection[],
|
||
lang: string
|
||
): PageSpecV1 {
|
||
return {
|
||
schemaVersion: 1,
|
||
id: slugId(plan.heroTitle),
|
||
lang,
|
||
hero: {
|
||
kicker: lang.startsWith('fr')
|
||
? 'EXPLAINER INTERACTIF'
|
||
: 'INTERACTIVE EXPLAINER',
|
||
title: plan.heroTitle,
|
||
subtitle: plan.heroSubtitle,
|
||
meta: lang.startsWith('fr')
|
||
? 'Généré depuis votre note'
|
||
: 'Generated from your note',
|
||
},
|
||
overview: {
|
||
lead: plan.overviewLead,
|
||
cards: plan.overviewCards.map((c) => ({
|
||
badge: c.badge,
|
||
title: c.title,
|
||
body: c.body,
|
||
|
||
intent: c.intent as any,
|
||
})),
|
||
},
|
||
sections,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Author flow (§8.7): LLM plan → one LLM call per section (progress shown)
|
||
* → validate → preview → publish (pageSpec snapshot, no double quota).
|
||
* Falls back to the deterministic page when the LLM path is unavailable.
|
||
*/
|
||
export function InteractivePagePublishDialog({
|
||
open,
|
||
onOpenChange,
|
||
noteId,
|
||
content,
|
||
language,
|
||
onPublished,
|
||
}: {
|
||
open: boolean
|
||
onOpenChange: (open: boolean) => void
|
||
noteId: string
|
||
content: string
|
||
language: string
|
||
onPublished: (slug: string) => void
|
||
}) {
|
||
const { t } = useLanguage()
|
||
const [phase, setPhase] = useState<Phase>('idle')
|
||
const [page, setPage] = useState<PageSpecV1 | null>(null)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [progress, setProgress] = useState<string | null>(null)
|
||
const [elapsedSec, setElapsedSec] = useState(0)
|
||
|
||
useEffect(() => {
|
||
if (!open || phase !== 'generating') {
|
||
if (!open) setElapsedSec(0)
|
||
return
|
||
}
|
||
const t0 = Date.now()
|
||
const id = window.setInterval(() => {
|
||
setElapsedSec(Math.floor((Date.now() - t0) / 1000))
|
||
}, 500)
|
||
return () => window.clearInterval(id)
|
||
}, [open, phase])
|
||
|
||
useEffect(() => {
|
||
if (!open) {
|
||
setPhase('idle')
|
||
setPage(null)
|
||
setError(null)
|
||
setProgress(null)
|
||
return
|
||
}
|
||
// Guard: empty content from editor race (aligned with API min 40 words)
|
||
const wordCount = content
|
||
.replace(/<[^>]+>/g, ' ')
|
||
.split(/\s+/)
|
||
.filter(Boolean).length
|
||
if (wordCount < 40) {
|
||
setPhase('error')
|
||
setError(
|
||
t('richTextEditor.publishInteractivePageTooShort') ||
|
||
'Note trop courte — ajoutez du contenu puis réessayez'
|
||
)
|
||
return
|
||
}
|
||
|
||
let cancelled = false
|
||
const run = async () => {
|
||
setPhase('generating')
|
||
setError(null)
|
||
setPage(null)
|
||
|
||
const legacyFallback = async (notice?: string) => {
|
||
const result = await generateInteractivePage({
|
||
content,
|
||
lang: language,
|
||
noteId,
|
||
})
|
||
if (cancelled) return
|
||
if (!result.ok) {
|
||
setPhase('error')
|
||
setError(
|
||
result.reason ||
|
||
result.error ||
|
||
t('richTextEditor.publishInteractivePageFailed') ||
|
||
'Échec de la page interactive'
|
||
)
|
||
if (result.quotaExceeded) {
|
||
toast.error(t('ai.quotaExceeded'))
|
||
}
|
||
window.dispatchEvent(new Event('ai-usage-changed'))
|
||
return
|
||
}
|
||
window.dispatchEvent(new Event('ai-usage-changed'))
|
||
if (notice) toast.info(notice)
|
||
setPage(result.page)
|
||
setPhase('preview')
|
||
}
|
||
|
||
// 1. LLM plan (billed)
|
||
setProgress(
|
||
t('richTextEditor.publishInteractivePagePlanning') ||
|
||
'Analyse du contenu — plan de la page…'
|
||
)
|
||
const planResult = await generateInteractivePagePlan({
|
||
content,
|
||
lang: language,
|
||
noteId,
|
||
})
|
||
if (cancelled) return
|
||
if (!planResult.ok) {
|
||
if (planResult.quotaExceeded) {
|
||
setPhase('error')
|
||
setError(planResult.error)
|
||
toast.error(t('ai.quotaExceeded'))
|
||
window.dispatchEvent(new Event('ai-usage-changed'))
|
||
return
|
||
}
|
||
if (planResult.error === 'unsuitable_content') {
|
||
setPhase('error')
|
||
setError(
|
||
planResult.reason ||
|
||
t('richTextEditor.publishInteractivePageFailed') ||
|
||
'Contenu inadapté'
|
||
)
|
||
window.dispatchEvent(new Event('ai-usage-changed'))
|
||
return
|
||
}
|
||
// LLM plan unavailable → deterministic full page
|
||
await legacyFallback(
|
||
t('richTextEditor.publishInteractivePageFallback') ||
|
||
'Génération IA indisponible — page simplifiée affichée'
|
||
)
|
||
return
|
||
}
|
||
window.dispatchEvent(new Event('ai-usage-changed'))
|
||
|
||
// 2. One LLM call per section, with real progress
|
||
const plan = planResult.plan
|
||
const sections: PageSection[] = []
|
||
let degraded = 0
|
||
for (let i = 0; i < plan.sections.length; i++) {
|
||
const planSection = plan.sections[i]
|
||
const sectionId = `s${i + 1}`
|
||
setProgress(
|
||
(
|
||
t('richTextEditor.publishInteractivePageSectionProgress') ||
|
||
'Section {current}/{total} : {title}'
|
||
)
|
||
.replace('{current}', String(i + 1))
|
||
.replace('{total}', String(plan.sections.length))
|
||
.replace('{title}', planSection.title)
|
||
)
|
||
const sectionResult = await generateInteractivePageSection({
|
||
content,
|
||
lang: language,
|
||
noteId,
|
||
pageTitle: plan.heroTitle,
|
||
sectionId,
|
||
section: planSection,
|
||
})
|
||
if (cancelled) return
|
||
if (sectionResult.ok) {
|
||
sections.push(sectionResult.section)
|
||
} else {
|
||
degraded += 1
|
||
sections.push(fallbackSection(sectionId, planSection, language))
|
||
}
|
||
}
|
||
|
||
// 3. Assemble + hard validation client-side
|
||
const candidate = assemblePage(plan, sections, language)
|
||
const validated = validateInteractivePage(candidate)
|
||
if (!validated.ok) {
|
||
await legacyFallback(
|
||
t('richTextEditor.publishInteractivePageFallback') ||
|
||
'Génération IA indisponible — page simplifiée affichée'
|
||
)
|
||
return
|
||
}
|
||
if (degraded > 0) {
|
||
toast.info(
|
||
t('richTextEditor.publishInteractivePagePartialFallback') ||
|
||
'Certaines sections ont été générées en mode simplifié'
|
||
)
|
||
}
|
||
setPage(validated.page)
|
||
setPhase('preview')
|
||
}
|
||
void run()
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
// Intentionally omit `t` — unstable identity cancels in-flight generation
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [open, content, language, noteId])
|
||
|
||
const handlePublish = async () => {
|
||
if (!page || phase === 'publishing') return
|
||
setPhase('publishing')
|
||
try {
|
||
const res = await fetch('/api/notes/publish', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
noteId,
|
||
action: 'publish',
|
||
mode: 'interactive-page',
|
||
template: 'interactive-page',
|
||
language,
|
||
pageSpec: page,
|
||
}),
|
||
})
|
||
const data = await res.json()
|
||
if (!res.ok) {
|
||
toast.error(
|
||
data.reason ||
|
||
data.error ||
|
||
t('richTextEditor.publishInteractivePageFailed') ||
|
||
'Échec de la publication'
|
||
)
|
||
setPhase('preview')
|
||
return
|
||
}
|
||
toast.success(
|
||
t('richTextEditor.publishInteractivePageSuccess') ||
|
||
'Page interactive publiée !'
|
||
)
|
||
onPublished(data.slug)
|
||
onOpenChange(false)
|
||
} catch {
|
||
toast.error(
|
||
t('richTextEditor.publishInteractivePageFailed') ||
|
||
'Échec de la publication'
|
||
)
|
||
setPhase('preview')
|
||
}
|
||
}
|
||
|
||
const generatingHint =
|
||
progress ||
|
||
(elapsedSec < 15
|
||
? t('richTextEditor.publishInteractivePageGenerating') ||
|
||
'Génération de la page…'
|
||
: elapsedSec < 40
|
||
? t('richTextEditor.publishInteractivePageGeneratingWait') ||
|
||
'Construction des sections et démos…'
|
||
: t('richTextEditor.publishInteractivePageGeneratingLong') ||
|
||
'Encore un instant — au-delà de ~90 s, annulez et réessayez')
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogContent className="flex max-h-[92vh] w-[min(1100px,96vw)] max-w-none flex-col gap-0 overflow-hidden p-0 sm:max-w-none">
|
||
<DialogHeader className="shrink-0 border-b border-border px-5 py-4">
|
||
<DialogTitle className="flex items-center gap-2 text-base">
|
||
<Clapperboard className="h-4 w-4 text-brand-accent" />
|
||
{t('richTextEditor.publishInteractivePage') ||
|
||
'Page interactive'}
|
||
</DialogTitle>
|
||
<DialogDescription className="text-xs">
|
||
{phase === 'generating'
|
||
? generatingHint
|
||
: t('richTextEditor.publishInteractivePagePreviewHint') ||
|
||
'Aperçu — vérifiez puis publiez sur l’URL publique'}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div className="min-h-0 flex-1 overflow-y-auto bg-background">
|
||
{phase === 'generating' ? (
|
||
<div className="flex flex-col items-center justify-center gap-3 py-24 text-sm text-muted-foreground">
|
||
<Loader2 className="h-6 w-6 animate-spin text-brand-accent" />
|
||
<p>{generatingHint}</p>
|
||
<p className="font-mono text-xs tabular-nums text-muted-foreground/80">
|
||
{elapsedSec}s
|
||
</p>
|
||
</div>
|
||
) : null}
|
||
|
||
{phase === 'error' ? (
|
||
<div className="mx-auto max-w-lg px-6 py-16 text-sm text-destructive">
|
||
<p className="font-medium">
|
||
{t('richTextEditor.publishInteractivePageFailed') ||
|
||
'Échec de la page interactive'}
|
||
</p>
|
||
<p className="mt-2 text-muted-foreground">{error}</p>
|
||
</div>
|
||
) : null}
|
||
|
||
{phase === 'preview' || phase === 'publishing' ? (
|
||
page ? <PageView page={page} demoMode="interactive" /> : null
|
||
) : null}
|
||
</div>
|
||
|
||
<DialogFooter className="shrink-0 border-t border-border px-5 py-3 sm:justify-between">
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
onClick={() => onOpenChange(false)}
|
||
disabled={phase === 'publishing'}
|
||
>
|
||
{t('general.cancel') || 'Annuler'}
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
onClick={handlePublish}
|
||
disabled={phase !== 'preview' || !page}
|
||
className="gap-2"
|
||
>
|
||
{phase === 'publishing' ? (
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
) : (
|
||
<Globe className="h-4 w-4" />
|
||
)}
|
||
{t('richTextEditor.publishInteractivePageConfirm') ||
|
||
'Publier la page'}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
)
|
||
}
|