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

@@ -94,12 +94,23 @@ export function BrainstormPage() {
setActiveSessionId(urlSessionId)
}
}, [urlSessionId])
const [showActivityFeed, setShowActivityFeed] = useState(false)
const [showShareDialog, setShowShareDialog] = useState(false)
const [manualEditCount, setManualEditCount] = useState(0)
const [shareStatus, setShareStatus] = useState<'idle' | 'copying' | 'copied'>('idle')
const { data: sessions, isLoading: sessionsLoading } = useBrainstormSessions()
// Auto-sélectionner la dernière session si aucune session active
useEffect(() => {
if (!activeSessionId && !urlSessionId && !sessionsLoading && sessions && sessions.length > 0) {
const last = sessions[0]
setActiveSessionId(last.id)
router.replace('/brainstorm?session=' + last.id)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessions, sessionsLoading])
const { data: sessionResult, isLoading: sessionLoading } = useBrainstormSession(activeSessionId)
const session = sessionResult?.session || null
const sessionMeta = sessionResult?.meta
@@ -128,6 +139,9 @@ export function BrainstormPage() {
const [viewMode, setViewMode] = useState<'canvas' | 'list'>('canvas')
const [summaryOpen, setSummaryOpen] = useState(false)
const [summaryText, setSummaryText] = useState<string | null>(null)
const [pptxLoading, setPptxLoading] = useState(false)
const [pptxError, setPptxError] = useState<string | null>(null)
const [fitTrigger, setFitTrigger] = useState(0)
const [renamingSession, setRenamingSession] = useState<string | null>(null)
const [renameInput, setRenameInput] = useState('')
const canvasContainerRef = useRef<HTMLDivElement>(null)
@@ -237,6 +251,36 @@ export function BrainstormPage() {
} catch {}
}
const handleExportPptx = async () => {
if (!activeSessionId) return
setPptxLoading(true)
setPptxError(null)
try {
const res = await fetch(`/api/brainstorm/${activeSessionId}/export-pptx`, {
method: 'POST',
credentials: 'include',
})
if (!res.ok) throw new Error(t('brainstorm.pptxError') || 'Export PPTX failed')
const blob = await res.blob()
const disposition = res.headers.get('Content-Disposition') || ''
const match = disposition.match(/filename="([^"]+)"/)
const filename = match?.[1] || 'brainstorm.pptx'
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch (err: any) {
setPptxError(err?.message || t('brainstorm.pptxError') || 'Export PPTX failed')
setTimeout(() => setPptxError(null), 4000)
} finally {
setPptxLoading(false)
}
}
const handleExport = async () => {
setExportError(null)
try {
@@ -577,6 +621,7 @@ export function BrainstormPage() {
remoteMove={remoteMove}
manualEditTrigger={manualEditCount}
playbackIdeas={playbackIdeas}
fitTrigger={fitTrigger}
/>
) : (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none opacity-20 flex-col gap-6">
@@ -594,7 +639,7 @@ export function BrainstormPage() {
animate={{ opacity: 1, y: 0 }}
className="absolute bottom-6 left-6 flex gap-2"
>
<div className="px-4 py-2 bg-[#F4F1EA] dark:bg-black/60 backdrop-blur-xl border border-border shadow-xl rounded-full flex items-center gap-6">
<div className="px-4 py-2 bg-[#F4F1EA] dark:bg-black/60 backdrop-blur-xl border border-border shadow-xl rounded-full flex items-center gap-5">
{[1, 2, 3].map((w) => (
<div key={w} className="flex items-center gap-2">
<div
@@ -609,6 +654,20 @@ export function BrainstormPage() {
</span>
</div>
))}
{viewMode === 'canvas' && (
<>
<div className="w-px h-4 bg-border/60" />
<button
onClick={() => setFitTrigger((c) => c + 1)}
title={t('brainstorm.fitToScreen') || 'Recentrer'}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/>
</svg>
</button>
</>
)}
</div>
{canEdit && (
@@ -854,7 +913,10 @@ export function BrainstormPage() {
{sessions?.map((s) => (
<div key={s.id} className="relative group/session">
<button
onClick={() => setActiveSessionId(s.id)}
onClick={() => {
setActiveSessionId(s.id)
router.replace('/brainstorm?session=' + s.id)
}}
className={`w-10 h-10 min-h-[40px] rounded-xl flex items-center justify-center text-xs font-bold transition-all shrink-0 ${
activeSessionId === s.id
? 'bg-foreground text-background scale-110 shadow-lg'
@@ -864,7 +926,7 @@ export function BrainstormPage() {
}`}
title={s.seedIdea}
>
{s.seedIdea.charAt(0).toUpperCase()}
{(s.seedIdea.replace(/\p{Emoji}/gu, '').trim().charAt(0) || '?').toUpperCase()}
</button>
{activeSessionId === s.id && (
<button
@@ -943,14 +1005,29 @@ export function BrainstormPage() {
{t('brainstorm.regenerateSummary') || 'Regenerate'}
</button>
)}
<button
onClick={() => { setSummaryOpen(false); handleExport() }}
disabled={exportBrainstorm.isPending || summarize.isPending}
className="ms-auto flex items-center gap-2 px-5 py-2.5 bg-foreground text-background rounded-xl text-xs font-bold uppercase tracking-widest disabled:opacity-50 hover:opacity-80 transition-opacity"
>
<Download size={13} />
{t('brainstorm.exportAsNote') || 'Export as note'}
</button>
<div className="ms-auto flex items-center gap-2">
<button
onClick={handleExportPptx}
disabled={pptxLoading || summarize.isPending}
title={t('brainstorm.downloadPptxDesc') || 'Download as PowerPoint'}
className="flex items-center gap-2 px-4 py-2.5 border border-foreground/20 text-foreground rounded-xl text-xs font-bold uppercase tracking-widest disabled:opacity-50 hover:bg-foreground/5 transition-colors"
>
{pptxLoading ? (
<motion.div animate={{ rotate: 360 }} transition={{ duration: 1, repeat: Infinity, ease: 'linear' }} className="w-3 h-3 border-2 border-current border-t-transparent rounded-full" />
) : (
<Download size={13} />
)}
{t('brainstorm.downloadPptx') || 'PPTX'}
</button>
<button
onClick={() => { setSummaryOpen(false); handleExport() }}
disabled={exportBrainstorm.isPending || summarize.isPending}
className="flex items-center gap-2 px-5 py-2.5 bg-foreground text-background rounded-xl text-xs font-bold uppercase tracking-widest disabled:opacity-50 hover:opacity-80 transition-opacity"
>
<Download size={13} />
{t('brainstorm.exportAsNote') || 'Export as note'}
</button>
</div>
</div>
</motion.div>
</motion.div>
@@ -1033,6 +1110,20 @@ export function BrainstormPage() {
)}
</AnimatePresence>
<AnimatePresence>
{pptxError && (
<motion.div
initial={{ y: 100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 100, opacity: 0 }}
className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 px-6 py-4 bg-rose-500 text-white rounded-2xl shadow-2xl flex items-center gap-4 text-sm font-medium"
>
<span>{pptxError}</span>
<button onClick={() => setPptxError(null)} className="text-white/60 hover:text-white"></button>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{exportToast && (
<motion.div

View File

@@ -14,6 +14,7 @@ interface WaveCanvasProps {
remoteMove?: { ideaId: string; x: number; y: number; _seq: number } | null
manualEditTrigger?: number
playbackIdeas?: any[] | null
fitTrigger?: number
}
const WAVE_COLORS: Record<number, string> = {
@@ -31,6 +32,7 @@ export const WaveCanvas: React.FC<WaveCanvasProps> = ({
remoteMove,
manualEditTrigger,
playbackIdeas,
fitTrigger,
}) => {
const { t, language } = useLanguage()
const svgRef = useRef<SVGSVGElement>(null)
@@ -39,6 +41,8 @@ export const WaveCanvas: React.FC<WaveCanvasProps> = ({
const linkRef = useRef<d3.Selection<SVGLineElement, any, SVGGElement, unknown> | null>(null)
const simulationRef = useRef<d3.Simulation<any, any> | null>(null)
const transformRef = useRef<d3.ZoomTransform>(d3.zoomIdentity)
const zoomRef = useRef<d3.ZoomBehavior<SVGSVGElement, unknown> | null>(null)
const centerRef = useRef<{ x: number; y: number; scale: number }>({ x: 0, y: 0, scale: 0.8 })
const onNodeSelectRef = useRef(onNodeSelect)
onNodeSelectRef.current = onNodeSelect
@@ -167,6 +171,9 @@ export const WaveCanvas: React.FC<WaveCanvasProps> = ({
transformRef.current = event.transform
})
zoomRef.current = zoom
centerRef.current = { x: centerX, y: centerY, scale: 0.8 }
svg.call(zoom)
svg.call(zoom.transform, d3.zoomIdentity.translate(centerX, centerY).scale(0.8))
@@ -517,6 +524,16 @@ export const WaveCanvas: React.FC<WaveCanvasProps> = ({
node.attr('transform', (d: any) => `translate(${d.x},${d.y})`)
}, [remoteMove])
// Fit-to-screen: re-center when fitTrigger increments
useEffect(() => {
if (!fitTrigger || !svgRef.current || !zoomRef.current) return
const { x, y, scale } = centerRef.current
d3.select(svgRef.current)
.transition()
.duration(500)
.call(zoomRef.current.transform, d3.zoomIdentity.translate(x, y).scale(scale))
}, [fitTrigger])
return (
<div
ref={containerRef}
@@ -524,6 +541,21 @@ export const WaveCanvas: React.FC<WaveCanvasProps> = ({
>
<svg ref={svgRef} className="w-full h-full" />
{/* Legend overlay — bottom right */}
<div className="absolute bottom-6 right-6 pointer-events-none flex flex-col gap-1 bg-white/80 dark:bg-[#1A1A1A]/80 backdrop-blur-sm rounded-xl px-3 py-2.5 shadow-sm border border-black/[0.06] dark:border-white/[0.06]">
{[
{ color: '#fb923c', label: t('brainstorm.legendWave1') || 'Variations' },
{ color: '#60a5fa', label: t('brainstorm.legendWave2') || 'Analogies' },
{ color: '#a78bfa', label: t('brainstorm.legendWave3') || 'Disruptions' },
{ color: '#10b981', label: t('brainstorm.legendConverted') || 'Convertie' },
].map(({ color, label }) => (
<div key={label} className="flex items-center gap-2">
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: color }} />
<span className="text-[10px] text-foreground/50 font-medium">{label}</span>
</div>
))}
</div>
{editingNode && (
<div
className="absolute z-50 pointer-events-auto"

View File

@@ -11,6 +11,7 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
@@ -18,7 +19,7 @@ import { Badge } from '@/components/ui/badge'
import {
X, Plus, Palette, Image as ImageIcon, Bell, Eye, Link as LinkIcon, Sparkles,
Maximize2, Copy, ArrowLeft, ChevronRight, PanelRight, Check, Loader2, Save, MoreHorizontal,
Trash2, LogOut, Wand2, Share2, Wind, Paperclip, GraduationCap
Trash2, LogOut, Wand2, Share2, Wind, Paperclip, GraduationCap, FileDown, FileUp
} from 'lucide-react'
import { FlashcardGenerateDialog } from '@/components/flashcards/flashcard-generate-dialog'
import { NoteShareDialog } from './note-share-dialog'
@@ -29,6 +30,7 @@ import { NOTE_COLORS, NoteColor, Note } from '@/lib/types'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
import { format } from 'date-fns'
import { tiptapHTMLToMarkdown, markdownToHTML, extractMarkdownTitle } from '@/lib/editor/markdown-export'
interface NoteEditorToolbarProps {
mode: 'fullPage' | 'dialog'
@@ -38,16 +40,70 @@ interface NoteEditorToolbarProps {
}
export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachmentsCount }: NoteEditorToolbarProps) {
const { state, actions, note, readOnly, fullPage, notebooks, fileInputRef } = useNoteEditorContext()
const { state, actions, note, readOnly, fullPage, notebooks, fileInputRef, richTextEditorRef } = useNoteEditorContext()
const { t } = useLanguage()
const [isConverting, setIsConverting] = useState(false)
const [shareOpen, setShareOpen] = useState(false)
const [flashcardsOpen, setFlashcardsOpen] = useState(false)
const mdImportInputRef = useRef<HTMLInputElement>(null)
const notebookName = notebooks.find(nb => nb.id === note.notebookId)?.name || null
const undoSnapshotRef = useRef<{ content: string; isMarkdown: boolean } | null>(null)
// ── Markdown export ───────────────────────────────────────────────────────
const handleExportMarkdown = () => {
try {
const editor = richTextEditorRef?.current?.getEditor()
if (!editor) {
toast.error(t('richTextEditor.markdownExportError'))
return
}
const html = editor.getHTML()
const title = state.title || note.title || 'note'
const titleLine = title ? `# ${title}\n\n` : ''
const markdown = titleLine + tiptapHTMLToMarkdown(html)
const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${title.replace(/[^a-z0-9\-_\s]/gi, '').trim().replace(/\s+/g, '-') || 'note'}.md`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
toast.success(t('richTextEditor.markdownExportSuccess'))
} catch {
toast.error(t('richTextEditor.markdownExportError'))
}
}
// ── Markdown import ───────────────────────────────────────────────────────
const handleImportMarkdownFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (ev) => {
try {
const md = ev.target?.result as string
const html = markdownToHTML(md)
const extractedTitle = extractMarkdownTitle(md)
const editor = richTextEditorRef?.current?.getEditor()
if (editor) {
editor.commands.setContent(html)
}
actions.setContent(html)
if (extractedTitle) actions.setTitle(extractedTitle)
toast.success(t('richTextEditor.markdownImportSuccess'))
} catch {
toast.error(t('richTextEditor.markdownExportError'))
}
}
reader.readAsText(file)
// Reset input so same file can be imported again
e.target.value = ''
}
const handleConvertToRichtext = async () => {
if (isConverting || !state.content.trim()) return
setIsConverting(true)
@@ -240,7 +296,16 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
<MoreHorizontal size={16} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem onClick={handleExportMarkdown}>
<FileDown className="h-4 w-4 me-2" />
{t('richTextEditor.exportMarkdown')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => mdImportInputRef.current?.click()}>
<FileUp className="h-4 w-4 me-2" />
{t('richTextEditor.importMarkdown')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={async () => {
try {
@@ -304,6 +369,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
}
return (
<>
<div className="flex items-center justify-between pt-3 border-t border-border/30">
<div className="flex items-center gap-0.5">
{!readOnly && (
@@ -432,5 +498,14 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
)}
</div>
</div>
{/* Hidden file input for Markdown import */}
<input
ref={mdImportInputRef}
type="file"
accept=".md,text/markdown"
className="hidden"
onChange={handleImportMarkdownFile}
/>
</>
)
}

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

View File

@@ -9,6 +9,8 @@ import type { ReactNode } from 'react'
import type { Translations } from '@/lib/i18n/load-translations'
import { AiConsentProvider } from '@/components/legal/ai-consent-provider'
import { SearchModalProvider } from '@/context/search-modal-context'
import { OnboardingWizard } from '@/components/onboarding/onboarding-wizard'
import { OnboardingEditorHints } from '@/components/onboarding/onboarding-editor-hints'
const RTL_LANGUAGES = ['ar', 'fa']
@@ -42,6 +44,8 @@ export function ProvidersWrapper({
<DirWrapper>
<SearchModalProvider>
{children}
<OnboardingWizard />
<OnboardingEditorHints />
</SearchModalProvider>
</DirWrapper>
</AiConsentProvider>

View File

@@ -41,6 +41,7 @@ import { parseBlockReferenceFromText, recallLastBlockReference, type ParsedBlock
import { getEmptyParagraphAtSelection } from '@/lib/editor/empty-paragraph-at-selection'
import { SmartPasteExtension } from '@/lib/editor/smart-paste-extension'
import { BlockSelectionExtension } from '@/lib/editor/block-selection-extension'
import { MarkdownPasteExtension } from '@/lib/editor/markdown-paste-extension'
import { TurnIntoShortcutExtension } from '@/lib/editor/turn-into-shortcut-extension'
import { UndoRedoFeedbackExtension } from '@/lib/editor/undo-redo-feedback-extension'
import type { Node as PMNode } from '@tiptap/pm/model'
@@ -417,6 +418,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
...globalDragHandleExtensions,
SmartPasteExtension,
BlockSelectionExtension,
MarkdownPasteExtension,
TurnIntoShortcutExtension,
UndoRedoFeedbackExtension,
LiveBlockExtension,

View File

@@ -59,6 +59,7 @@ import {
import { performSignOut } from '@/lib/auth-client'
import { useBrainstormSessions, useDeleteBrainstorm } from '@/hooks/use-brainstorm'
import { UsageMeter } from './usage-meter'
import { StarterPackBadge } from './onboarding/starter-pack-badge'
type NavigationView = 'notebooks' | 'agents' | 'reminders' | 'brainstorms' | 'revision' | 'insights'
type SortOrder = 'newest' | 'oldest' | 'alpha' | 'manual'
@@ -1535,8 +1536,9 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
</AnimatePresence>
</div>
{/* ── Usage meter en bas du panneau ── */}
<div className="border-t border-border/20 px-3 py-3 mt-auto">
{/* ── Usage meter + starter badge en bas du panneau ── */}
<div className="border-t border-border/20 px-3 py-3 mt-auto space-y-2">
<StarterPackBadge />
<UsageMeter />
</div>