feat: dashboard Second Brain, essai 7 jours et vérification e-mail
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:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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') ||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
134
memento-note/components/interactive-page/steps-block.tsx
Normal file
134
memento-note/components/interactive-page/steps-block.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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)'
|
||||
|
||||
Reference in New Issue
Block a user