Story 6-2 — Markdown roundtrip export/import: - lib/editor/markdown-export.ts: tiptapHTMLToMarkdown, markdownToHTML, looksLikeMarkdown - lib/editor/markdown-paste-extension.ts: TipTap extension paste Markdown → blocs - note-editor-toolbar.tsx: export .md + import .md (file picker) - rich-text-editor.tsx: intégration MarkdownPasteExtension - 40 tests unitaires markdown-export.test.ts Story 6-3 — Brainstorm PPTX + Canvas: - lib/brainstorm/export-pptx.ts: génération PPTX 5 slides (pptxgenjs) - app/api/brainstorm/[sessionId]/export-pptx/route.ts: route POST protégée - brainstorm-page.tsx: bouton PPTX, auto-select session, fix emoji, fix router.replace - wave-canvas.tsx: fitTrigger recentrage, légende bas-droite Onboarding activation wizard (Story 6-1): - components/onboarding/: wizard multi-étapes, hints éditeur - app/api/onboarding/: route PATCH onboarding - prisma/migrations: champs onboarding user Locales: 15 langues mises à jour (brainstorm, markdown, onboarding keys) Sprint: 6-1 done, 6-2 review, 6-3 review Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
220 lines
7.6 KiB
TypeScript
220 lines
7.6 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useCallback } from 'react'
|
|
import { motion, AnimatePresence } from 'motion/react'
|
|
import { useSession } from 'next-auth/react'
|
|
import { useRouter } from 'next/navigation'
|
|
import { Sparkles, X } from 'lucide-react'
|
|
import { cn } from '@/lib/utils'
|
|
import { useLanguage } from '@/lib/i18n'
|
|
import { OnboardingStepWelcome } from './onboarding-step-welcome'
|
|
import { OnboardingStepNotes } from './onboarding-step-notes'
|
|
import { OnboardingStepAha } from './onboarding-step-aha'
|
|
import { OnboardingStepFeatures } from './onboarding-step-features'
|
|
|
|
const TOTAL_STEPS = 4
|
|
|
|
async function markOnboardingComplete() {
|
|
const res = await fetch('/api/user/me', {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ onboardingCompleted: true }),
|
|
})
|
|
if (!res.ok) throw new Error('Failed to save onboarding state')
|
|
}
|
|
|
|
async function getNoteCount(): Promise<number> {
|
|
try {
|
|
const res = await fetch('/api/notes?limit=1')
|
|
if (!res.ok) return 0
|
|
const data = await res.json()
|
|
if (data?.data && Array.isArray(data.data)) return data.data.length
|
|
if (Array.isArray(data)) return data.length
|
|
return 0
|
|
} catch {
|
|
return 0
|
|
}
|
|
}
|
|
|
|
export function OnboardingWizard() {
|
|
const { data: session, update: updateSession } = useSession()
|
|
const { language, t } = useLanguage()
|
|
const router = useRouter()
|
|
|
|
const [step, setStep] = useState(1)
|
|
const [visible, setVisible] = useState(false)
|
|
const [minimized, setMinimized] = useState(false)
|
|
const [noteCount, setNoteCount] = useState(0)
|
|
const [demoNotesJustCreated, setDemoNotesJustCreated] = useState(false)
|
|
const [triedCount, setTriedCount] = useState(0)
|
|
|
|
const user = session?.user as any
|
|
|
|
useEffect(() => {
|
|
if (!session) return
|
|
if (user?.onboardingCompleted === false) {
|
|
setVisible(true)
|
|
getNoteCount().then(setNoteCount)
|
|
}
|
|
}, [session, user?.onboardingCompleted])
|
|
|
|
const handleSkip = useCallback(async () => {
|
|
setVisible(false)
|
|
setMinimized(false)
|
|
try {
|
|
await markOnboardingComplete()
|
|
await updateSession({ onboardingCompleted: true })
|
|
} catch (e) {
|
|
console.error('[Onboarding] skip failed:', e)
|
|
}
|
|
}, [updateSession])
|
|
|
|
const handleNext = useCallback(() => {
|
|
setStep(s => Math.min(s + 1, TOTAL_STEPS))
|
|
}, [])
|
|
|
|
const handleNotesNext = useCallback((justCreated: boolean) => {
|
|
setDemoNotesJustCreated(justCreated)
|
|
setStep(s => Math.min(s + 1, TOTAL_STEPS))
|
|
}, [])
|
|
|
|
const handleDone = useCallback(async () => {
|
|
setVisible(false)
|
|
setMinimized(false)
|
|
try {
|
|
await markOnboardingComplete()
|
|
await updateSession({ onboardingCompleted: true })
|
|
} catch (e) {
|
|
console.error('[Onboarding] done failed:', e)
|
|
}
|
|
}, [updateSession])
|
|
|
|
// Called from step 4 when user clicks "Essayer" on a feature
|
|
const handleTry = useCallback((href: string) => {
|
|
setMinimized(true)
|
|
setTriedCount(c => c + 1)
|
|
router.push(href)
|
|
}, [router])
|
|
|
|
if (!visible) return null
|
|
|
|
return (
|
|
<>
|
|
{/* Minimized pill — always rendered when minimized so it shows on any page */}
|
|
<AnimatePresence>
|
|
{minimized && (
|
|
<motion.button
|
|
key="onboarding-pill"
|
|
initial={{ opacity: 0, y: 40, scale: 0.8 }}
|
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
exit={{ opacity: 0, y: 40, scale: 0.8 }}
|
|
transition={{ type: 'spring', stiffness: 400, damping: 28 }}
|
|
onClick={() => setMinimized(false)}
|
|
className="fixed bottom-6 right-6 z-[300] flex items-center gap-2.5 rounded-full bg-violet-600 text-white shadow-lg shadow-violet-500/30 px-4 py-3 text-sm font-medium hover:bg-violet-700 transition-colors"
|
|
>
|
|
<Sparkles className="h-4 w-4" />
|
|
<span>{t('onboarding.pill_resume')}</span>
|
|
{triedCount > 0 && (
|
|
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-white/20 text-xs font-bold">
|
|
{triedCount}
|
|
</span>
|
|
)}
|
|
</motion.button>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Full wizard modal */}
|
|
<AnimatePresence>
|
|
{!minimized && (
|
|
<motion.div
|
|
key="onboarding-overlay"
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
className="fixed inset-0 z-[200] flex items-end justify-center sm:items-center bg-black/50 backdrop-blur-sm p-4"
|
|
>
|
|
<motion.div
|
|
initial={{ y: 40, opacity: 0, scale: 0.97 }}
|
|
animate={{ y: 0, opacity: 1, scale: 1 }}
|
|
exit={{ y: 60, opacity: 0, scale: 0.95 }}
|
|
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
|
className={cn(
|
|
'relative w-full max-w-md rounded-2xl bg-background border border-border shadow-2xl p-8',
|
|
'sm:rounded-2xl rounded-t-2xl rounded-b-none sm:rounded-b-2xl'
|
|
)}
|
|
>
|
|
{/* Progress */}
|
|
<div className="flex flex-col items-center gap-1.5 mb-6">
|
|
<div className="flex items-center gap-2">
|
|
{Array.from({ length: TOTAL_STEPS }).map((_, i) => (
|
|
<motion.div
|
|
key={i}
|
|
animate={{
|
|
width: i + 1 === step ? 24 : 8,
|
|
backgroundColor: i + 1 <= step ? 'var(--brand-accent, #7c3aed)' : 'var(--border)',
|
|
}}
|
|
transition={{ duration: 0.3 }}
|
|
className="h-2 rounded-full bg-border"
|
|
/>
|
|
))}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground/60">
|
|
{t('onboarding.progress', { current: step, total: TOTAL_STEPS })}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Skip / close button (steps 1-3 only, step 4 has its own CTA) */}
|
|
{step < 4 && (
|
|
<button
|
|
onClick={handleSkip}
|
|
className="absolute top-4 right-4 rounded-lg p-1.5 text-muted-foreground/40 hover:text-muted-foreground hover:bg-muted/50 transition-colors"
|
|
aria-label="Fermer"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
)}
|
|
|
|
{/* Step content */}
|
|
<AnimatePresence mode="wait">
|
|
{step === 1 && (
|
|
<OnboardingStepWelcome
|
|
key="step-1"
|
|
onNext={handleNext}
|
|
onSkip={handleSkip}
|
|
userName={user?.name}
|
|
/>
|
|
)}
|
|
{step === 2 && (
|
|
<OnboardingStepNotes
|
|
key="step-2"
|
|
noteCount={noteCount}
|
|
onNext={handleNotesNext}
|
|
onSkip={handleSkip}
|
|
locale={language}
|
|
/>
|
|
)}
|
|
{step === 3 && (
|
|
<OnboardingStepAha
|
|
key="step-3"
|
|
onDone={handleNext}
|
|
locale={language}
|
|
autoSearch={demoNotesJustCreated}
|
|
/>
|
|
)}
|
|
{step === 4 && (
|
|
<OnboardingStepFeatures
|
|
key="step-4"
|
|
onDone={handleDone}
|
|
onTry={handleTry}
|
|
/>
|
|
)}
|
|
</AnimatePresence>
|
|
</motion.div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</>
|
|
)
|
|
}
|