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>
203 lines
7.4 KiB
TypeScript
203 lines
7.4 KiB
TypeScript
'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>
|
||
)
|
||
}
|