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>
156 lines
4.8 KiB
TypeScript
156 lines
4.8 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useMemo, useState } from 'react'
|
|
import type { PageSpecV1 } from '@/lib/interactive-page'
|
|
import { intentColor } from '@/lib/interactive-demo/intent-colors'
|
|
import type { IntentId } from '@/lib/interactive-demo/types'
|
|
import { useDarkMode } from '@/components/interactive-demo/demo-speak'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
function collectIntents(page: PageSpecV1): IntentId[] {
|
|
const set = new Set<IntentId>()
|
|
for (const card of page.overview?.cards ?? []) {
|
|
if (card.intent) set.add(card.intent)
|
|
}
|
|
for (const section of page.sections) {
|
|
for (const block of section.blocks) {
|
|
if (block.type === 'stats') {
|
|
for (const item of block.items) {
|
|
if (item.intent) set.add(item.intent)
|
|
}
|
|
}
|
|
if (block.type === 'chart') {
|
|
for (const s of block.payload.series) {
|
|
if (s.intent) set.add(s.intent)
|
|
}
|
|
}
|
|
if (block.type === 'demo') {
|
|
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)
|
|
}
|
|
if (panel.type === 'chart') {
|
|
for (const s of panel.payload.series) if (s.intent) set.add(s.intent)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return [...set]
|
|
}
|
|
|
|
const INTENT_LABELS_FR: Record<IntentId, string> = {
|
|
highlight: 'Focus',
|
|
flow: 'Flux',
|
|
cache: 'Mémoire',
|
|
compute: 'Calcul',
|
|
output: 'Résultat',
|
|
warning: 'Attention',
|
|
}
|
|
|
|
const INTENT_LABELS_EN: Record<IntentId, string> = {
|
|
highlight: 'Focus',
|
|
flow: 'Flow',
|
|
cache: 'Memory',
|
|
compute: 'Compute',
|
|
output: 'Result',
|
|
warning: 'Warning',
|
|
}
|
|
|
|
export function PageStickyNav({ page }: { page: PageSpecV1 }) {
|
|
const [active, setActive] = useState(page.sections[0]?.id ?? '')
|
|
const dark = useDarkMode()
|
|
const intents = useMemo(() => collectIntents(page), [page])
|
|
|
|
useEffect(() => {
|
|
const nodes = page.sections
|
|
.map((s) => document.getElementById(s.id))
|
|
.filter(Boolean) as HTMLElement[]
|
|
if (!nodes.length) return
|
|
|
|
const obs = new IntersectionObserver(
|
|
(entries) => {
|
|
const visible = entries
|
|
.filter((e) => e.isIntersecting)
|
|
.sort((a, b) => b.intersectionRatio - a.intersectionRatio)
|
|
const top = visible[0]?.target?.id
|
|
if (top) setActive(top)
|
|
},
|
|
{ rootMargin: '-20% 0px -55% 0px', threshold: [0.1, 0.25, 0.5] }
|
|
)
|
|
nodes.forEach((n) => obs.observe(n))
|
|
return () => obs.disconnect()
|
|
}, [page.sections])
|
|
|
|
return (
|
|
<div
|
|
className="sticky top-0 z-30 backdrop-blur-sm"
|
|
style={{
|
|
background: 'color-mix(in oklab, var(--pp-paper) 96%, transparent)',
|
|
borderTop: '1px solid var(--pp-line)',
|
|
borderBottom: '1px solid var(--pp-line)',
|
|
}}
|
|
>
|
|
<nav
|
|
className="mx-auto flex max-w-[1100px] gap-1 overflow-x-auto px-5 py-2.5 scrollbar-thin"
|
|
aria-label="Sections"
|
|
style={{ fontFamily: 'var(--pp-mono)' }}
|
|
>
|
|
{page.sections.map((s, i) => (
|
|
<a
|
|
key={s.id}
|
|
href={`#${s.id}`}
|
|
className={cn(
|
|
'shrink-0 rounded-lg px-2.5 py-1.5 text-xs transition-colors',
|
|
active === s.id ? 'font-semibold' : 'hover:opacity-80'
|
|
)}
|
|
style={
|
|
active === s.id
|
|
? { background: 'var(--pp-ink)', color: 'var(--pp-paper)' }
|
|
: { color: 'var(--pp-muted)' }
|
|
}
|
|
>
|
|
{i + 1} · {s.title}
|
|
</a>
|
|
))}
|
|
</nav>
|
|
{intents.length > 0 ? (
|
|
<div
|
|
className="mx-auto flex max-w-[1100px] flex-wrap gap-3 px-5 py-2 text-[11px]"
|
|
style={{
|
|
borderTop: '1px solid var(--pp-line)',
|
|
fontFamily: 'var(--pp-mono)',
|
|
color: 'var(--pp-muted)',
|
|
}}
|
|
>
|
|
<span className="font-semibold uppercase tracking-[0.14em] text-[10px]">
|
|
{page.lang.startsWith('fr') ? 'Légende' : 'Legend'}
|
|
</span>
|
|
{intents.map((id) => {
|
|
const color = intentColor(id, dark)
|
|
return (
|
|
<span
|
|
key={id}
|
|
className="inline-flex items-center gap-1.5"
|
|
>
|
|
<span
|
|
className="inline-block h-2.5 w-2.5 rounded-full"
|
|
style={{ backgroundColor: color }}
|
|
/>
|
|
{page.lang.startsWith('fr')
|
|
? INTENT_LABELS_FR[id]
|
|
: INTENT_LABELS_EN[id]}
|
|
</span>
|
|
)
|
|
})}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|