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>
This commit is contained in:
232
memento-note/components/onboarding/onboarding-editor-hints.tsx
Normal file
232
memento-note/components/onboarding/onboarding-editor-hints.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
202
memento-note/components/onboarding/onboarding-step-aha.tsx
Normal file
202
memento-note/components/onboarding/onboarding-step-aha.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
116
memento-note/components/onboarding/onboarding-step-features.tsx
Normal file
116
memento-note/components/onboarding/onboarding-step-features.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
203
memento-note/components/onboarding/onboarding-step-notes.tsx
Normal file
203
memento-note/components/onboarding/onboarding-step-notes.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
219
memento-note/components/onboarding/onboarding-wizard.tsx
Normal file
219
memento-note/components/onboarding/onboarding-wizard.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
59
memento-note/components/onboarding/starter-pack-badge.tsx
Normal file
59
memento-note/components/onboarding/starter-pack-badge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user