landing : mesure d'audience des pages statiques, provenance gardée jusqu'à l'app

Les guides, l'aide, la vidéo et les pages vie privée (public/, copiés sans Vite) n'avaient aucune
mesure. src/pages.js, construit par Vite avec une empreinte et posé au build dans chaque page par
scripts/inject-pages.mjs, envoie les mêmes événements que la page d'accueil (page_viewed, scroll_depth_50
et 100, signup_cta_clicked) et video_played sur /video/. Les liens vers l'app gardent le ?source=
d'arrivée au lieu de la provenance fixe de la page. Page vie privée (fr, en) complétée.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 committed 2026-11-04 19:01:07 +01:00
1 parent 2c7e5361cc
commit 9bf55a16f3
6 files changed
+131 -3

No files matched your search

+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build && vite build --ssr src/entry-server.jsx --outDir dist-ssr && node scripts/prerender.mjs",
"build": "vite build && vite build --ssr src/entry-server.jsx --outDir dist-ssr && node scripts/prerender.mjs && node scripts/inject-pages.mjs",
"preview": "vite preview",
"test": "node --test"
},
@@ -96,7 +96,7 @@
<h2>Mesure d'audience</h2>
<p>Parenthèse utilise PostHog pour compter les visites et voir ce qui est utilisé. PostHog est hébergé aux États-Unis, les données d'usage décrites ici y sont envoyées.</p>
<p>Ce qui est mesuré, et rien de plus. Sur le site, la visite d'une page, le défilement, les clics sur les boutons « Créer mon arbre », avec la provenance (page précédente, mobile ou ordinateur). Dans l'application, l'ouverture de l'écran de compte, la création d'un compte ou d'un arbre, et pour les arbres partagés, l'ouverture, le temps passé et le type de contenu consulté (texte, photo, vidéo), avec le type d'accès. L'arbre y est désigné par un code brouillé, jamais par son nom.</p>
<p>Ce qui est mesuré, et rien de plus. Sur le site, la visite d'une page, le défilement, la lecture de la vidéo de présentation, les clics sur les boutons « Créer mon arbre », avec la provenance (lien d'arrivée, page précédente, mobile ou ordinateur). Dans l'application, l'ouverture de l'écran de compte, la création d'un compte ou d'un arbre, et pour les arbres partagés, l'ouverture, le temps passé et le type de contenu consulté (texte, photo, vidéo), avec le type d'accès. L'arbre y est désigné par un code brouillé, jamais par son nom.</p>
<p>Il n'y a pas de capture automatique des clics ni des pages, et pas d'enregistrement des sessions. Aucun contenu d'arbre (nom, personne, photo) n'est envoyé, ni l'adresse de l'arbre, retirée des liens avant tout envoi. Quand vous êtes connecté à votre compte, un identifiant technique du compte (jamais votre adresse email) relie vos visites entre elles. Un cookie est posé sur parenthese.io et ses sous-domaines pour reconnaître la même visite entre le site et l'application.</p>
<h2>Vos droits</h2>
+1 -1
View File
@@ -96,7 +96,7 @@
<h2>Audience measurement</h2>
<p>Parenthèse uses PostHog to count visits and see what gets used. PostHog is hosted in the United States, and the usage data described here is sent there.</p>
<p>Here is what is measured, and nothing more. On the website, page views, scrolling, clicks on the “Start your tree” buttons, with where they came from (previous page, mobile or computer). In the app, opening the account screen, creating an account or a tree, and for shared trees, opening them, the time spent and the type of content viewed (text, photo, video), with the type of access. The tree is identified there by a scrambled code, never by its name.</p>
<p>Here is what is measured, and nothing more. On the website, page views, scrolling, playing the introduction video, clicks on the “Start your tree” buttons, with where they came from (link used to arrive, previous page, mobile or computer). In the app, opening the account screen, creating an account or a tree, and for shared trees, opening them, the time spent and the type of content viewed (text, photo, video), with the type of access. The tree is identified there by a scrambled code, never by its name.</p>
<p>There is no automatic capture of clicks or pages, and no session recording. No tree content (name, person, photo) is sent, and neither is the address of the tree, which is removed from links before anything is sent. When you are logged in to your account, a technical account identifier (never your email address) links your visits together. A cookie is set on parenthese.io and its subdomains to recognize the same visit across the site and the app.</p>
<h2>Your rights</h2>
+37
View File
@@ -0,0 +1,37 @@
// Pose la mesure d'audience (src/pages.js, nom à empreinte lu dans le manifeste de Vite) dans chaque page
// statique de dist/ : guides, aide, vidéo, vie privée. Ces pages viennent de public/ et ne passent pas par
// Vite. La page d'accueil (fr et en, <div id="root">) a déjà sa propre mesure (App.jsx), elle est sautée.
// Lancé par `npm run build` après scripts/prerender.mjs.
import { readdir, readFile, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
const dist = fileURLToPath(new URL('../dist', import.meta.url))
const manifestPath = join(dist, '.vite', 'manifest.json')
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
const entry = manifest['src/pages.js']
if (!entry?.file) throw new Error('src/pages.js absent du manifeste de Vite')
const tag = `<script type="module" src="/${entry.file}"></script>`
async function* htmlFiles(dir) {
for (const item of await readdir(dir, { withFileTypes: true })) {
const path = join(dir, item.name)
if (item.isDirectory()) yield* htmlFiles(path)
else if (item.name === 'index.html') yield path
}
}
let injected = 0
for await (const path of htmlFiles(dist)) {
const html = await readFile(path, 'utf8')
if (html.includes('<div id="root">')) continue
if (html.includes(tag)) continue
if (!html.includes('</body>')) throw new Error(`</body> introuvable dans ${path}`)
await writeFile(path, html.replace('</body>', ` ${tag}\n</body>`))
injected++
}
if (injected === 0) throw new Error('Aucune page statique trouvée pour la mesure d\'audience')
// Le manifeste ne sert qu'au build, il n'a rien à faire en ligne
await rm(join(dist, '.vite'), { recursive: true, force: true })
console.log(`Mesure d'audience posée dans ${injected} pages statiques (${entry.file})`)
+85
View File
@@ -0,0 +1,85 @@
// Mesure d'audience des pages statiques (guides, aide, vidéo, vie privée), injectée au build
// par scripts/inject-pages.mjs. Mêmes événements et mêmes propriétés que la page d'accueil (App.jsx),
// et rien de plus que ce que décrit la page « Données et vie privée ».
import { analytics } from './analytics.js'
const params = new URLSearchParams(window.location.search)
// Provenance de l'arrivée (?source=newsletter…), sinon « direct », comme sur la page d'accueil
const arrival = params.get('source') || 'direct'
const lang = document.documentElement.lang?.startsWith('en') ? 'en' : 'fr'
const eventProps = (extra = {}) => ({
source: arrival,
referrer: document.referrer || 'none',
viewport: window.innerWidth < 768 ? 'mobile' : 'desktop',
lang,
...extra,
})
// Les liens vers l'app gardent la provenance de l'arrivée : un lecteur venu de ?source=newsletter
// ouvre la démo en newsletter, pas en « video ». Sans provenance, le lien garde celle de la page.
function forwardSource() {
if (arrival === 'direct') return
for (const link of document.querySelectorAll('a[href^="https://app.parenthese.io"]')) {
const url = new URL(link.href)
url.searchParams.set('source', arrival)
link.href = url.toString()
}
}
// Un clic vers la création de compte : le même événement que la page d'accueil (funnel PostHog)
function trackSignupClicks() {
document.addEventListener('click', (event) => {
const link = event.target.closest?.('a[href^="https://app.parenthese.io"]')
if (!link || !link.href.includes('account=register')) return
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
event.preventDefault()
analytics.captureThenNavigate('signup_cta_clicked', eventProps({ placement: 'page' }), link.href)
})
}
function trackScroll() {
const reached = { half: false, full: false }
const onScroll = () => {
const doc = document.documentElement
const max = Math.max(0, doc.scrollHeight - window.innerHeight)
const progress = max > 0 ? (window.scrollY || 0) / max : 1
if (!reached.half && progress >= 0.5) {
reached.half = true
analytics.capture('scroll_depth_50', eventProps())
}
if (!reached.full && progress >= 0.99) {
reached.full = true
analytics.capture('scroll_depth_100', eventProps())
window.removeEventListener('scroll', onScroll)
}
}
window.addEventListener('scroll', onScroll, { passive: true })
}
// La vidéo de présentation (/video/) : une lecture par visite, avec le format regardé
function trackVideo() {
let played = false
for (const video of document.querySelectorAll('video')) {
video.addEventListener('play', () => {
if (played) return
played = true
analytics.capture('video_played', eventProps({ format: video.videoWidth > video.videoHeight ? '16x9' : '9x16' }))
})
}
}
function start() {
forwardSource()
trackSignupClicks()
// PostHog après le premier rendu, comme main.jsx : les événements d'ici là partent à l'init
if ('requestIdleCallback' in window) window.requestIdleCallback(() => analytics.init(), { timeout: 3000 })
else window.setTimeout(() => analytics.init(), 1500)
analytics.capture('page_viewed', eventProps())
trackScroll()
trackVideo()
}
// Une page préchargée (speculation rules) n'est comptée que si on l'ouvre vraiment
if (document.prerendering) document.addEventListener('prerenderingchange', start, { once: true })
else start()
+6
View File
@@ -9,5 +9,11 @@ export default defineConfig({
target: ['chrome107', 'edge107', 'firefox104', 'safari16'],
outDir: 'dist',
emptyOutDir: true,
// Le manifeste donne le nom à empreinte de src/pages.js, que scripts/inject-pages.mjs pose
// dans les pages statiques de public/ (guides, aide, vidéo), copiées sans passer par Vite
manifest: true,
rollupOptions: {
input: { main: 'index.html', pages: 'src/pages.js' },
},
},
})