feat: dashboard Second Brain, essai 7 jours et vérification e-mail
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m14s
CI / Deploy production (on server) (push) Successful in 1m25s

Rendre le dashboard actionnable (inbox, peek, carte mentale), aligner la facturation sur l’essai 7 jours, et bloquer le login e-mail tant que l’adresse n’est pas confirmée.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Antigravity
2026-08-30 07:19:36 +00:00
parent 69c99e4f4f
commit 80ccc1f6de
95 changed files with 4158 additions and 618 deletions

View File

@@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useState, useRef } from 'react'
import { motion, AnimatePresence } from 'motion/react'
import { Search, Bot, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
@@ -35,12 +35,22 @@ export function DashboardAgentCarousel({
}: DashboardAgentCarouselProps) {
const { t } = useLanguage()
const [idx, setIdx] = useState(0)
const directionRef = useRef(1)
const goPrev = () => {
directionRef.current = -1
setIdx(i => Math.max(0, i - 1))
}
const goNext = () => {
directionRef.current = 1
setIdx(i => Math.min(suggestions.length - 1, i + 1))
}
const navActions = suggestions.length > 1 ? (
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => setIdx(i => Math.max(0, i - 1))}
onClick={goPrev}
disabled={idx === 0}
className="p-1 rounded border border-border/30 disabled:opacity-25"
aria-label={t('homeDashboard.intelPrev')}
@@ -52,7 +62,7 @@ export function DashboardAgentCarousel({
</span>
<button
type="button"
onClick={() => setIdx(i => Math.min(suggestions.length - 1, i + 1))}
onClick={goNext}
disabled={idx >= suggestions.length - 1}
className="p-1 rounded border border-border/30 disabled:opacity-25"
aria-label={t('homeDashboard.intelNext')}
@@ -84,6 +94,7 @@ export function DashboardAgentCarousel({
) : (
<AgentSlide
current={suggestions[idx]}
direction={directionRef.current}
actingId={actingId}
formatFrequency={formatFrequency}
onAccept={onAccept}
@@ -98,6 +109,7 @@ export function DashboardAgentCarousel({
function AgentSlide({
current,
direction,
actingId,
formatFrequency,
onAccept,
@@ -106,6 +118,7 @@ function AgentSlide({
t,
}: {
current: AgentSuggestion
direction: number
actingId: string | null
formatFrequency: (f: string) => string
onAccept: (id: string) => void
@@ -115,7 +128,11 @@ function AgentSlide({
}) {
const slide = prefersReducedMotion
? { initial: {}, animate: {}, exit: {} }
: { initial: { opacity: 0, y: 8 }, animate: { opacity: 1, y: 0 }, exit: { opacity: 0, y: -8 } }
: {
initial: { opacity: 0, x: 14 * direction },
animate: { opacity: 1, x: 0 },
exit: { opacity: 0, x: -14 * direction },
}
return (
<AnimatePresence mode="wait">

View File

@@ -1,5 +1,6 @@
'use client'
import { motion, useReducedMotion } from 'motion/react'
import { Inbox, GraduationCap, Mail, Pin, Bot, BarChart3 } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { RevisionHeatmap } from '@/components/flashcards/revision-heatmap'
@@ -42,14 +43,19 @@ export function DashboardWidgetShell({
export function DashboardInboxWidget({
count,
notes,
loading,
onOpen,
onSelect,
}: {
count: number
notes: Array<{ id: string; title: string | null; notebookId: string | null }>
loading: boolean
onOpen: () => void
onSelect: (id: string, notebookId: string | null) => void
}) {
const { t } = useLanguage()
const reduced = !!useReducedMotion()
return (
<DashboardWidgetShell
widgetId="inbox"
@@ -58,19 +64,42 @@ export function DashboardInboxWidget({
compact
>
{loading ? (
<div className="h-10 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
<div className="space-y-2">
<div className="h-10 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
<div className="h-8 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
</div>
) : notes.length === 0 ? (
<p className="text-[11px] text-concrete italic py-1">{t('homeDashboard.inboxEmpty')}</p>
) : (
<button
type="button"
onClick={onOpen}
className="w-full flex items-center justify-between gap-3 p-3 rounded-xl border border-border/20 hover:border-brand-accent/30 hover:bg-brand-accent/[0.03] transition-all text-start"
>
<div>
<p className="text-2xl font-serif font-bold text-ink dark:text-dark-ink leading-none">{count}</p>
<p className="text-[10px] text-concrete mt-1">{t('homeDashboard.toOrganize')}</p>
</div>
<span className="text-[9px] font-mono uppercase font-bold text-brand-accent">{t('homeDashboard.widgetOpen')} </span>
</button>
<div className="space-y-1.5">
{notes.map((note, idx) => (
<motion.button
key={note.id}
type="button"
onClick={() => onSelect(note.id, note.notebookId)}
initial={reduced ? false : { opacity: 0, x: 8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: reduced ? 0 : 0.22, delay: reduced ? 0 : Math.min(idx * 0.05, 0.15), ease: [0.16, 1, 0.3, 1] }}
className="w-full text-start p-2.5 rounded-xl border border-border/20 hover:border-brand-accent/30 hover:bg-brand-accent/[0.03] transition-all"
>
<p className="text-[11px] text-ink dark:text-dark-ink truncate">
{note.title || t('homeDashboard.untitled')}
</p>
</motion.button>
))}
<button
type="button"
onClick={onOpen}
className="w-full flex items-center justify-between gap-2 px-1 pt-1 text-start"
>
<span className="text-[9px] font-mono uppercase font-bold text-concrete">
{t('homeDashboard.inboxSeeAll', { count })}
</span>
<span className="text-[9px] font-mono uppercase font-bold text-brand-accent">
{t('homeDashboard.widgetOpen')}
</span>
</button>
</div>
)}
</DashboardWidgetShell>
)

View File

@@ -19,12 +19,22 @@ interface BridgeNote {
}
const CLUSTER_COLORS = ['#F87171', '#60A5FA', '#34D399', '#FBBF24', '#A78BFA', '#F472B6', '#2DD4BF']
const EASE = [0.16, 1, 0.3, 1] as const
function clusterPoint(index: number, count: number): { x: number; y: number } {
if (count === 1) return { x: 50, y: 42 }
const angle = -Math.PI / 2 + (index * 2 * Math.PI) / count
const rx = count <= 3 ? 28 : 36
const ry = count <= 3 ? 24 : 30
return { x: 50 + Math.cos(angle) * rx, y: 44 + Math.sin(angle) * ry }
}
export interface DashboardMindOrbitProps {
clusters: Cluster[]
bridgeNotes: BridgeNote[]
loading?: boolean
onOpenInsights: () => void
onOpenCluster?: (clusterId: number) => void
onNoteSelect: (id: string) => void
prefersReducedMotion?: boolean
}
@@ -34,6 +44,7 @@ export function DashboardMindOrbit({
bridgeNotes,
loading,
onOpenInsights,
onOpenCluster,
onNoteSelect,
prefersReducedMotion,
}: DashboardMindOrbitProps) {
@@ -43,6 +54,8 @@ export function DashboardMindOrbit({
.slice(0, 5)
const maxCount = topClusters[0]?.noteIds.length || 1
const topBridge = bridgeNotes[0]
const reduced = !!prefersReducedMotion
const points = topClusters.map((_, idx) => clusterPoint(idx, topClusters.length))
if (loading) {
return <div className="h-[180px] rounded-2xl bg-stone-50 dark:bg-zinc-950/30 animate-pulse" />
@@ -80,21 +93,64 @@ export function DashboardMindOrbit({
)}
/>
<div className="flex flex-wrap items-center justify-center gap-3 min-h-[100px] py-2">
<motion.div
className="relative h-[176px]"
initial={reduced ? false : { clipPath: 'circle(0% at 50% 44%)' }}
animate={{ clipPath: 'circle(120% at 50% 44%)' }}
transition={{ duration: reduced ? 0 : 0.62, ease: EASE }}
>
<svg
viewBox="0 0 100 100"
preserveAspectRatio="none"
className="absolute inset-0 w-full h-full pointer-events-none"
aria-hidden
>
{points.map((point, idx) => {
const color = CLUSTER_COLORS[topClusters[idx].clusterId % CLUSTER_COLORS.length]
return (
<motion.path
key={`spoke-${topClusters[idx].clusterId}`}
d={`M 50 44 L ${point.x} ${point.y}`}
fill="none"
stroke={color}
strokeWidth={0.7}
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
initial={reduced ? false : { pathLength: 0, opacity: 0 }}
animate={{ pathLength: 1, opacity: 0.4 }}
transition={{ duration: reduced ? 0 : 0.45, delay: reduced ? 0 : 0.12 + idx * 0.05, ease: EASE }}
/>
)
})}
</svg>
<div
className="absolute left-1/2 top-[44%] w-2.5 h-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-brand-accent shadow-[0_2px_8px_rgba(164,113,72,0.45)] pointer-events-none"
aria-hidden
/>
{topClusters.map((cluster, idx) => {
const color = CLUSTER_COLORS[cluster.clusterId % CLUSTER_COLORS.length]
const scale = 0.65 + (cluster.noteIds.length / maxCount) * 0.55
const size = Math.round(56 * scale)
const size = Math.round(52 * scale)
const label = cluster.name || `${t('homeDashboard.theme')} ${cluster.clusterId + 1}`
const point = points[idx]
return (
<motion.button
key={cluster.clusterId}
type="button"
whileHover={prefersReducedMotion ? undefined : { scale: 1.06 }}
whileTap={prefersReducedMotion ? undefined : { scale: 0.97 }}
onClick={onOpenInsights}
className="relative flex flex-col items-center gap-1.5 group"
style={{ width: size + 16 }}
whileHover={reduced ? undefined : { scale: 1.06 }}
whileTap={reduced ? undefined : { scale: 0.97 }}
onClick={() => (onOpenCluster ? onOpenCluster(cluster.clusterId) : onOpenInsights())}
className="absolute flex flex-col items-center gap-1 group"
style={{
left: `${point.x}%`,
top: `${point.y}%`,
width: size + 16,
}}
initial={reduced ? { x: '-50%', y: '-50%' } : { x: '-50%', y: '-50%', scale: 0.82, opacity: 0.35 }}
animate={{ x: '-50%', y: '-50%', scale: 1, opacity: 1 }}
transition={{ duration: reduced ? 0 : 0.38, delay: reduced ? 0 : 0.18 + idx * 0.05, ease: EASE }}
>
<div
className="rounded-full border-2 flex items-center justify-center font-mono font-bold text-white shadow-sm group-hover:shadow-md transition-shadow"
@@ -114,13 +170,16 @@ export function DashboardMindOrbit({
</motion.button>
)
})}
</div>
</motion.div>
{topBridge?.note && (
<button
<motion.button
type="button"
onClick={() => onNoteSelect(topBridge.noteId)}
className="w-full mt-2 p-2.5 rounded-xl border border-brand-accent/20 bg-brand-accent/[0.04] hover:bg-brand-accent/[0.08] transition-all text-start flex items-center gap-2 group"
initial={reduced ? false : { opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: reduced ? 0 : 0.32, delay: reduced ? 0 : 0.48, ease: EASE }}
>
<Zap size={11} className="text-brand-accent shrink-0" />
<div className="min-w-0 flex-1">
@@ -132,7 +191,7 @@ export function DashboardMindOrbit({
<span className="text-[8px] font-mono font-bold text-brand-accent bg-brand-accent/10 px-1.5 py-0.5 rounded-full shrink-0">
{Math.round(topBridge.bridgeScore * 100)}%
</span>
</button>
</motion.button>
)}
</div>
)

View File

@@ -41,7 +41,8 @@ export function DashboardNextPaths({
onAction,
prefersReducedMotion,
}: DashboardNextPathsProps) {
const { t } = useLanguage()
const { t, language } = useLanguage()
const rtl = language === 'ar' || language === 'fa'
if (loading) {
return (
@@ -84,6 +85,8 @@ export function DashboardNextPaths({
const hero = paths[0]
const rest = paths.slice(1, 5)
const HeroIcon = TYPE_META[hero.type].Icon
const reduced = !!prefersReducedMotion
const ease = [0.16, 1, 0.3, 1] as const
return (
<div className="rounded-2xl border border-brand-accent/20 bg-gradient-to-br from-white via-white to-brand-accent/[0.04] dark:from-zinc-900 dark:via-zinc-900 dark:to-brand-accent/[0.06] shadow-sm overflow-hidden">
@@ -109,7 +112,11 @@ export function DashboardNextPaths({
<motion.button
type="button"
onClick={() => onAction(hero)}
whileHover={prefersReducedMotion ? undefined : { y: -1 }}
whileHover={reduced ? undefined : { y: -1 }}
key={hero.id}
initial={reduced ? false : { clipPath: rtl ? 'inset(0 0 0 72%)' : 'inset(0 72% 0 0)', opacity: 0.55 }}
animate={{ clipPath: 'inset(0 0% 0 0)', opacity: 1 }}
transition={{ duration: reduced ? 0 : 0.48, ease }}
className="w-full text-start px-5 py-4 hover:bg-brand-accent/[0.03] transition-colors border-b border-border/10"
>
<div className="flex items-start gap-3">
@@ -137,14 +144,17 @@ export function DashboardNextPaths({
{rest.length > 0 && (
<div className="divide-y divide-border/10">
{rest.map(path => {
{rest.map((path, idx) => {
const meta = TYPE_META[path.type]
const Icon = meta.Icon
return (
<button
<motion.button
key={path.id}
type="button"
onClick={() => onAction(path)}
initial={reduced ? false : { opacity: 0, x: rtl ? -10 : 10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: reduced ? 0 : 0.28, delay: reduced ? 0 : Math.min(0.08 + idx * 0.04, 0.2), ease }}
className="w-full flex items-center gap-3 px-5 py-3 text-start hover:bg-stone-50/80 dark:hover:bg-zinc-950/40 transition-colors"
>
<Icon size={13} className={`shrink-0 ${meta.accent}`} />
@@ -155,7 +165,7 @@ export function DashboardNextPaths({
<span className="text-[8px] font-mono uppercase text-brand-accent shrink-0">
{t(`homeDashboard.pathActions.${path.actionKey}`)}
</span>
</button>
</motion.button>
)
})}
</div>

View File

@@ -3,7 +3,7 @@
import { useState, useEffect, useCallback, useMemo, type ReactNode } from 'react'
import { useRouter } from 'next/navigation'
import { useReducedMotion } from 'motion/react'
import { Inbox, Send, Bell, Mail } from 'lucide-react'
import { Inbox, Send, Bell, Mail, Loader2 } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { useAiConsent } from '@/components/legal/ai-consent-provider'
import { redirectToAiConsentSettings } from '@/lib/consent/ai-consent-redirect'
@@ -152,7 +152,7 @@ interface MindMapData {
}
interface DashboardViewProps {
onNoteSelect: (noteId: string, notebookId: string | null) => void
onNoteSelect: (noteId: string, notebookId: string | null, peekNoteId?: string | null) => void
}
interface GmailStatus {
@@ -217,6 +217,7 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
const [data, setData] = useState<{
recentNotes: BriefingNote[]
inboxCount: number
inboxPreview?: Array<{ id: string; title: string | null; notebookId: string | null }>
dueFlashcards: number
upcomingReminders: BriefingReminder[]
insights: BriefingInsight[]
@@ -491,9 +492,14 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
} : prev)
}, [])
const handleOpenFromInsight = useCallback(async (insight: BriefingInsight, noteId: string) => {
const handleOpenFromInsight = useCallback(async (
insight: BriefingInsight,
noteId: string,
peekNoteId?: string | null,
) => {
await markInsightViewed(insight.id)
onNoteSelect(noteId, null)
const peek = peekNoteId && peekNoteId !== noteId ? peekNoteId : undefined
onNoteSelect(noteId, null, peek)
}, [markInsightViewed, onNoteSelect])
const handleDismissInsight = useCallback(async (insight: BriefingInsight) => {
@@ -636,18 +642,14 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
if (path.noteId) onNoteSelect(path.noteId, path.notebookId ?? null)
break
case 'compare':
if (path.noteId) onNoteSelect(path.noteId, path.notebookId ?? null)
if (path.note2Id) toast.info(t('homeDashboard.pathCompareHint', { title: path.title }))
if (path.noteId) onNoteSelect(path.noteId, path.notebookId ?? null, path.note2Id)
break
case 'addLink':
if (path.noteId) {
onNoteSelect(path.noteId, path.notebookId ?? null)
toast.info(t('homeDashboard.pathAddLinkHint', { link: path.title }))
}
if (path.noteId) onNoteSelect(path.noteId, path.notebookId ?? null, path.note2Id)
break
case 'openInsight': {
const insight = insights.find(i => i.id === path.insightId)
if (insight && path.noteId) handleOpenFromInsight(insight, path.noteId)
if (insight && path.noteId) handleOpenFromInsight(insight, path.noteId, path.note2Id)
break
}
case 'createBridge': {
@@ -772,8 +774,11 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
onClick={handleCapture}
disabled={!captureText.trim() || capturing}
className="absolute bottom-2.5 end-2.5 p-2 bg-ink text-white dark:bg-white dark:text-black rounded-lg disabled:opacity-25 hover:scale-105 active:scale-95 transition-all shadow-sm"
aria-busy={capturing}
>
<Send size={12} />
{capturing
? <Loader2 size={12} className="animate-spin" />
: <Send size={12} />}
</button>
</div>,
)
@@ -952,6 +957,7 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
bridgeNotes={mindMap?.bridgeNotes ?? []}
loading={mindMapLoading}
onOpenInsights={() => router.push('/insights')}
onOpenCluster={(clusterId) => router.push(`/insights?cluster=${clusterId}`)}
onNoteSelect={(nid) => onNoteSelect(nid, null)}
prefersReducedMotion={!!prefersReducedMotion}
/>,
@@ -983,8 +989,10 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
return wrap(
<DashboardInboxWidget
count={inboxCount}
notes={data?.inboxPreview ?? []}
loading={briefingLoading}
onOpen={() => router.push('/home?forceList=1')}
onSelect={onNoteSelect}
/>,
)
case 'revision':
@@ -1114,7 +1122,17 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
</header>
<div className="pb-24">
<DashboardWidgetGrid renderWidget={renderWidget} />
<DashboardWidgetGrid
renderWidget={renderWidget}
isWidgetEmpty={(id) => {
if (briefingLoading) return false
if (id === 'sentiment') return !sentimentLoading && (!sentiment?.available || !sentiment?.dominantEmotion)
if (id === 'reminders') return reminders.length === 0
if (id === 'revision') return dueFlashcards === 0
if (id === 'inbox') return inboxCount === 0
return false
}}
/>
</div>
</div>
</div>

View File

@@ -40,6 +40,7 @@ import { toast } from 'sonner'
interface DashboardWidgetGridProps {
renderWidget: (id: DashboardWidgetId) => React.ReactNode
isWidgetEmpty?: (id: DashboardWidgetId) => boolean
}
function SortableWidget({
@@ -139,7 +140,7 @@ function ZoneColumn({
)
}
export function DashboardWidgetGrid({ renderWidget }: DashboardWidgetGridProps) {
export function DashboardWidgetGrid({ renderWidget, isWidgetEmpty }: DashboardWidgetGridProps) {
const { t } = useLanguage()
const [layout, setLayout] = useState<DashboardLayout>(() => getDefaultDashboardLayout())
const [editMode, setEditMode] = useState(false)
@@ -228,8 +229,11 @@ export function DashboardWidgetGrid({ renderWidget }: DashboardWidgetGridProps)
}, [persistLayout, t])
const fullWidgets = visibleWidgetsInZone(layout, 'full')
.filter(w => editMode || !isWidgetEmpty?.(w.id))
const mainWidgets = visibleWidgetsInZone(layout, 'main')
.filter(w => editMode || !isWidgetEmpty?.(w.id))
const sideWidgets = visibleWidgetsInZone(layout, 'side')
.filter(w => editMode || !isWidgetEmpty?.(w.id))
const hidden = hiddenWidgetIds(layout)
const catalog = catalogByCategory(layout)
const hasVisibleWidgets = fullWidgets.length + mainWidgets.length + sideWidgets.length > 0

View File

@@ -657,7 +657,10 @@ export function HomeClient({
// Garder openNote dans l'URL tant que l'éditeur est ouvert → le sidebar peut surligner la note (comme activeNoteId dans la ref.)
useEffect(() => {
const openNoteId = searchParams.get('openNote')
if (!openNoteId) return
if (!openNoteId) {
setEditingNote(null)
return
}
let cancelled = false
const run = async () => {
@@ -816,8 +819,15 @@ export function HomeClient({
const handleEditorClose = useCallback(() => {
setEditingNote(null)
// Ouvert depuis le dashboard (Comparer / Lier / clic note) → revenir au dashboard, pas à la liste des carnets.
if (searchParams.get('from') === 'dashboard' || searchParams.get('peekNote')) {
router.replace('/home', { scroll: false })
return
}
const params = new URLSearchParams(searchParams.toString())
params.delete('openNote')
params.delete('peekNote')
params.delete('from')
const qs = params.toString()
router.replace(qs ? `/home?${qs}` : '/home', { scroll: false })
}, [router, searchParams])
@@ -831,10 +841,17 @@ export function HomeClient({
// Show dashboard when no active filter/view params
const showDashboard = !editingNote && isDashboardHomeRoute('/home', searchParams)
const handleDashboardNoteSelect = useCallback((noteId: string, notebookId: string | null) => {
const handleDashboardNoteSelect = useCallback((
noteId: string,
notebookId: string | null,
peekNoteId?: string | null,
) => {
const params = new URLSearchParams()
params.set('openNote', noteId)
if (notebookId) params.set('notebook', notebookId)
params.set('from', 'dashboard')
// Avec aperçu split, ne pas ouvrir le panneau carnets : il mange la largeur des deux notes.
if (notebookId && !peekNoteId) params.set('notebook', notebookId)
if (peekNoteId && peekNoteId !== noteId) params.set('peekNote', peekNoteId)
router.push(`/home?${params.toString()}`)
}, [router])

View File

@@ -149,7 +149,7 @@ export interface IntelligenceHubProps {
onDismissInsight: (insight: IntelBriefingInsight) => void
onDismissBridgeSuggestion: (s: IntelBridgeSuggestion) => void
onCreateBridgeSuggestion: (s: IntelBridgeSuggestion) => void
onOpenInsightNote: (insight: IntelBriefingInsight, noteId: string) => void
onOpenInsightNote: (insight: IntelBriefingInsight, noteId: string, peekNoteId?: string | null) => void
dismissingInsightId: string | null
actingBridgeSuggestionKey: string | null
prefersReducedMotion: boolean
@@ -334,7 +334,7 @@ export function IntelligenceHub({
</button>
<button
type="button"
onClick={() => onOpenInsightNote(insight, insight.note2.id)}
onClick={() => onOpenInsightNote(insight, insight.note1.id, insight.note2.id)}
className="inline-flex items-center gap-1 text-[8.5px] font-mono uppercase font-bold px-2.5 py-1.5 rounded-lg border border-border/40 hover:border-indigo-400/40 transition-colors"
>
<GitCompare size={9} />

View File

@@ -50,8 +50,10 @@ function elementOpacity(
state: StepResolvedState,
dim: number
): number {
// Overview = full scene at full brightness (matches edges behavior)
if (state.overview) return 1
if (!(id in state.revealed)) return 0
if (state.overview || state.spotlight.length === 0) return 1
if (state.spotlight.length === 0) return 1
return state.spotlight.includes(id) ? 1 : dim
}
@@ -750,6 +752,11 @@ function HeatmapPanel({
const dim = dimOpacity(dark)
const fillIntent = spotlightColor(dark)
const ghostStroke = dark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.16)'
// Intensity ∝ value — normalized so real-world scales (not just [0,1]) work
const maxAbsV = Math.max(
1e-9,
...values.flat().map((v) => Math.abs(v))
)
const cellRect = (r: number, c: number) => ({
x: labelW + (c - 1) * cell,
@@ -778,8 +785,23 @@ function HeatmapPanel({
return map
}, [rows, cols, triangular])
const uid = useId().replace(/:/g, '')
const markerId = `demo-arrowhead-hm-${uid}`
return (
<svg viewBox={`0 0 ${w} ${h}`} className="w-full h-auto max-w-lg mx-auto">
<defs>
<marker
id={markerId}
markerWidth="9"
markerHeight="9"
refX="7"
refY="3.5"
orient="auto"
>
<path d="M0,0 L7,3.5 L0,7 Z" fill={fillIntent} />
</marker>
</defs>
{colLabels?.map((lab, i) => (
<text
key={`c-${i}`}
@@ -839,9 +861,10 @@ function HeatmapPanel({
)
}
const fillOpacity = 0.14 + v * 0.78
const vNorm = Math.min(1, Math.abs(v) / maxAbsV)
const fillOpacity = 0.14 + vNorm * 0.78
const textFill =
v > 0.45 ? (dark ? '#0a0a0a' : '#fff') : dark ? '#eee' : '#111'
vNorm > 0.45 ? (dark ? '#0a0a0a' : '#fff') : dark ? '#eee' : '#111'
return (
<g key={id} opacity={op} style={{ transition }}>
@@ -874,7 +897,7 @@ function HeatmapPanel({
<AnnotationsOverlay
annotations={state.annotations}
dark={dark}
markerId="demo-arrowhead-hm"
markerId={markerId}
getAnchor={(id, kind) => {
const p = positions.get(id)
if (!p) return null

View File

@@ -26,7 +26,7 @@ export function DemoSpeak({ speak, className }: { speak: string; className?: str
let md = marked.parse(withSlots, { gfm: true, breaks: true }) as string
placeholders.forEach((frag, i) => {
md = md.replace(`%%KATEX${i}%%`, frag)
md = md.replace(`%%KATEX${i}%%`, () => frag)
})
return sanitizeRichHtml(md)
}, [speak])

View File

@@ -141,12 +141,12 @@ export function InteractivePagePublishDialog({
setProgress(null)
return
}
// Guard: empty content from editor race
// Guard: empty content from editor race (aligned with API min 40 words)
const wordCount = content
.replace(/<[^>]+>/g, ' ')
.split(/\s+/)
.filter(Boolean).length
if (wordCount < 30) {
if (wordCount < 40) {
setPhase('error')
setError(
t('richTextEditor.publishInteractivePageTooShort') ||

View File

@@ -4,6 +4,17 @@ import { PageView } from '@/components/interactive-page/page-view'
import { validateInteractivePage, type PageSpecV1 } from '@/lib/interactive-page'
import { AlertCircle } from 'lucide-react'
const STRINGS = {
fr: {
stale: 'Le contenu source a évolué — cette page interactive est à régénérer.',
invalid: 'Page interactive indisponible',
},
en: {
stale: 'The source content has changed — this interactive page needs regeneration.',
invalid: 'Interactive page unavailable',
},
}
/**
* Public / preview shell for published interactive pages.
* Parses stored PageSpecV1 JSON from `publishedContent`.
@@ -17,20 +28,26 @@ export function InteractivePublishedPage({
}) {
let page: PageSpecV1 | null = null
let error: string | null = null
let lang = 'fr'
try {
const raw = JSON.parse(publishedContent)
const result = validateInteractivePage(raw)
if (result.ok) page = result.page
else error = result.issues[0]?.message || 'PageSpec invalide'
if (result.ok) {
page = result.page
lang = result.page.lang || 'fr'
} else {
error = result.issues[0]?.message || 'PageSpec invalide'
}
} catch {
error = 'JSON de page interactive illisible'
}
const t = lang.startsWith('fr') ? STRINGS.fr : STRINGS.en
if (!page) {
return (
<div className="mx-auto flex max-w-lg gap-3 p-10 text-sm text-destructive">
<AlertCircle className="h-5 w-5 shrink-0" />
<p>{error || 'Page interactive indisponible'}</p>
<p>{error || t.invalid}</p>
</div>
)
}
@@ -39,7 +56,7 @@ export function InteractivePublishedPage({
<div>
{isStale ? (
<div className="border-b border-amber-500/30 bg-amber-500/10 px-4 py-2 text-center text-xs text-amber-800 dark:text-amber-200">
Le contenu source a évolué cette page interactive est à régénérer.
{t.stale}
</div>
) : null}
<PageView page={page} demoMode="interactive" />

View File

@@ -17,6 +17,7 @@ import { InteractiveDemoPlayer } from '@/components/interactive-demo/interactive
import { useDarkMode } from '@/components/interactive-demo/demo-speak'
import { PageFormula, PageMd } from '@/components/interactive-page/page-md'
import { SimBlockView } from '@/components/interactive-page/sim-block'
import { StepsBlockView } from '@/components/interactive-page/steps-block'
import { intentColor } from '@/lib/interactive-demo/intent-colors'
import type { IntentId, InteractiveDemoV1 } from '@/lib/interactive-demo/types'
import type { PageBlock } from '@/lib/interactive-page'
@@ -25,16 +26,20 @@ import { cn } from '@/lib/utils'
/** Intents actually used inside a demo (legend per demo, brainstorm P11). */
function collectDemoIntents(demo: InteractiveDemoV1): IntentId[] {
const set = new Set<IntentId>()
for (const panel of demo.scene.panels) {
if (panel.type === 'svg-scene') {
for (const n of panel.payload.nodes) if (n.intent) set.add(n.intent)
for (const e of panel.payload.edges ?? []) if (e.intent) set.add(e.intent)
}
if (panel.type === 'chart') {
for (const s of panel.payload.series) if (s.intent) set.add(s.intent)
const collectFromPanels = (panels: InteractiveDemoV1['scene']['panels']) => {
for (const panel of panels) {
if (panel.type === 'svg-scene') {
for (const n of panel.payload.nodes) if (n.intent) set.add(n.intent)
for (const e of panel.payload.edges ?? []) if (e.intent) set.add(e.intent)
}
if (panel.type === 'chart') {
for (const s of panel.payload.series) if (s.intent) set.add(s.intent)
}
}
}
collectFromPanels(demo.scene.panels)
for (const act of demo.acts) {
if (act.scene) collectFromPanels(act.scene.panels)
for (const step of act.steps) {
for (const a of step.annotate ?? []) if (a.intent) set.add(a.intent)
}
@@ -280,6 +285,10 @@ export function PageBlockView({
return <SimBlockView block={block} lang={lang} />
}
if (block.type === 'steps') {
return <StepsBlockView block={block} lang={lang} />
}
if (block.type === 'chart') {
return <ChartBlockView block={block} />
}

View File

@@ -31,7 +31,8 @@ export function PageMd({
let out = marked.parse(withSlots, { gfm: true, breaks: true }) as string
placeholders.forEach((frag, i) => {
out = out.replace(`%%KATEX${i}%%`, frag)
// function replacement — $', $`, $& in KaTeX HTML must not be interpreted
out = out.replace(`%%KATEX${i}%%`, () => frag)
})
return sanitizeRichHtml(out)
}, [md])

View File

@@ -25,7 +25,11 @@ function collectIntents(page: PageSpecV1): IntentId[] {
}
}
if (block.type === 'demo') {
for (const panel of block.demo.scene.panels) {
const panels = [
...block.demo.scene.panels,
...block.demo.acts.flatMap((a) => a.scene?.panels ?? []),
]
for (const panel of panels) {
if (panel.type === 'svg-scene') {
for (const n of panel.payload.nodes) if (n.intent) set.add(n.intent)
for (const e of panel.payload.edges ?? []) if (e.intent) set.add(e.intent)

View File

@@ -79,6 +79,9 @@ export function PageView({
style={paperStyle}
>
<style>{`
@media (prefers-reduced-motion: no-preference) {
html { scroll-behavior: smooth; }
}
.interactive-page {
--pp-paper: #F4F0E8;
--pp-paper-deep: #EAE4D9;
@@ -139,6 +142,15 @@ export function PageView({
}
}
`}</style>
{/* Without JS the scroll-reveal never fires — show everything */}
<noscript>
<style>{`
.interactive-page [data-scroll-init] {
opacity: 1 !important;
transform: none !important;
}
`}</style>
</noscript>
{/* ── Hero (kicker / 800 title / ink subtitle / mono meta) ── */}
<header className="mx-auto max-w-[1100px] px-5 pb-8 pt-12 md:pt-16">

View File

@@ -0,0 +1,134 @@
'use client'
import { useMemo } from 'react'
import katex from 'katex'
import { AnimPlayerShell } from '@/components/simulators/anim-player-shell'
import type { StepsBlock } from '@/lib/interactive-page'
import { cn } from '@/lib/utils'
function renderTex(tex: string, displayMode: boolean): string {
try {
return katex.renderToString(tex, { displayMode, throwOnError: false })
} catch {
return tex
}
}
/**
* Step-by-step derivation (Symbolab/Khan style): equation states revealed
* line by line, current line highlighted, transformation rule in the margin.
* No boxes, no LLM drawing — pure KaTeX + deterministic chrome.
*/
export function StepsBlockView({
block,
lang,
}: {
block: StepsBlock
lang: string
}) {
const fr = lang.startsWith('fr')
const steps = block.steps
const beats = useMemo(
() =>
steps.map((s, i) => ({
id: `st${i + 1}`,
speak: {
fr: s.speak || s.rule || (fr ? `Étape ${i + 1}` : `Step ${i + 1}`),
en: s.speak || s.rule || `Step ${i + 1}`,
},
})),
[steps, fr]
)
const rendered = useMemo(() => steps.map((s) => renderTex(s.tex, true)), [steps])
return (
<figure
className="my-8 rounded-2xl border p-4 md:p-5"
style={{ background: 'var(--pp-card)', borderColor: 'var(--pp-line)' }}
>
{block.title ? (
<div className="mb-4 flex items-baseline justify-between gap-3">
<h3 className="text-sm font-semibold tracking-tight">{block.title}</h3>
<span
className="text-[10px] font-semibold uppercase tracking-[0.16em]"
style={{ color: 'var(--pp-muted)' }}
>
{fr ? 'Dérivation pas à pas' : 'Step-by-step derivation'}
</span>
</div>
) : null}
<AnimPlayerShell beats={beats} lang={lang}>
{(stepIndex) => (
<div className="space-y-1.5 p-3 md:p-4" style={{ background: 'var(--pp-paper)' }}>
{steps.slice(0, stepIndex + 1).map((s, i) => {
const current = i === stepIndex
return (
<div
key={i}
className={cn(
'flex items-start gap-3 rounded-lg border-l-4 px-3 py-2',
current ? 'shadow-sm' : 'border-transparent'
)}
style={{
borderLeftColor: current ? 'var(--pp-plum)' : 'transparent',
background: current ? 'var(--pp-card)' : 'transparent',
opacity: current ? 1 : 0.72,
transition: 'opacity 400ms ease, background 400ms ease',
}}
>
<span
className="mt-1 shrink-0 text-[10px] font-semibold tabular-nums"
style={{ color: 'var(--pp-muted)', fontFamily: 'var(--pp-mono)' }}
>
{String(i + 1).padStart(2, '0')}
</span>
<div
className="min-w-0 flex-1 overflow-x-auto text-[1.02em] [&_.katex-display]:my-1"
dangerouslySetInnerHTML={{ __html: rendered[i] }}
/>
{s.rule ? (
<span
className="mt-0.5 shrink-0 rounded-md px-2 py-1 text-[11px] leading-snug"
style={{
fontFamily: 'var(--pp-mono)',
color: 'var(--pp-plum)',
background: 'color-mix(in oklab, var(--pp-plum) 10%, transparent)',
maxWidth: '38%',
}}
>
{s.rule}
</span>
) : null}
</div>
)
})}
</div>
)}
</AnimPlayerShell>
{block.caption ? (
<figcaption
className="mt-3 text-center text-sm"
style={{ color: 'var(--pp-muted)' }}
>
{block.caption}
</figcaption>
) : null}
{/* Without JS: full derivation visible + rules listed */}
<noscript>
<ol className="mt-3 list-inside list-decimal space-y-2 text-sm">
{steps.map((s, i) => (
<li key={i}>
<span dangerouslySetInnerHTML={{ __html: rendered[i] }} />
{s.rule ? <em className="ml-2 text-muted-foreground">({s.rule})</em> : null}
</li>
))}
</ol>
</noscript>
</figure>
)
}

View File

@@ -9,6 +9,7 @@ import Link from 'next/link'
import Image from 'next/image'
import { useLanguage } from '@/lib/i18n'
import type { SupportedLanguage } from '@/lib/i18n/load-translations'
import { SUBSCRIPTION_TRIAL_DAYS } from '@/lib/billing/trial-constants'
import { useEffect, useRef, useState, type ReactNode } from 'react'
const ECHO_LINES = ['echo0', 'echo1', 'echo2'] as const
@@ -68,11 +69,30 @@ export function LandingPage() {
return () => { root.style.overflow = prev }
}, [menuOpen])
const trialDays = SUBSCRIPTION_TRIAL_DAYS
const PLANS = [
{ key: 'basic', popular: false, price: t('landing.pricing.basicPrice'), period: '' },
{ key: 'pro', popular: true, price: billingInterval === 'monthly' ? '9,90€' : '7,90€', period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual') },
{ key: 'business', popular: false, price: billingInterval === 'monthly' ? '29,90€' : '23,90€', period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual') },
{ key: 'enterprise', popular: false, price: billingInterval === 'monthly' ? '49,90€' : '39,90€', period: billingInterval === 'monthly' ? t('landing.pricing.perUser') : t('landing.pricing.perUserAnnual') },
{ key: 'basic', popular: false, hasTrial: false, price: t('landing.pricing.basicPrice'), period: '' },
{
key: 'pro',
popular: true,
hasTrial: true,
price: billingInterval === 'monthly' ? t('landing.pricing.proMonthly') : t('landing.pricing.proAnnualMonthly'),
period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual'),
},
{
key: 'business',
popular: false,
hasTrial: true,
price: billingInterval === 'monthly' ? t('landing.pricing.businessMonthly') : t('landing.pricing.businessAnnualMonthly'),
period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual'),
},
{
key: 'enterprise',
popular: false,
hasTrial: false,
price: t('landing.pricing.enterprisePrice'),
period: '',
},
]
const NAV = [
@@ -467,7 +487,9 @@ export function LandingPage() {
className={`px-5 py-2 rounded-full text-[12px] font-semibold transition-all relative ${billingInterval === 'annual' ? 'bg-[#F4F1EA] text-[#0B0A09]' : 'text-white/45'}`}
>
{t('landing.pricing.annual')}
<span className="absolute -top-3 -right-1 text-[10px] text-[#D4A373]">-20%</span>
<span className="absolute -top-3 -right-1 text-[10px] text-[#D4A373] whitespace-nowrap">
{t('landing.pricing.savePercent')}
</span>
</button>
</div>
</div>
@@ -493,8 +515,19 @@ export function LandingPage() {
<span className="text-3xl font-serif">{plan.price}</span>
{plan.period && <span className="text-xs text-white/35">{plan.period}</span>}
</div>
{plan.hasTrial && (
<p className="text-[11px] font-semibold text-[#D4A373] mb-3">
{t('landing.pricing.trialBadge', { days: trialDays })}
</p>
)}
<p className="text-sm text-white/45 mb-6">{t(`landing.pricing.${plan.key}.desc`)}</p>
<ul className="space-y-2.5 mb-8 flex-1">
{plan.hasTrial && (
<li className="flex gap-2 text-xs text-[#D4A373]/90">
<Check size={12} className="text-[#D4A373] mt-0.5 shrink-0" />
{t('landing.pricing.trialFeature', { days: trialDays })}
</li>
)}
{[0, 1, 2, 3, 4, 5].map((j) => {
const feat = t(`landing.pricing.${plan.key}.feature${j}`)
if (!feat || feat.startsWith('landing.')) return null
@@ -514,7 +547,9 @@ export function LandingPage() {
: 'bg-white/10 text-white hover:bg-white/15'
}`}
>
{t(`landing.pricing.${plan.key}.cta`)}
{plan.hasTrial
? t('landing.pricing.trialCta', { days: trialDays })
: t(`landing.pricing.${plan.key}.cta`)}
</Link>
</div>
))}

View File

@@ -1,12 +1,15 @@
'use client';
import { useActionState } from 'react';
import { useActionState, useRef, useState, Suspense } from 'react';
import { useFormStatus } from 'react-dom';
import { authenticate } from '@/app/actions/auth';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { Mail, Lock, ArrowRight, Sparkles } from 'lucide-react';
import { useLanguage } from '@/lib/i18n';
import { GoogleSignInButton } from '@/components/google-sign-in-button';
import { resendSignupVerification } from '@/app/actions/auth-verify';
import { toast } from 'sonner';
function AuthDivider({ label }: { label: string }) {
return (
@@ -41,7 +44,7 @@ function LoginButton() {
);
}
export function LoginForm({
function LoginFormInner({
allowRegister = true,
googleAuthEnabled = false,
authError,
@@ -51,7 +54,11 @@ export function LoginForm({
authError?: string;
}) {
const [errorMessage, dispatch] = useActionState(authenticate, undefined);
const { t } = useLanguage();
const { t, language } = useLanguage();
const searchParams = useSearchParams();
const emailRef = useRef<HTMLInputElement>(null);
const [resending, setResending] = useState(false);
const verified = searchParams.get('verified') === '1';
const oauthError =
authError === 'SessionRequired'
? t('auth.sessionExpired')
@@ -59,6 +66,21 @@ export function LoginForm({
? t('auth.oauthAccountNotLinked')
: authError ?? null;
const showUnverified = errorMessage === 'EMAIL_NOT_VERIFIED';
const handleResend = async () => {
const email = emailRef.current?.value?.trim() ?? '';
if (!email) {
toast.error(t('auth.verifyMissingEmail'));
return;
}
setResending(true);
const result = await resendSignupVerification(email, language);
setResending(false);
if (result.success) toast.success(t('auth.verifyResent'));
else toast.error(t('auth.verifyResendFailed'));
};
return (
<div className="bg-white dark:bg-[var(--background)]/50 border border-[var(--border)] p-8 md:p-10 rounded-[48px] shadow-2xl">
<div className="space-y-8">
@@ -71,6 +93,12 @@ export function LoginForm({
</p>
</div>
{verified && (
<p className="text-sm text-emerald-600 dark:text-emerald-400 text-center px-2" role="status">
{t('auth.emailVerifiedBanner')}
</p>
)}
{oauthError && (
<p className="text-sm text-red-500 text-center px-2" role="alert">
{oauthError}
@@ -94,6 +122,7 @@ export function LoginForm({
<Mail size={16} />
</div>
<input
ref={emailRef}
className="w-full bg-slate-50 dark:bg-white/5 border border-[var(--border)] rounded-2xl py-4 pl-12 pr-4 text-sm outline-none focus:border-[var(--color-brand-accent)] focus:ring-4 ring-[var(--color-brand-accent)]/5 transition-all"
id="email"
type="email"
@@ -136,13 +165,26 @@ export function LoginForm({
<LoginButton />
<div
className="flex h-8 items-end space-x-1"
aria-live="polite"
aria-atomic="true"
>
{errorMessage && (
<p className="text-sm text-red-500">{errorMessage}</p>
<div className="space-y-2" aria-live="polite" aria-atomic="true">
{showUnverified && (
<div className="rounded-2xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 space-y-2">
<p className="text-sm text-amber-800 dark:text-amber-200">{t('auth.emailNotVerified')}</p>
<button
type="button"
onClick={handleResend}
disabled={resending}
className="text-[11px] font-bold uppercase tracking-widest text-[var(--color-brand-accent)] hover:underline disabled:opacity-50"
>
{resending ? t('auth.sending') : t('auth.resendVerification')}
</button>
</div>
)}
{errorMessage && !showUnverified && (
<p className="text-sm text-red-500">
{errorMessage === 'Invalid credentials.'
? t('auth.invalidCredentials')
: errorMessage}
</p>
)}
</div>
</form>
@@ -164,3 +206,15 @@ export function LoginForm({
</div>
);
}
export function LoginForm(props: {
allowRegister?: boolean;
googleAuthEnabled?: boolean;
authError?: string;
}) {
return (
<Suspense fallback={<div className="p-10 text-center text-sm text-muted-foreground"></div>}>
<LoginFormInner {...props} />
</Suspense>
);
}

View File

@@ -24,10 +24,19 @@ interface NoteEditorPeekHostProps {
export function NoteEditorPeekHost({ noteId, fullPage, children }: NoteEditorPeekHostProps) {
const router = useRouter()
const searchParams = useSearchParams()
const peekNoteId = searchParams.get('peekNote')
const { t, language } = useLanguage()
const isRtl = language === 'fa' || language === 'ar'
const [peekState, setPeekState] = useState<{ note: Note; blockId?: string } | null>(null)
const stripPeekFromUrl = useCallback(() => {
if (!searchParams.get('peekNote')) return
const params = new URLSearchParams(searchParams.toString())
params.delete('peekNote')
const qs = params.toString()
router.replace(qs ? `/home?${qs}` : '/home', { scroll: false })
}, [router, searchParams])
useEffect(() => {
const onOpenPeek = (event: Event) => {
const detail = (event as CustomEvent<NotePeekOpenDetail>).detail
@@ -52,9 +61,25 @@ export function NoteEditorPeekHost({ noteId, fullPage, children }: NoteEditorPee
}
}, [noteId, t])
// Dashboard « Comparer / Lier » : ouvrir la note liée à droite dès que léditeur est monté.
useEffect(() => {
if (!peekNoteId || peekNoteId === noteId) return
let cancelled = false
void getNoteById(peekNoteId).then((fetched) => {
if (cancelled) return
if (fetched) {
setPeekState(prev => (prev?.note.id === fetched.id ? prev : { note: fetched }))
} else {
toast.error(t('notePeek.loadFailed'))
}
})
return () => { cancelled = true }
}, [peekNoteId, noteId, t])
const handleClosePeek = useCallback(() => {
setPeekState(null)
}, [])
stripPeekFromUrl()
}, [stripPeekFromUrl])
const handleOpenPeekFully = useCallback(() => {
if (!peekState) return
@@ -63,6 +88,7 @@ export function NoteEditorPeekHost({ noteId, fullPage, children }: NoteEditorPee
}))
const params = new URLSearchParams(searchParams.toString())
params.set('openNote', peekState.note.id)
params.delete('peekNote')
router.replace(params.toString() ? `/home?${params.toString()}` : '/home', { scroll: false })
setPeekState(null)
}, [noteId, peekState, router, searchParams])
@@ -82,6 +108,11 @@ export function NoteEditorPeekHost({ noteId, fullPage, children }: NoteEditorPee
blockId={peekState.blockId}
onClose={handleClosePeek}
onOpenFully={handleOpenPeekFully}
onBackToDashboard={
searchParams.get('from') === 'dashboard'
? () => router.replace('/home')
: undefined
}
/>
)}
</AnimatePresence>

View File

@@ -2,7 +2,7 @@
import { useEffect, useRef } from 'react'
import { motion } from 'framer-motion'
import { X, Maximize2 } from 'lucide-react'
import { X, Maximize2, LayoutGrid } from 'lucide-react'
import type { Note } from '@/lib/types'
import { useLanguage } from '@/lib/i18n'
import { NoteEditorProvider, useNoteEditorContext } from './note-editor-context'
@@ -17,6 +17,7 @@ interface NoteEditorSplitPeekProps {
blockId?: string
onClose: () => void
onOpenFully: () => void
onBackToDashboard?: () => void
}
function PeekEditorBody({ blockId }: { blockId?: string }) {
@@ -54,7 +55,7 @@ function PeekEditorBody({ blockId }: { blockId?: string }) {
)
}
export function NoteEditorSplitPeek({ note, blockId, onClose, onOpenFully }: NoteEditorSplitPeekProps) {
export function NoteEditorSplitPeek({ note, blockId, onClose, onOpenFully, onBackToDashboard }: NoteEditorSplitPeekProps) {
const { t, language } = useLanguage()
const isRtl = language === 'fa' || language === 'ar'
@@ -77,6 +78,16 @@ export function NoteEditorSplitPeek({ note, blockId, onClose, onOpenFully }: Not
{t('notePeek.label')}
</span>
<div className="flex items-center gap-1 shrink-0">
{onBackToDashboard && (
<button
type="button"
onClick={onBackToDashboard}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wide text-ink dark:text-dark-ink hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
>
<LayoutGrid size={12} />
{t('notes.backToDashboard')}
</button>
)}
<button
type="button"
onClick={onOpenFully}

View File

@@ -1,6 +1,7 @@
'use client'
import { useState, useRef, useCallback, useEffect } from 'react'
import { useSearchParams } from 'next/navigation'
import { useNoteEditorContext } from './note-editor-context'
import { LabelManager } from '@/components/label-manager'
import { LabelBadge } from '@/components/label-badge'
@@ -48,6 +49,8 @@ interface NoteEditorToolbarProps {
export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachmentsCount }: NoteEditorToolbarProps) {
const { state, actions, note, readOnly, fullPage, notebooks, fileInputRef, richTextEditorRef } = useNoteEditorContext()
const { t, language } = useLanguage()
const searchParams = useSearchParams()
const fromDashboard = searchParams.get('from') === 'dashboard' || Boolean(searchParams.get('peekNote'))
const { requestAiConsent } = useAiConsent()
const [isConverting, setIsConverting] = useState(false)
const [shareOpen, setShareOpen] = useState(false)
@@ -355,19 +358,24 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
const handlePublishInteractivePage = async () => {
if (publishLoading) return
const consented = await requestAiConsent()
if (!consented) return
if (state.isDirty && !state.isSaving) {
await actions.handleSaveInPlace()
setPublishLoading(true)
try {
const consented = await requestAiConsent()
if (!consented) return
if (state.isDirty && !state.isSaving) {
await actions.handleSaveInPlace()
}
const html =
richTextEditorRef?.current?.getEditor()?.getHTML?.() ||
state.content ||
note.content ||
''
setInteractivePageContent(html)
setPublishOpen(false)
setInteractivePageOpen(true)
} finally {
setPublishLoading(false)
}
const html =
richTextEditorRef?.current?.getEditor()?.getHTML?.() ||
state.content ||
note.content ||
''
setInteractivePageContent(html)
setPublishOpen(false)
setInteractivePageOpen(true)
}
const handlePublishWithAi = async () => {
@@ -564,7 +572,9 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
className="flex items-center gap-2 text-foreground hover:opacity-60 transition-opacity"
>
<ArrowLeft size={18} />
<span className="text-sm font-medium">{t('notes.backToCollection')}</span>
<span className="text-sm font-medium">
{fromDashboard ? t('notes.backToDashboard') : t('notes.backToCollection')}
</span>
</button>
<div className="flex items-center gap-1.5 sm:gap-2">

View File

@@ -10,6 +10,7 @@ import { toast } from 'sonner';
import { format } from 'date-fns';
import { motion } from 'motion/react';
import { BillingHistory } from './billing-history';
import { SUBSCRIPTION_TRIAL_DAYS } from '@/lib/billing/trial-constants';
type Tier = 'PRO' | 'BUSINESS';
type Interval = 'month' | 'year';
@@ -22,6 +23,9 @@ interface BillingStatus {
currentPeriodEnd: string | null;
cancelAtPeriodEnd: boolean;
hasStripeSubscription: boolean;
trialEndsAt?: string | null;
trialEligible?: boolean;
trialDays?: number;
billingEnabled?: boolean;
prices?: {
PRO: {
@@ -250,6 +254,12 @@ export function BillingPlans() {
const effectiveTier = status?.effectiveTier ?? 'BASIC';
const isPaid = effectiveTier !== 'BASIC';
const isTrialing = (status?.status ?? '').toUpperCase() === 'TRIALING';
const trialEligible = !!status?.trialEligible;
const trialDays = status?.trialDays ?? SUBSCRIPTION_TRIAL_DAYS;
const trialCta = (fallback: string) =>
trialEligible ? t('billing.startTrialCta', { days: trialDays }) : fallback;
const plans = [
{
@@ -280,6 +290,7 @@ export function BillingPlans() {
period: interval === 'month' ? t('billing.perMonth') : t('billing.perYear'),
description: t('billing.proDescription') || 'Pour les consultants et créateurs exigeants.',
features: [
...(trialEligible ? [t('billing.trialFeature', { days: trialDays })] : []),
t('billing.proFeature1'),
t('billing.proFeature2'),
t('billing.proFeature3'),
@@ -289,7 +300,7 @@ export function BillingPlans() {
],
current: effectiveTier === 'PRO',
popular: true,
buttonText: effectiveTier === 'PRO' ? (t('billing.currentPlan') || 'Plan Actuel') : (t('billing.proCta') || 'Passer au Plan Pro'),
buttonText: effectiveTier === 'PRO' ? (t('billing.currentPlan') || 'Plan Actuel') : trialCta(t('billing.proCta') || 'Passer au Plan Pro'),
buttonClass: effectiveTier === 'PRO'
? 'bg-paper text-concrete cursor-default'
: 'bg-brand-accent text-white shadow-xl shadow-brand-accent/20 hover:scale-[1.02] active:scale-95',
@@ -302,6 +313,7 @@ export function BillingPlans() {
(interval === 'month' ? (t('billing.businessPrice') || '29,90€') : (t('billing.businessAnnualPrice') || '299€')),
period: interval === 'month' ? t('billing.perMonth') : t('billing.perYear'),
features: [
...(trialEligible ? [t('billing.trialFeature', { days: trialDays })] : []),
t('billing.businessFeature1'),
t('billing.businessFeature2'),
t('billing.businessFeature3'),
@@ -310,7 +322,7 @@ export function BillingPlans() {
t('billing.businessFeature6'),
],
current: effectiveTier === 'BUSINESS',
buttonText: effectiveTier === 'BUSINESS' ? (t('billing.currentPlan') || 'Plan Actuel') : (t('billing.businessCta') || 'Choisir Plan Business'),
buttonText: effectiveTier === 'BUSINESS' ? (t('billing.currentPlan') || 'Plan Actuel') : trialCta(t('billing.businessCta') || 'Choisir Plan Business'),
buttonClass: effectiveTier === 'BUSINESS'
? 'bg-paper text-concrete cursor-default'
: 'bg-ink text-white shadow-xl shadow-ink/20 hover:scale-[1.02] active:scale-95',
@@ -415,9 +427,11 @@ export function BillingPlans() {
<div className="ml-auto">
<span className={cn(
'px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest',
status?.status === 'active' || status?.status === 'ACTIVE'
? 'bg-primary/10 text-primary/80 dark:text-primary border border-primary/20'
: 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20'
isTrialing
? 'bg-sky-500/10 text-sky-700 dark:text-sky-300 border border-sky-500/20'
: status?.status === 'active' || status?.status === 'ACTIVE'
? 'bg-primary/10 text-primary/80 dark:text-primary border border-primary/20'
: 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20'
)}>
{status?.status ? t(`billing.${status.status.toLowerCase()}`) || status.status : t('billing.active')}
</span>
@@ -425,6 +439,12 @@ export function BillingPlans() {
)}
</div>
{isTrialing && status?.trialEndsAt && (
<p className="text-xs text-sky-700 dark:text-sky-300 bg-sky-500/10 border border-sky-500/20 rounded-xl px-3 py-2">
{t('billing.trialEndsOn', { date: formatDate(status.trialEndsAt) })}
</p>
)}
{isPaid && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-4 border-t border-border/40">
<div className="space-y-1">
@@ -439,10 +459,18 @@ export function BillingPlans() {
</div>
<div className="space-y-1">
<span className="text-[10px] text-concrete uppercase tracking-wider">
{status?.cancelAtPeriodEnd ? t('billing.expiresOn') : t('billing.nextBillingDate')}
{isTrialing
? t('billing.trialEndsLabel')
: status?.cancelAtPeriodEnd
? t('billing.expiresOn')
: t('billing.nextBillingDate')}
</span>
<p className="text-xs font-semibold text-ink">
{status?.currentPeriodEnd ? formatDate(status.currentPeriodEnd) : '—'}
{isTrialing && status?.trialEndsAt
? formatDate(status.trialEndsAt)
: status?.currentPeriodEnd
? formatDate(status.currentPeriodEnd)
: '—'}
</p>
</div>
</div>
@@ -619,6 +647,7 @@ export function BillingPlans() {
brainstorm_expand: t('usageMeter.featureBrainstormExpand'),
brainstorm_enrich: t('usageMeter.featureBrainstormEnrich'),
suggest_charts: t('usageMeter.featureCharts'),
interactive_demo: t('usageMeter.featureInteractiveDemo'),
publish_enhance: t('usageMeter.featurePublishEnhance'),
ai_flashcard: t('usageMeter.featureFlashcards'),
voice_transcribe: t('usageMeter.featureVoice'),

View File

@@ -1433,7 +1433,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
label: t('nav.dashboard') || 'Dashboard',
onClick: () => {
setActiveView('dashboard')
router.push('/home')
router.replace('/home')
},
isActive: isDashboardRoute,
},

View File

@@ -63,14 +63,16 @@ export function GenericFormulaView({
const xParam = sim.params.find((p) => p.id === visual.xParamId)
const parsed = parseSimExpr(visual.expr)
if (!xParam || 'message' in parsed) return null
// Full env: params + chained computed (visual.expr may reference computed ids)
const fullEnv = { ...values, ...results }
const pts: { x: number; y: number }[] = []
for (let i = 0; i <= CURVE_SAMPLES; i++) {
const x = xParam.min + ((xParam.max - xParam.min) * i) / CURVE_SAMPLES
const y = parsed.evaluate({ ...values, [xParam.id]: x })
const y = parsed.evaluate({ ...fullEnv, [xParam.id]: x })
pts.push({ x: Number(x.toFixed(4)), y: Number.isFinite(y) ? Number(y.toFixed(6)) : 0 })
}
return { pts, xParam, currentX: values[xParam.id], currentY: parsed.evaluate(values) }
}, [visual, sim.params, values])
return { pts, xParam, currentX: values[xParam.id], currentY: parsed.evaluate(fullEnv) }
}, [visual, sim.params, values, results])
const accent = intentColor('highlight', dark)
const gridStroke = dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'