feat(credits): solde IA unique (option M), packs Stripe et polish billing

Un pot de crédits global (BASIC 100 / PRO 1 000 / BUSINESS 4 000) remplace
les seaux par fonction côté débit. Packs one-shot S/M/L, admin sans seaux
legacy, usage multi-features, hints de coût, anti-flash thème et hydratation
admin. Inclut aussi le pipeline slides renforcé et la page publique polishée.
This commit is contained in:
Antigravity
2026-07-16 20:43:18 +00:00
parent 704fed1191
commit 556a0b2f3f
77 changed files with 35745 additions and 10430 deletions

View File

@@ -0,0 +1,42 @@
'use client'
import { useState, type CSSProperties } from 'react'
import { Check, Link2 } from 'lucide-react'
export function CopyLinkButton({
label = 'Copier le lien',
copiedLabel = 'Copié',
className,
style,
}: {
label?: string
copiedLabel?: string
className?: string
style?: CSSProperties
}) {
const [copied, setCopied] = useState(false)
const onCopy = async () => {
try {
await navigator.clipboard.writeText(window.location.href)
setCopied(true)
window.setTimeout(() => setCopied(false), 2000)
} catch {
// ignore
}
}
return (
<button
type="button"
onClick={() => void onCopy()}
className={className}
style={style}
aria-label={copied ? copiedLabel : label}
title={copied ? copiedLabel : label}
>
{copied ? <Check size={13} strokeWidth={2.25} /> : <Link2 size={13} strokeWidth={2.25} />}
<span>{copied ? copiedLabel : label}</span>
</button>
)
}

View File

@@ -0,0 +1,77 @@
'use client'
import { useEffect, useState } from 'react'
/**
* Thin reading progress bar. Tracks the nearest scrollable ancestor
* (public layout uses a fixed-height overflow container, not window).
*/
export function ReadingProgress({ accent = '#A47148' }: { accent?: string }) {
const [progress, setProgress] = useState(0)
useEffect(() => {
const findScrollRoot = (): HTMLElement | Window => {
let el: HTMLElement | null = document.querySelector('[data-pub-root]') as HTMLElement | null
while (el) {
const { overflowY } = getComputedStyle(el)
if (overflowY === 'auto' || overflowY === 'scroll') return el
el = el.parentElement
}
return window
}
const root = findScrollRoot()
const update = () => {
let scrollTop: number
let scrollHeight: number
let clientHeight: number
if (root === window) {
scrollTop = window.scrollY
scrollHeight = document.documentElement.scrollHeight
clientHeight = window.innerHeight
} else {
const node = root as HTMLElement
scrollTop = node.scrollTop
scrollHeight = node.scrollHeight
clientHeight = node.clientHeight
}
const max = Math.max(1, scrollHeight - clientHeight)
setProgress(Math.min(100, Math.max(0, (scrollTop / max) * 100)))
}
update()
root.addEventListener('scroll', update, { passive: true })
window.addEventListener('resize', update, { passive: true })
return () => {
root.removeEventListener('scroll', update)
window.removeEventListener('resize', update)
}
}, [])
return (
<div
aria-hidden
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
height: 3,
zIndex: 100,
background: 'transparent',
pointerEvents: 'none',
}}
>
<div
style={{
height: '100%',
width: `${progress}%`,
background: accent,
boxShadow: `0 0 12px ${accent}66`,
transition: 'width 80ms linear',
}}
/>
</div>
)
}