Merge branch 'seo/1-landing' into seo/integration

This commit is contained in:
IliesAllali committed 2026-09-25 12:02:31 +02:00
commit c1bcb57ed5
8 files changed
+70 -19

No files matched your search

+1
View File
@@ -3,6 +3,7 @@
# Build outputs
**/dist/
**/dist-ssr/
**/build/
**/.next/
**/coverage/
+3 -1
View File
@@ -1,2 +1,4 @@
# L'app n'est pas à indexer. L'exploration reste permise pour que les moteurs lisent
# la balise <meta name="robots" content="noindex, nofollow"> de index.html (un Disallow la masque).
User-agent: *
Disallow: /
Allow: /
+6 -4
View File
@@ -15,7 +15,7 @@
<meta property="og:locale" content="fr_FR" />
<meta property="og:url" content="https://parenthese.io/" />
<meta property="og:title" content="Parenthèse : l'arbre généalogique vivant de votre famille" />
<meta property="og:description" content="Une galaxie de votre famille. Des photos, des anecdotes, des voix. Partageable en un lien, sans compte, sans friction." />
<meta property="og:description" content="L'arbre généalogique de votre famille en ligne, avec photos, voix et souvenirs. Partageable en un lien, sans compte." />
<meta property="og:image" content="https://parenthese.io/og-image.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
@@ -24,13 +24,13 @@
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Parenthèse : l'arbre généalogique vivant de votre famille" />
<meta name="twitter:description" content="Une galaxie de votre famille. Partageable en un lien, sans compte, sans friction." />
<meta name="twitter:description" content="L'arbre généalogique de votre famille en ligne, avec photos, voix et souvenirs. Partageable en un lien, sans compte." />
<meta name="twitter:image" content="https://parenthese.io/og-image.png" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<!-- Google Fonts : Newsreader et DM Sans (DA Lavande) -->
<!-- Google Fonts : Newsreader et DM Sans (DA terracotta) -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;1,6..72,300&family=DM+Sans:opsz,wght@9..40,400;9..40,500&display=swap" rel="stylesheet" />
@@ -63,7 +63,7 @@
"applicationCategory": "LifestyleApplication",
"operatingSystem": "Web",
"inLanguage": "fr-FR",
"description": "Web app pour construire et partager la mémoire d'une famille : arbre en forme de galaxie, photos, anecdotes, vidéos et voix, partage par lien.",
"description": "Web app pour créer l'arbre généalogique d'une famille en ligne, avec photos, anecdotes, vidéos et voix, partageable en un lien sans compte.",
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "EUR" },
"publisher": { "@id": "https://parenthese.io/#organization" }
}
@@ -72,6 +72,8 @@
</script>
</head>
<body>
<!-- Sans JavaScript, le contenu prérendu reste visible (les révélations au défilement demandent JS) -->
<noscript><style>[data-reveal] { opacity: 1; transform: none; }</style></noscript>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"build": "vite build && vite build --ssr src/entry-server.jsx --outDir dist-ssr && node scripts/prerender.mjs",
"preview": "vite preview",
"test": "node --test"
},
+21
View File
@@ -0,0 +1,21 @@
// Injecte le HTML de la landing, rendu par React à la compilation, dans dist/index.html.
// Lancé par `npm run build` après le build client (dist/) et le build SSR (dist-ssr/).
import { readFile, rm, writeFile } from 'node:fs/promises'
import { fileURLToPath, pathToFileURL } from 'node:url'
const root = fileURLToPath(new URL('..', import.meta.url))
const indexPath = `${root}dist/index.html`
const ssrDir = `${root}dist-ssr`
const MARKER = '<div id="root"></div>'
const { render } = await import(pathToFileURL(`${ssrDir}/entry-server.js`).href)
const appHtml = render()
if (!appHtml || !appHtml.includes('<h1')) throw new Error('Prérendu vide ou sans H1')
const template = await readFile(indexPath, 'utf8')
if (!template.includes(MARKER)) throw new Error(`${MARKER} introuvable dans dist/index.html`)
await writeFile(indexPath, template.replace(MARKER, `<div id="root">${appHtml}</div>`))
await rm(ssrDir, { recursive: true, force: true })
console.log(`Prérendu injecté dans dist/index.html (${Math.round(appHtml.length / 1024)} Ko de HTML)`)
+17 -10
View File
@@ -788,7 +788,8 @@ function InlineDemo() {
const [active, setActive] = useState(false)
const [loaded, setLoaded] = useState(false)
const [shouldLoad, setShouldLoad] = useState(false)
const [isMobile, setIsMobile] = useState(() => window.matchMedia('(max-width: 768px)').matches)
// Faux au prérendu comme au premier rendu client (hydratation identique), corrigé par l'effet ci-dessous
const [isMobile, setIsMobile] = useState(false)
const sectionRef = useRef(null)
// L'iframe de l'app (500 Ko de JS + la galaxie) ne se charge qu'à l'approche de la section,
@@ -824,8 +825,8 @@ function InlineDemo() {
<div className="wrap sec-in">
<div className="head c" data-reveal>
<span className="eyebrow">Un exemple</span>
<h2 className="t-d2">Toute une famille <em className="s">sur une page</em></h2>
<p className="lede">Cliquez sur un visage, sa fiche s'ouvre. C'est l'arbre de démonstration, vous pouvez l'explorer sans créer de compte.</p>
<h2 className="t-d2">Un arbre généalogique <em className="s">avec photos, à explorer</em></h2>
<p className="lede">Cliquez sur un visage pour ouvrir sa fiche. C'est un arbre de démonstration, sans compte à créer.</p>
</div>
<div className="demo-frame" data-reveal>
@@ -904,14 +905,20 @@ const GUIDES = [
},
]
const readSource = () => new URLSearchParams(window.location.search).get('source') || 'direct'
export default function App() {
const [headerElevated, setHeaderElevated] = useState(false)
const [sharedReturnUrl, setSharedReturnUrl] = useState('')
const scrollMilestonesRef = useRef({ half: false, full: false })
const source = new URLSearchParams(window.location.search).get('source') || 'direct'
// La page est prérendue à la compilation, sans URL : le premier rendu est toujours la variante
// par défaut, puis la variante `app-shared` est posée après le montage (pas d'écart d'hydratation).
const [source, setSource] = useState('direct')
useEffect(() => { setSource(readSource()) }, [])
const buildEventProps = (extra = {}) => ({
source,
source: readSource(),
referrer: document.referrer || 'none',
viewport: window.innerWidth < 768 ? 'mobile' : 'desktop',
...extra,
@@ -1001,14 +1008,14 @@ export default function App() {
<HeroGraph className="order-1 lg:order-2" />
<div className="hero-txt order-2 lg:order-1">
<span className="pill rise"><i />Gratuit, sans compte pour lire</span>
<span className="pill rise"><i />Arbre généalogique gratuit, en ligne</span>
<h1 className="t-d1 rise" style={{ animationDelay: '80ms' }}>
L'histoire de votre famille, racontée par ceux qui <em className="s">l'ont vécue</em>
</h1>
<p className="lede rise" style={{ animationDelay: '160ms' }}>
{source === 'app-shared'
? "Vous venez de découvrir Parenthèse à travers le lien d'une famille. Créez la vôtre en quelques minutes."
: 'Des visages, des voix, des souvenirs rangés par génération. Un lien suffit pour que toute la famille y ait accès.'}
: "Créez l'arbre généalogique de votre famille en ligne, avec des photos, des voix et des souvenirs. Un lien suffit pour que toute la famille le consulte."}
</p>
<div className="ctas rise" style={{ animationDelay: '240ms' }}>
<button type="button" onClick={() => goToSignup('hero')} className="btn p">Créer mon arbre</button>
@@ -1030,7 +1037,7 @@ export default function App() {
<div className="wrap sec-in">
<div className="head c" data-reveal>
<span className="eyebrow">Comment ça marche</span>
<h2 className="t-d2">Trois gestes, <em className="s">pas plus</em></h2>
<h2 className="t-d2">Créer un arbre généalogique, <em className="s">en trois gestes</em></h2>
</div>
<div className="steps" data-reveal>
<div className="card step">
@@ -1081,7 +1088,7 @@ export default function App() {
<div className="head">
<span className="eyebrow">Pour qui</span>
<h2 className="t-d2">Du petit-fils de 8 ans <em className="s">à la grand-mère de 80</em></h2>
<p className="lede">Parenthèse se lit comme un album. Pas de jargon, pas d'arborescence à comprendre. On reconnaît quelqu'un, on clique.</p>
<p className="lede">Parenthèse se lit comme un album de famille. On reconnaît un visage, on clique, on écoute.</p>
</div>
<div className="gens" aria-hidden="true">
<figure><div className="ph" style={{ '--s': 74, backgroundImage: 'url(/hero/photos/lya-14.webp)' }} /><figcaption>Lya, 14 ans</figcaption></figure>
@@ -1104,7 +1111,7 @@ export default function App() {
<div className="wrap sec-in">
<div className="head c" data-reveal>
<span className="eyebrow">Guides</span>
<h2 className="t-d2">Avant de commencer, <em className="s">ou pour aller plus loin</em></h2>
<h2 className="t-d2">Guides pour faire <em className="s">son arbre généalogique</em></h2>
</div>
<div className="guides" data-reveal>
{GUIDES.map(({ href, title, text }) => (
+12
View File
@@ -0,0 +1,12 @@
import { StrictMode } from 'react'
import { renderToString } from 'react-dom/server'
import App from './App.jsx'
// Rendu de la landing à la compilation (npm run build), injecté dans dist/index.html par scripts/prerender.mjs
export function render() {
return renderToString(
<StrictMode>
<App />
</StrictMode>,
)
}
+9 -3
View File
@@ -1,5 +1,5 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { createRoot, hydrateRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
import { analytics } from './analytics.js'
@@ -9,8 +9,14 @@ const deferInit = () => analytics.init()
if ('requestIdleCallback' in window) window.requestIdleCallback(deferInit, { timeout: 3000 })
else window.setTimeout(deferInit, 1500)
createRoot(document.getElementById('root')).render(
const container = document.getElementById('root')
const app = (
<StrictMode>
<App />
</StrictMode>,
</StrictMode>
)
// En production le HTML est prérendu (scripts/prerender.mjs) : on l'hydrate.
// En développement (vite dev) la racine est vide : rendu client classique.
if (container.hasChildNodes()) hydrateRoot(container, app)
else createRoot(container).render(app)