Epic 6: Stories 6-2 (Markdown roundtrip) + 6-3 (Brainstorm PPTX + Canvas)
Some checks failed
CI / Lint, Unit Tests & Build (push) Successful in 5m37s
CI / Deploy production (on server) (push) Has been cancelled

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>
This commit is contained in:
Antigravity
2026-05-29 11:24:56 +00:00
parent dae56187fc
commit 6b4ed8514f
49 changed files with 5215 additions and 66 deletions

View File

@@ -0,0 +1,232 @@
'use client'
import { useState, useEffect } from 'react'
import { motion, AnimatePresence } from 'motion/react'
import { useSession } from 'next-auth/react'
import { usePathname } from 'next/navigation'
import {
Slash, Sparkles, History, GraduationCap, Link2,
PenLine, FlipVertical, Keyboard, Lightbulb, ArrowRight,
FileDown, Network, Zap, RefreshCw,
X, ChevronLeft, ChevronRight,
} from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import type { LucideIcon } from 'lucide-react'
// ── Types ──────────────────────────────────────────────────────────────────
interface HintDef {
icon: LucideIcon
color: string
bg: string
key: string
}
interface RouteHintSet {
hints: HintDef[]
storageKey: string
}
// ── Hint definitions per route ─────────────────────────────────────────────
// Every item here maps to a real, verified UI element or gesture in the codebase.
const ROUTE_HINTS: Record<string, RouteHintSet> = {
'/home': {
storageKey: 'momento_hints_home',
hints: [
{ icon: PenLine, color: 'text-violet-500', bg: 'bg-violet-500/10', key: 'create_note' },
{ icon: Slash, color: 'text-indigo-500', bg: 'bg-indigo-500/10', key: 'slash' },
{ icon: Sparkles, color: 'text-amber-500', bg: 'bg-amber-500/10', key: 'ai' },
{ icon: History, color: 'text-blue-500', bg: 'bg-blue-500/10', key: 'version' },
{ icon: Link2, color: 'text-rose-500', bg: 'bg-rose-500/10', key: 'links' },
{ icon: GraduationCap, color: 'text-emerald-500',bg: 'bg-emerald-500/10',key: 'flashcards' },
],
},
'/revision': {
storageKey: 'momento_hints_revision',
hints: [
{ icon: FlipVertical, color: 'text-violet-500', bg: 'bg-violet-500/10', key: 'flip' },
{ icon: Keyboard, color: 'text-blue-500', bg: 'bg-blue-500/10', key: 'rate_keys' },
{ icon: GraduationCap,color: 'text-emerald-500',bg: 'bg-emerald-500/10',key: 'generate_from_note' },
],
},
'/brainstorm': {
storageKey: 'momento_hints_brainstorm',
hints: [
{ icon: Lightbulb, color: 'text-amber-500', bg: 'bg-amber-500/10', key: 'brainstorm_start' },
{ icon: ArrowRight, color: 'text-violet-500',bg: 'bg-violet-500/10', key: 'brainstorm_deepen' },
{ icon: FileDown, color: 'text-blue-500', bg: 'bg-blue-500/10', key: 'brainstorm_export' },
],
},
'/insights': {
storageKey: 'momento_hints_insights',
hints: [
{ icon: Network, color: 'text-violet-500', bg: 'bg-violet-500/10', key: 'insights_clusters' },
{ icon: Zap, color: 'text-amber-500', bg: 'bg-amber-500/10', key: 'insights_bridge' },
{ icon: RefreshCw, color: 'text-blue-500', bg: 'bg-blue-500/10', key: 'insights_refresh' },
],
},
}
function getRouteSet(pathname: string): RouteHintSet | null {
// Exact match first
if (ROUTE_HINTS[pathname]) return ROUTE_HINTS[pathname]
// Prefix match (e.g. /brainstorm?session=xxx)
for (const route of Object.keys(ROUTE_HINTS)) {
if (pathname.startsWith(route)) return ROUTE_HINTS[route]
}
return null
}
// ── Component ──────────────────────────────────────────────────────────────
export function OnboardingEditorHints() {
const { data: session } = useSession()
const pathname = usePathname()
const { t } = useLanguage()
const [visible, setVisible] = useState(false)
const [index, setIndex] = useState(0)
const [routeKey, setRouteKey] = useState<string | null>(null)
const user = session?.user as any
// When route changes, check if we should show hints for the new page
useEffect(() => {
if (!session) return
if (user?.onboardingCompleted !== false) return
const routeSet = getRouteSet(pathname)
if (!routeSet) {
setVisible(false)
return
}
if (typeof window !== 'undefined' && localStorage.getItem(routeSet.storageKey)) {
setVisible(false)
return
}
// Reset to first hint when page changes
setIndex(0)
setRouteKey(pathname)
const timer = setTimeout(() => setVisible(true), 900)
return () => clearTimeout(timer)
}, [pathname, session, user?.onboardingCompleted])
// Hide when hints change (route switch)
useEffect(() => {
if (routeKey !== null && routeKey !== pathname) {
setVisible(false)
}
}, [pathname, routeKey])
function dismiss() {
const routeSet = getRouteSet(pathname)
if (routeSet && typeof window !== 'undefined') {
localStorage.setItem(routeSet.storageKey, '1')
}
setVisible(false)
}
const routeSet = getRouteSet(pathname)
if (!routeSet) return null
const hints = routeSet.hints
const hint = hints[Math.min(index, hints.length - 1)]
const Icon = hint.icon
return (
<AnimatePresence>
{visible && (
<motion.div
key={`hints-${pathname}`}
initial={{ opacity: 0, x: 60 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 60 }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
className="fixed bottom-24 right-4 z-[150] w-72 rounded-2xl border border-border bg-background shadow-xl"
>
{/* Header */}
<div className="flex items-center justify-between px-4 pt-3 pb-2 border-b border-border/50">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
{t('onboarding.editor_hints_title')}
</p>
<button
onClick={dismiss}
className="rounded p-0.5 text-muted-foreground/40 hover:text-muted-foreground transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
{/* Hint content */}
<AnimatePresence mode="wait">
<motion.div
key={hint.key}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.18 }}
className="px-4 py-3 flex items-start gap-3"
>
<span className={`mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${hint.bg} ${hint.color}`}>
<Icon className="h-4 w-4" />
</span>
<div>
<p className="text-sm font-semibold text-foreground">
{t(`onboarding.hint_${hint.key}_title`)}
</p>
<p className="text-xs text-muted-foreground mt-0.5 leading-relaxed">
{t(`onboarding.hint_${hint.key}_desc`)}
</p>
</div>
</motion.div>
</AnimatePresence>
{/* Navigation */}
<div className="flex items-center justify-between px-4 pb-3">
{/* Dots */}
<div className="flex gap-1">
{hints.map((_, i) => (
<button
key={i}
onClick={() => setIndex(i)}
className={`h-1.5 rounded-full transition-all ${
i === index ? 'w-4 bg-violet-500' : 'w-1.5 bg-border'
}`}
/>
))}
</div>
{/* Arrows + dismiss */}
<div className="flex items-center gap-1">
<button
onClick={() => setIndex(i => Math.max(0, i - 1))}
disabled={index === 0}
className="flex h-6 w-6 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted disabled:opacity-30 transition-colors"
>
<ChevronLeft className="h-3.5 w-3.5" />
</button>
{index < hints.length - 1 ? (
<button
onClick={() => setIndex(i => i + 1)}
className="flex h-6 w-6 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
<ChevronRight className="h-3.5 w-3.5" />
</button>
) : (
<button
onClick={dismiss}
className="flex items-center gap-1 text-xs font-medium px-2.5 h-6 rounded-lg bg-violet-500/10 text-violet-500 hover:bg-violet-500/20 transition-colors"
>
{t('onboarding.editor_hints_got_it')}
</button>
)}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
)
}

View File

@@ -0,0 +1,202 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import { motion, AnimatePresence } from 'motion/react'
import { Search, Sparkles, ArrowRight } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useLanguage } from '@/lib/i18n'
import { semanticSearch } from '@/app/actions/semantic-search'
import confetti from 'canvas-confetti'
interface SearchResult { id: string; title: string | null; snippet?: string }
interface Props {
onDone: () => void
locale: string
autoSearch?: boolean
}
const SEARCH_PREFILL: Record<string, string> = {
fr: 'notes sur ma productivité',
en: 'notes about productivity',
fa: 'یادداشت‌های بهره‌وری',
ar: 'ملاحظات حول الإنتاجية',
de: 'Notizen zur Produktivität',
es: 'notas sobre productividad',
it: 'note sulla produttività',
pt: 'notas sobre produtividade',
ru: 'заметки о продуктивности',
zh: '关于生产力的笔记',
ja: '生産性に関するメモ',
ko: '생산성에 관한 노트',
nl: 'notities over productiviteit',
pl: 'notatki o produktywności',
hi: 'उत्पादकता पर नोट्स',
}
function fireConfetti() {
const end = Date.now() + 800
const colors = ['#7c3aed', '#a78bfa', '#fbbf24', '#34d399']
const frame = () => {
confetti({ particleCount: 3, angle: 60, spread: 55, origin: { x: 0 }, colors })
confetti({ particleCount: 3, angle: 120, spread: 55, origin: { x: 1 }, colors })
if (Date.now() < end) requestAnimationFrame(frame)
}
frame()
}
export function OnboardingStepAha({ onDone, locale, autoSearch = false }: Props) {
const { t } = useLanguage()
const lang = locale.split('-')[0].toLowerCase()
const prefill = SEARCH_PREFILL[lang] ?? SEARCH_PREFILL.en
const [query, setQuery] = useState(prefill)
const [results, setResults] = useState<SearchResult[] | null>(null)
const [loading, setLoading] = useState(false)
const [searched, setSearched] = useState(false)
const [quotaExceeded, setQuotaExceeded] = useState(false)
const autoSearched = useRef(false)
async function handleSearch() {
const q = query.trim()
if (!q) return
setLoading(true)
setQuotaExceeded(false)
try {
const res = await semanticSearch(q, { limit: 5 })
const hits = res.results.map(r => ({ id: r.noteId, title: r.title, snippet: r.content?.slice(0, 80) }))
setResults(hits)
setSearched(true)
if (hits.length > 0) {
setTimeout(fireConfetti, 200)
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message.toLowerCase() : ''
if (msg.includes('quota') || msg.includes('limit') || msg.includes('exceeded') || msg.includes('upgrade')) {
setQuotaExceeded(true)
}
setResults([])
setSearched(true)
} finally {
setLoading(false)
}
}
useEffect(() => {
if (!autoSearch || autoSearched.current) return
autoSearched.current = true
const timer = setTimeout(() => { void handleSearch() }, 800)
return () => clearTimeout(timer)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoSearch])
return (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -16 }}
className="flex flex-col items-center gap-5 w-full"
>
<motion.div
initial={{ scale: 0.7, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.1, type: 'spring', stiffness: 200 }}
className="flex h-20 w-20 items-center justify-center rounded-2xl bg-violet-500/10 text-violet-500"
>
<Sparkles className="h-10 w-10" />
</motion.div>
<div className="space-y-2 text-center">
<h2 className="text-2xl font-bold tracking-tight text-foreground">
{t('onboarding.step_aha_title')}
</h2>
<p className="text-base text-muted-foreground max-w-xs mx-auto">
{t('onboarding.step_aha_subtitle')}
</p>
</div>
<motion.div
animate={searched ? {} : { boxShadow: ['0 0 0 0 rgba(139,92,246,0)', '0 0 0 6px rgba(139,92,246,0.15)', '0 0 0 0 rgba(139,92,246,0)'] }}
transition={{ duration: 2, repeat: searched ? 0 : Infinity }}
className="flex w-full max-w-sm rounded-xl border border-border bg-background overflow-hidden shadow-sm"
>
<input
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSearch()}
className="flex-1 px-4 py-3 text-sm bg-transparent outline-none placeholder:text-muted-foreground"
dir={['fa', 'ar'].includes(lang) ? 'rtl' : 'ltr'}
aria-label={t('onboarding.step_aha_search_aria')}
/>
<button
onClick={handleSearch}
disabled={loading || !query.trim()}
aria-label={t('onboarding.step_aha_search_button')}
className="flex items-center gap-1.5 px-4 text-sm font-medium text-muted-foreground hover:text-violet-500 transition-colors disabled:opacity-50"
>
{loading
? <span className="h-4 w-4 animate-spin border-2 border-violet-500/30 border-t-violet-500 rounded-full block" />
: <Search className="h-4 w-4" />
}
<span className="hidden sm:inline">{t('onboarding.step_aha_search_button')}</span>
</button>
</motion.div>
<AnimatePresence>
{searched && results !== null && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
className="w-full max-w-sm space-y-2"
>
{quotaExceeded ? (
<p className="text-sm text-center text-destructive py-2">
{t('onboarding.quota_exceeded')}
</p>
) : results.length === 0 ? (
<p className="text-sm text-center text-muted-foreground py-2">
{t('onboarding.no_results')}
</p>
) : (
<>
{results.slice(0, 3).map((r, i) => (
<motion.div
key={r.id}
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.07 }}
className="rounded-lg border border-border bg-muted/30 px-3 py-2"
>
<p className="text-sm font-medium text-foreground truncate">{r.title ?? 'Untitled'}</p>
{r.snippet && (
<p className="text-xs text-muted-foreground truncate mt-0.5">{r.snippet}</p>
)}
</motion.div>
))}
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
className="flex items-center justify-center gap-1 text-xs text-violet-500 pt-1"
>
<span></span>
<span>{t('onboarding.search_credit_used')}</span>
</motion.p>
</>
)}
</motion.div>
)}
</AnimatePresence>
<Button
onClick={onDone}
size="lg"
className="w-full max-w-sm gap-2"
>
{t('onboarding.step_aha_cta')}
<ArrowRight className="h-4 w-4" />
</Button>
</motion.div>
)
}

