Files
Momento/memento-note/components/onboarding/onboarding-step-aha.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

203 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
)
}