mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-12 00:12:14 +00:00
Merge branch 'seo/1-landing' into seo/integration
This commit is contained in:
commit
c1bcb57ed5
8 files changed
+70
-19
No files matched your search
@@ -3,6 +3,7 @@
|
||||
|
||||
# Build outputs
|
||||
**/dist/
|
||||
**/dist-ssr/
|
||||
**/build/
|
||||
**/.next/
|
||||
**/coverage/
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
@@ -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 }) => (
|
||||
|
||||
@@ -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>,
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user