Files
Momento/memento-note/components/onboarding/onboarding-wizard.tsx
Antigravity 6b4ed8514f
Some checks failed
CI / Lint, Unit Tests & Build (push) Successful in 5m37s
CI / Deploy production (on server) (push) Has been cancelled
Epic 6: Stories 6-2 (Markdown roundtrip) + 6-3 (Brainstorm PPTX + Canvas)
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>
2026-05-29 11:24:56 +00:00

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>
</>
)
}