Files
parenthese/frontend/src/utils/analytics.js
T
IliesAllaliandClaude Opus 5.5 0365a71618 Suivi avant le Show HN : démo, source jusqu'à l'inscription, un seul clic CTA
- demo_opened (une fois par chargement, visiteur sans compte) et demo_interaction (personne, média, recherche, édition, création d'arbre ; une fois par type, sans contenu)
- Source d'arrivée gardée sur l'appareil (?source=hn) et jointe à account_screen_viewed et account_registered ; la landing transmet la vraie source au CTA et à l'iframe de démo au lieu de source=landing
- Plus de remise à zéro PostHog à chaque ouverture anonyme (effaçait le visiteur de la landing), seulement à la déconnexion
- Landing : un seul événement signup_cta_clicked (hero_cta_clicked retiré, plus utilisé par le funnel), envoyé avant la navigation sans sendBeacon (bloqué par Brave)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:41:21 +02:00

194 lines
6.0 KiB
JavaScript

let ph = null
let initStarted = false
let enabled = false
const queuedEvents = []
const MAX_QUEUED_EVENTS = 100
function flushQueue() {
if (!ph) return
while (queuedEvents.length > 0) {
const next = queuedEvents.shift()
if (next.type === 'identify' && typeof ph.identify === 'function') {
ph.identify(next.distinctId, next.properties)
continue
}
if (next.type === 'reset' && typeof ph.reset === 'function') {
ph.reset()
continue
}
if (next.type === 'capture' && typeof ph.capture === 'function') {
ph.capture(next.eventName, next.properties)
}
}
}
// L'adresse d'un arbre (/arbre/<slug>) vaut clé d'accès en lecture : elle ne quitte jamais le
// navigateur. Toute chaîne envoyée à PostHog ($current_url, $pathname, $referrer,
// $initial_current_url, $session_entry_url, propriétés de personne...) est réécrite en /arbre/:slug.
const TREE_PATH_RE = /\/arbre\/[^/?#\s]+/g
const TREE_PATH_ENCODED_RE = /%2Farbre%2F(?:(?!%2F|%3F|%23)[^&#\s])+/gi
export function scrubTreeSlug(value) {
if (typeof value === 'string') {
return value
.replace(TREE_PATH_RE, '/arbre/:slug')
.replace(TREE_PATH_ENCODED_RE, '%2Farbre%2F%3Aslug')
}
if (Array.isArray(value)) return value.map(scrubTreeSlug)
if (value && typeof value === 'object' && !(value instanceof Date)) {
const out = {}
for (const [k, v] of Object.entries(value)) out[k] = scrubTreeSlug(v)
return out
}
return value
}
export function scrubAnalyticsEvent(event) {
if (!event) return event
const next = { ...event }
if (next.properties) next.properties = scrubTreeSlug(next.properties)
if (next.$set) next.$set = scrubTreeSlug(next.$set)
if (next.$set_once) next.$set_once = scrubTreeSlug(next.$set_once)
return next
}
// Source d'arrivée (?source=hn, landing, app-shared...) : lue une fois à l'ouverture et gardée sur
// l'appareil, pour que l'inscription dise d'où vient le visiteur même après quelques clics.
// La première source connue gagne, « direct » ne remplace jamais une vraie source.
const SOURCE_KEY = 'parenthese_source'
export function rememberSource() {
try {
const fromUrl = new URLSearchParams(window.location.search).get('source')
const clean = String(fromUrl || '').trim().toLowerCase().replace(/[^a-z0-9_-]/g, '').slice(0, 40)
if (clean && clean !== 'direct' && !localStorage.getItem(SOURCE_KEY)) {
localStorage.setItem(SOURCE_KEY, clean)
}
} catch {
// Stockage indisponible : l'inscription partira sans source
}
}
export function getSource() {
try {
return localStorage.getItem(SOURCE_KEY) || 'direct'
} catch {
return 'direct'
}
}
function normalizeRole(value) {
return String(value || '').trim().toLowerCase()
}
export function initAppAnalytics() {
if (initStarted) return
initStarted = true
rememberSource()
const key = import.meta.env.VITE_POSTHOG_KEY
if (!key) return
enabled = true
import('posthog-js')
.then(({ default: posthog }) => {
posthog.init(key, {
api_host: import.meta.env.VITE_POSTHOG_HOST || 'https://us.i.posthog.com',
cross_subdomain_cookie: true,
defaults: '2026-01-30',
person_profiles: 'identified_only',
autocapture: false,
capture_pageview: false,
disable_session_recording: true,
// Aucun feature flag dans le code. Sans ça, /flags recevrait les propriétés de personne
// ($initial_current_url...), que before_send ne voit pas.
advanced_disable_flags: true,
// Verrouillé dans le code plutôt que laissé au tableau de bord PostHog : clics morts, cartes de chaleur
// et exceptions enverraient le texte des éléments (des noms de la famille) ou des messages d'erreur, que
// before_send ne nettoie pas. Aucun script externe (replay, sondages, site apps), la CSP n'en autorise pas.
capture_dead_clicks: false,
capture_heatmaps: false,
capture_exceptions: false,
capture_performance: false,
rageclick: false,
disable_surveys: true,
disable_product_tours: true,
disable_web_experiments: true,
disable_external_dependency_loading: true,
before_send: scrubAnalyticsEvent,
})
ph = posthog
flushQueue()
})
.catch(() => {
enabled = false
queuedEvents.length = 0
})
}
function fnv1aHash(input) {
let hash = 0x811c9dc5
for (let i = 0; i < input.length; i += 1) {
hash ^= input.charCodeAt(i)
hash = Math.imul(hash, 0x01000193)
}
return (hash >>> 0).toString(16)
}
export function toOpaqueTreeId(treeId) {
if (!treeId) return ''
return fnv1aHash(String(treeId))
}
export function toJourneyRole(rawRole) {
const role = normalizeRole(rawRole)
if (role === 'visitor') return 'visitor'
if (role === 'contributor') return 'contributor_anon'
return null
}
export function toInteractionMediaType(rawType) {
const type = String(rawType || '').trim().toLowerCase()
if (type === 'photo') return 'photo'
if (type === 'video') return 'video'
return 'text'
}
export function trackAppEvent(eventName, properties = {}) {
if (!eventName) return
if (ph && typeof ph.capture === 'function') {
ph.capture(eventName, properties)
return
}
if (!enabled) return
if (queuedEvents.length >= MAX_QUEUED_EVENTS) return
queuedEvents.push({ type: 'capture', eventName, properties })
}
export function identifyUser(distinctId, properties = {}) {
const normalizedDistinctId = String(distinctId || '').trim()
if (!normalizedDistinctId) return
if (ph && typeof ph.identify === 'function') {
ph.identify(normalizedDistinctId, properties)
return
}
if (!enabled) return
if (queuedEvents.length >= MAX_QUEUED_EVENTS) return
queuedEvents.push({ type: 'identify', distinctId: normalizedDistinctId, properties })
}
export function resetUser() {
if (ph && typeof ph.reset === 'function') {
ph.reset()
return
}
if (!enabled) return
if (queuedEvents.length >= MAX_QUEUED_EVENTS) return
queuedEvents.push({ type: 'reset' })
}