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>
283 lines
9.0 KiB
TypeScript
283 lines
9.0 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useState, type CSSProperties } from 'react'
|
||
import { PageBlockView, IntentBadge } from '@/components/interactive-page/page-blocks'
|
||
import { PageMd } from '@/components/interactive-page/page-md'
|
||
import { PageStickyNav } from '@/components/interactive-page/page-sticky-nav'
|
||
import type { PageSpecV1 } from '@/lib/interactive-page'
|
||
import { cn } from '@/lib/utils'
|
||
|
||
export type PageViewProps = {
|
||
page: PageSpecV1
|
||
/** interactive = hydrate demo players; static = final-state only (SSR/noscript) */
|
||
demoMode?: 'interactive' | 'static'
|
||
className?: string
|
||
paper?: boolean
|
||
}
|
||
|
||
function usePrefersReducedMotion(): boolean {
|
||
const [reduced, setReduced] = useState(false)
|
||
useEffect(() => {
|
||
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||
setReduced(mq.matches)
|
||
const onChange = () => setReduced(mq.matches)
|
||
mq.addEventListener('change', onChange)
|
||
return () => mq.removeEventListener('change', onChange)
|
||
}, [])
|
||
return reduced
|
||
}
|
||
|
||
function useScrollReveal(enabled: boolean) {
|
||
useEffect(() => {
|
||
if (!enabled) {
|
||
document
|
||
.querySelectorAll<HTMLElement>('[data-scroll-init]')
|
||
.forEach((el) => el.setAttribute('data-scroll-visible', 'true'))
|
||
return
|
||
}
|
||
const nodes = Array.from(
|
||
document.querySelectorAll<HTMLElement>('[data-scroll-init]')
|
||
)
|
||
const obs = new IntersectionObserver(
|
||
(entries) => {
|
||
for (const e of entries) {
|
||
if (e.isIntersecting) {
|
||
e.target.setAttribute('data-scroll-visible', 'true')
|
||
obs.unobserve(e.target)
|
||
}
|
||
}
|
||
},
|
||
{ rootMargin: '0px 0px -8% 0px', threshold: 0.12 }
|
||
)
|
||
nodes.forEach((n) => obs.observe(n))
|
||
return () => obs.disconnect()
|
||
}, [enabled])
|
||
}
|
||
|
||
/**
|
||
* PageView — Kimi/AttnRes-style explainer page.
|
||
* Design tokens replicated from the reference page (paper, plum accent,
|
||
* mono labels, formula left-bar, stat cards) with dark-mode adaptation.
|
||
*/
|
||
export function PageView({
|
||
page,
|
||
demoMode = 'interactive',
|
||
className,
|
||
paper = true,
|
||
}: PageViewProps) {
|
||
const reducedMotion = usePrefersReducedMotion()
|
||
useScrollReveal(!reducedMotion)
|
||
|
||
const paperStyle = {
|
||
'--page-paper': paper ? 'var(--pp-paper)' : 'var(--background)',
|
||
} as CSSProperties
|
||
|
||
return (
|
||
<article
|
||
className={cn('interactive-page min-h-screen', paper && 'page-paper', className)}
|
||
data-page-id={page.id}
|
||
style={paperStyle}
|
||
>
|
||
<style>{`
|
||
@media (prefers-reduced-motion: no-preference) {
|
||
html { scroll-behavior: smooth; }
|
||
}
|
||
.interactive-page {
|
||
--pp-paper: #F4F0E8;
|
||
--pp-paper-deep: #EAE4D9;
|
||
--pp-card: #FFFDF8;
|
||
--pp-ink: #242422;
|
||
--pp-muted: #686762;
|
||
--pp-line: #D6D0C6;
|
||
--pp-plum: #9F3F70;
|
||
--pp-plum-soft: #F2DCE8;
|
||
--pp-blue: #3F6F9F;
|
||
--pp-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||
color: var(--pp-ink);
|
||
}
|
||
.dark .interactive-page {
|
||
--pp-paper: #17140F;
|
||
--pp-paper-deep: #201C15;
|
||
--pp-card: #221E17;
|
||
--pp-ink: #EDE7DB;
|
||
--pp-muted: #A39C8D;
|
||
--pp-line: #3B352A;
|
||
--pp-plum: #D07AA6;
|
||
--pp-plum-soft: #3A2530;
|
||
--pp-blue: #7FA8CC;
|
||
}
|
||
.interactive-page.page-paper {
|
||
background-color: var(--pp-paper);
|
||
background-image: radial-gradient(
|
||
color-mix(in oklab, var(--pp-ink) 7%, transparent) 0.7px,
|
||
transparent 0.7px
|
||
);
|
||
background-size: 18px 18px;
|
||
}
|
||
.pp-card {
|
||
background: var(--pp-card);
|
||
border: 1px solid var(--pp-line);
|
||
border-radius: 14px;
|
||
}
|
||
.pp-mono {
|
||
font-family: var(--pp-mono);
|
||
}
|
||
.interactive-page [data-scroll-init]:not([data-scroll-visible]) {
|
||
opacity: 0;
|
||
transform: translateY(12px);
|
||
}
|
||
.interactive-page [data-scroll-init] {
|
||
transition: opacity 420ms ease, transform 420ms ease;
|
||
will-change: opacity, transform;
|
||
}
|
||
.interactive-page [data-scroll-visible] {
|
||
opacity: 1;
|
||
transform: none;
|
||
}
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.interactive-page [data-scroll-init] {
|
||
opacity: 1 !important;
|
||
transform: none !important;
|
||
transition: none !important;
|
||
}
|
||
}
|
||
`}</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">
|
||
<p
|
||
className="pp-mono mb-2.5 text-xs uppercase"
|
||
style={{ letterSpacing: '0.14em', color: 'var(--pp-plum)' }}
|
||
>
|
||
{page.hero.kicker}
|
||
</p>
|
||
<h1
|
||
className="max-w-[22ch] font-extrabold leading-[1.14] tracking-[-0.015em]"
|
||
style={{ fontSize: 'clamp(26px, 3.4vw, 44px)' }}
|
||
>
|
||
{page.hero.title}
|
||
</h1>
|
||
{page.hero.subtitle ? (
|
||
<p
|
||
className="mt-2.5 font-semibold"
|
||
style={{ fontSize: 'clamp(16px, 1.6vw, 21px)' }}
|
||
>
|
||
{page.hero.subtitle}
|
||
</p>
|
||
) : null}
|
||
{page.hero.meta ? (
|
||
<p
|
||
className="pp-mono mt-3 leading-relaxed"
|
||
style={{ fontSize: '12.5px', color: 'var(--pp-muted)' }}
|
||
>
|
||
{page.hero.meta}
|
||
</p>
|
||
) : null}
|
||
</header>
|
||
|
||
<PageStickyNav page={page} />
|
||
|
||
<div className="mx-auto max-w-[1100px] px-5 pb-24 pt-10">
|
||
{/* ── One-minute overview ── */}
|
||
{page.overview ? (
|
||
<section className="pp-card mb-20 p-6 shadow-sm md:p-8" data-scroll-init>
|
||
<p
|
||
className="pp-mono mb-3 text-[11px] uppercase"
|
||
style={{ letterSpacing: '0.14em', color: 'var(--pp-muted)' }}
|
||
>
|
||
{page.lang.startsWith('fr')
|
||
? 'L’essentiel en une minute'
|
||
: 'One-minute overview'}
|
||
</p>
|
||
<PageMd
|
||
md={page.overview.lead}
|
||
className="max-w-[75ch] text-lg leading-relaxed [&_p]:my-0"
|
||
/>
|
||
<div className="mt-5 grid gap-3.5 sm:grid-cols-2 lg:grid-cols-3">
|
||
{page.overview.cards.map((card) => (
|
||
<div
|
||
key={card.badge + card.title}
|
||
className="rounded-xl border p-4"
|
||
style={{
|
||
background: 'var(--pp-paper)',
|
||
borderColor: 'var(--pp-line)',
|
||
}}
|
||
>
|
||
<IntentBadge label={card.badge} intent={card.intent} />
|
||
<h3 className="mt-3 text-base font-semibold tracking-tight">
|
||
{card.title}
|
||
</h3>
|
||
<PageMd
|
||
md={card.body}
|
||
className="mt-1.5 text-sm text-muted-foreground [&_p]:my-0"
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
|
||
{/* ── Sections ── */}
|
||
<div className="space-y-20">
|
||
{page.sections.map((section, i) => (
|
||
<section
|
||
key={section.id}
|
||
id={section.id}
|
||
className="scroll-mt-28"
|
||
data-scroll-init
|
||
>
|
||
<h2 className="mb-6 flex items-baseline gap-3 text-2xl font-extrabold tracking-tight md:text-[28px]">
|
||
<span
|
||
className="pp-mono text-sm font-semibold"
|
||
style={{ color: 'var(--pp-plum)' }}
|
||
>
|
||
{String(i + 1).padStart(2, '0')}
|
||
</span>
|
||
{section.title}
|
||
</h2>
|
||
<div>
|
||
{section.blocks.map((block, bi) => {
|
||
const narrow =
|
||
block.type === 'prose' ||
|
||
block.type === 'formula' ||
|
||
block.type === 'callout'
|
||
return (
|
||
<div
|
||
key={`${section.id}-${bi}`}
|
||
className={narrow ? 'max-w-[75ch]' : 'max-w-[880px]'}
|
||
>
|
||
<PageBlockView
|
||
block={block}
|
||
demoMode={demoMode}
|
||
lang={page.lang}
|
||
/>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</section>
|
||
))}
|
||
</div>
|
||
|
||
{page.footer ? (
|
||
<footer
|
||
className="pp-mono mt-16 border-t pt-5 text-xs italic"
|
||
style={{ borderColor: 'var(--pp-line)', color: 'var(--pp-muted)' }}
|
||
>
|
||
{page.footer}
|
||
</footer>
|
||
) : null}
|
||
</div>
|
||
</article>
|
||
)
|
||
}
|