View File

@@ -0,0 +1,116 @@
'use client'
import { useState } from 'react'
import { motion, AnimatePresence } from 'motion/react'
import { Pencil, CreditCard, Lightbulb, Check, ArrowRight, Sparkles } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useLanguage } from '@/lib/i18n'
interface Props {
onDone: () => void
onTry: (href: string) => void
}
const ACTIONS = [
{ icon: Pencil, color: 'text-violet-500', bg: 'bg-violet-500/10', key: 'write', href: '/home' },
{ icon: CreditCard, color: 'text-blue-500', bg: 'bg-blue-500/10', key: 'flashcards', href: '/revision' },
{ icon: Lightbulb, color: 'text-amber-500', bg: 'bg-amber-500/10', key: 'brainstorm', href: '/brainstorm' },
]
export function OnboardingStepFeatures({ onDone, onTry }: Props) {
const { t } = useLanguage()
const [checked, setChecked] = useState<Set<string>>(new Set())
function handleTry(key: string, href: string) {
setChecked(prev => new Set([...prev, key]))
onTry(href)
}
const allChecked = checked.size === ACTIONS.length
return (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -16 }}
className="flex flex-col items-center gap-5 w-full"
>
<motion.div
initial={{ scale: 0.7, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.1, type: 'spring', stiffness: 200 }}
className="flex h-20 w-20 items-center justify-center rounded-2xl bg-amber-500/10 text-amber-500"
>
<Sparkles className="h-10 w-10" />
</motion.div>
<div className="space-y-1 text-center">
<h2 className="text-2xl font-bold tracking-tight text-foreground">
{t('onboarding.step_features_title')}
</h2>
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
{t('onboarding.step_features_subtitle')}
</p>
</div>
<div className="flex flex-col gap-2.5 w-full">
{ACTIONS.map(({ icon: Icon, color, bg, key, href }, i) => {
const done = checked.has(key)
return (
<motion.div
key={key}
initial={{ opacity: 0, x: -12 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.08 + i * 0.08 }}
className={`flex items-center gap-3 rounded-xl border p-3 transition-all ${
done ? 'border-emerald-500/30 bg-emerald-500/5' : 'border-border bg-muted/10 hover:border-border/80'
}`}
>
{/* Checkmark */}
<span className={`shrink-0 flex h-5 w-5 items-center justify-center rounded-full border-2 transition-colors ${
done ? 'border-emerald-500 bg-emerald-500' : 'border-border'
}`}>
<AnimatePresence>
{done && (
<motion.span initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}>
<Check className="h-3 w-3 text-white" />
</motion.span>
)}
</AnimatePresence>
</span>
{/* Icon */}
<span className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${bg} ${color}`}>
<Icon className="h-4 w-4" />
</span>
{/* Text */}
<div className="flex-1 min-w-0">
<p className={`text-sm font-medium leading-tight ${done ? 'text-muted-foreground' : 'text-foreground'}`}>
{t(`onboarding.action_${key}_title`)}
</p>
<p className="text-xs text-muted-foreground leading-tight mt-0.5">
{t(`onboarding.action_${key}_desc`)}
</p>
</div>
{/* Essayer — minimise wizard + navigue */}
<button
onClick={() => handleTry(key, href)}
className={`shrink-0 flex items-center gap-1 text-xs font-medium px-2.5 py-1.5 rounded-lg transition-opacity ${color} ${bg} ${done ? 'opacity-40' : 'hover:opacity-80'}`}
>
{done ? t('onboarding.action_done') : t('onboarding.action_try')}
{!done && <ArrowRight className="h-3 w-3" />}
</button>
</motion.div>
)
})}
</div>
<Button onClick={onDone} size="lg" className="w-full gap-2 mt-1">
{allChecked ? t('onboarding.step_features_cta_all') : t('onboarding.step_features_cta')}
<ArrowRight className="h-4 w-4" />
</Button>
</motion.div>
)
}

View File

@@ -0,0 +1,203 @@
'use client'
import { useRef, useState } from 'react'
import { motion } from 'motion/react'
import { FileText, Upload, Sparkles, CheckCircle, AlertCircle } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useLanguage } from '@/lib/i18n'
import { markdownToHTML, extractMarkdownTitle } from '@/lib/editor/markdown-export'
interface Props {
noteCount: number
onNext: (justCreated: boolean) => void
onSkip: () => void
locale: string
}
async function createNote(title: string | null, content: string) {
const res = await fetch('/api/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: title ?? 'Untitled', content }),
})
if (!res.ok) throw new Error('Failed to create note')
}
export function OnboardingStepNotes({ noteCount, onNext, onSkip, locale }: Props) {
const { t } = useLanguage()
const [loading, setLoading] = useState(false)
const [created, setCreated] = useState(false)
const [importError, setImportError] = useState<string | null>(null)
const [importedCount, setImportedCount] = useState(0)
const fileInputRef = useRef<HTMLInputElement>(null)
async function handleCreateDemo() {
setLoading(true)
try {
await fetch('/api/onboarding/seed-demo-notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ locale }),
})
setCreated(true)
setTimeout(() => onNext(true), 1200)
} finally {
setLoading(false)
}
}
async function handleImportFiles(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files ?? [])
if (!files.length) return
setLoading(true)
setImportError(null)
let count = 0
try {
for (const file of files) {
const text = await file.text()
const isMd = file.name.endsWith('.md')
const title = extractMarkdownTitle(text) ?? file.name.replace(/\.(md|txt)$/, '')
const content = isMd ? markdownToHTML(text) : `<p>${text.replace(/\n\n+/g, '</p><p>').replace(/\n/g, '<br/>')}</p>`
await createNote(title, content)
count++
}
setImportedCount(count)
setCreated(true)
setTimeout(() => onNext(false), 1200)
} catch {
setImportError(t('onboarding.import_error'))
} finally {
setLoading(false)
if (fileInputRef.current) fileInputRef.current.value = ''
}
}
const hasNotes = noteCount > 0
return (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -16 }}
className="flex flex-col items-center gap-6 text-center"
>
<motion.div
initial={{ scale: 0.7, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.1, type: 'spring', stiffness: 200 }}
className="flex h-20 w-20 items-center justify-center rounded-2xl bg-blue-500/10 text-blue-500"
>
<FileText className="h-10 w-10" />
</motion.div>
<div className="space-y-2">
<h2 className="text-2xl font-bold tracking-tight text-foreground">
{t('onboarding.step_notes_title')}
</h2>
<p className="text-base text-muted-foreground max-w-xs">
{hasNotes
? t('onboarding.step_notes_has_notes', { count: noteCount })
: t('onboarding.step_notes_empty')}
</p>
{!hasNotes && (
<p className="text-xs text-violet-500/80 max-w-xs">
{t('onboarding.step_notes_hint')}
</p>
)}
</div>
{hasNotes ? (
<div className="flex flex-col gap-2 w-full max-w-xs">
<Button onClick={() => onNext(false)} size="lg" className="w-full">
{t('onboarding.step_notes_cta')}
</Button>
<button
onClick={onSkip}
className="text-xs text-muted-foreground/60 hover:text-muted-foreground transition-colors py-1"
>
{t('onboarding.skip')}
</button>
</div>
) : (
<div className="flex flex-col gap-3 w-full max-w-xs">
{created ? (
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="flex items-center justify-center gap-2 rounded-lg bg-green-500/10 py-3 text-green-600"
>
<CheckCircle className="h-5 w-5" />
<span className="text-sm font-medium">
{importedCount > 0
? t('onboarding.import_notes_ready', { count: importedCount })
: t('onboarding.demo_notes_ready')}
</span>
</motion.div>
) : (
<>
<Button
onClick={handleCreateDemo}
size="lg"
className="w-full gap-2"
disabled={loading}
>
{loading ? (
<>
<span className="animate-spin h-4 w-4 border-2 border-white/30 border-t-white rounded-full" />
{t('onboarding.creating_demo_notes')}
</>
) : (
<>
<Sparkles className="h-4 w-4" />
{t('onboarding.step_notes_demo')}
</>
)}
</Button>
{/* Hidden file input — accepts .md and .txt */}
<input
ref={fileInputRef}
type="file"
accept=".md,.txt"
multiple
className="hidden"
onChange={handleImportFiles}
/>
<Button
onClick={() => fileInputRef.current?.click()}
size="lg"
variant="outline"
className="w-full gap-2"
disabled={loading}
>
<Upload className="h-4 w-4" />
{t('onboarding.step_notes_import')}
</Button>
<p className="text-xs text-muted-foreground/50">
{t('onboarding.import_formats')}
</p>
</>
)}
{importError && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex items-center gap-2 rounded-lg bg-destructive/10 py-2 px-3 text-destructive text-sm"
>
<AlertCircle className="h-4 w-4 shrink-0" />
{importError}
</motion.div>
)}
<button
onClick={onSkip}
className="text-xs text-muted-foreground/60 hover:text-muted-foreground transition-colors py-1"
>
{t('onboarding.skip')}
</button>
</div>
)}
</motion.div>
)
}

View File

@@ -0,0 +1,58 @@
'use client'
import { motion } from 'motion/react'
import { Brain } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useLanguage } from '@/lib/i18n'
interface Props {
onNext: () => void
onSkip: () => void
userName?: string | null
}
export function OnboardingStepWelcome({ onNext, onSkip, userName }: Props) {
const { t } = useLanguage()
const firstName = userName?.split(' ')[0] ?? null
return (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -16 }}
className="flex flex-col items-center gap-6 text-center"
>
<motion.div
initial={{ scale: 0.7, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.1, type: 'spring', stiffness: 200 }}
className="flex h-20 w-20 items-center justify-center rounded-2xl bg-brand-accent/10 text-brand-accent"
>
<Brain className="h-10 w-10" />
</motion.div>
<div className="space-y-2">
<h2 className="text-2xl font-bold tracking-tight text-foreground">
{firstName
? t('onboarding.welcome_title_name', { name: firstName })
: t('onboarding.welcome_title')}
</h2>
<p className="text-base text-muted-foreground max-w-xs">
{t('onboarding.welcome_subtitle')}
</p>
</div>
<div className="flex flex-col gap-2 w-full max-w-xs">
<Button onClick={onNext} size="lg" className="w-full">
{t('onboarding.welcome_cta')}
</Button>
<button
onClick={onSkip}
className="text-xs text-muted-foreground/60 hover:text-muted-foreground transition-colors py-1"
>
{t('onboarding.skip')}
</button>
</div>
</motion.div>
)
}

View File

@@ -0,0 +1,219 @@
'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>
</>
)
}

View File

@@ -0,0 +1,59 @@
'use client'
import { useQuery } from '@tanstack/react-query'
import { Zap, ArrowUpRight } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
import Link from 'next/link'
interface QuotaData { remaining: number; limit: number; used: number }
interface UsageData { quotas: Record<string, QuotaData>; tier: string }
export function StarterPackBadge() {
const { t } = useLanguage()
const { data } = useQuery<UsageData>({
queryKey: ['usage', 'current'],
queryFn: async () => {
const res = await fetch('/api/usage/current')
if (!res.ok) throw new Error('Failed')
return res.json()
},
staleTime: 5000,
})
if (!data) return null
// Paid tiers do not need the badge; only BASIC (free) users see it
const PAID_TIERS = ['PRO', 'BUSINESS', 'ENTERPRISE']
if (PAID_TIERS.includes(data.tier)) return null
const semanticQuota = data.quotas?.['semantic_search']
if (!semanticQuota) return null
const remaining = semanticQuota.remaining ?? 0
const isCritical = remaining === 0
const isWarning = remaining > 0 && remaining < 5
return (
<Link
href="/pricing"
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-xs font-medium transition-colors',
isCritical
? 'bg-destructive/10 text-destructive hover:bg-destructive/20'
: isWarning
? 'bg-orange-500/10 text-orange-500 hover:bg-orange-500/20'
: 'bg-muted text-muted-foreground hover:text-foreground hover:bg-muted/80'
)}
>
<Zap className={cn('h-3.5 w-3.5', isWarning || isCritical ? 'animate-pulse' : '')} />
<span>
{isCritical
? t('onboarding.badge_upgrade')
: t('onboarding.badge_credits', { count: remaining })
}
</span>
{isCritical && <ArrowUpRight className="h-3 w-3 ml-auto" />}
</Link>
)
}