feat(admin): décomposition par page dans l'entonnoir d'acquisition

Ajoute une colonne `page` sur `growth_events` (migration 0036) et étend
la contrainte unique pour permettre un landing_view distinct par page visitée.
Homepage, vitrines et landings SEO envoient désormais leur identifiant de page ;
le dashboard Acquisition affiche le classement des pages d'entrée.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gautier Stefanini 2026-08-14 13:46:21 +00:00
parent 9aff5d8146
commit c346a6cff2
13 changed files with 106 additions and 5 deletions

View file

@ -0,0 +1,39 @@
"""add page column to growth_events for landing-level breakdown.
Revision ID: 0036
Revises: 0035
Create Date: 2026-08-14
"""
import sqlalchemy as sa
from alembic import op
revision = "0036"
down_revision = "0035"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"growth_events",
sa.Column("page", sa.String(length=120), server_default="", nullable=False),
)
# La contrainte inclut désormais `page` : un visiteur peut avoir un
# landing_view par page visitée (home, landing key, vitrine/service…).
op.drop_constraint("uq_growth_event_dedupe", "growth_events", type_="unique")
op.create_unique_constraint(
"uq_growth_event_dedupe",
"growth_events",
["anonymous_id", "event_name", "source", "experiment_key", "page"],
)
def downgrade() -> None:
op.drop_constraint("uq_growth_event_dedupe", "growth_events", type_="unique")
op.drop_column("growth_events", "page")
op.create_unique_constraint(
"uq_growth_event_dedupe",
"growth_events",
["anonymous_id", "event_name", "source", "experiment_key"],
)

View file

@ -100,6 +100,7 @@ export interface FunnelResponse {
by_source: Record<string, Record<string, number>>
by_device: Record<string, Record<string, number>>
by_locale: Record<string, Record<string, number>>
by_page: Record<string, number>
viral: {
guest_opened_per_organizer: number | null
guest_to_organizer_rate: number | null

View file

@ -14,6 +14,7 @@ const response: FunnelResponse = {
by_source: {},
by_device: { mobile: { landing_view: 60 }, desktop: { landing_view: 40 } },
by_locale: {},
by_page: { home: 80, 'vitrine/kdo': 20 },
viral: { guest_opened_per_organizer: 1.5, guest_to_organizer_rate: 0.12 },
}

View file

@ -199,7 +199,7 @@ export default function AdminAcquisition() {
</AdminChartWrapper>
)}
{/* Expériences A/B */}
{/* Sources de trafic */}
{Object.keys(data.by_source ?? {}).length > 0 && (
<AdminChartWrapper title="Sources de trafic (1ère étape)">
<div className="space-y-2">
@ -222,6 +222,32 @@ export default function AdminAcquisition() {
</div>
</AdminChartWrapper>
)}
{/* Pages visitées */}
{Object.keys(data.by_page ?? {}).length > 0 && (
<AdminChartWrapper title="Pages d'entrée (landing_view, visiteurs uniques)">
<div className="space-y-2">
{(() => {
const maxPageCount = Math.max(...Object.values(data.by_page), 1)
return Object.entries(data.by_page).map(([pageName, count]) => (
<div key={pageName} className="flex items-center gap-3">
<span className="text-meta text-ink-subtle w-48 shrink-0 truncate" title={pageName}>
{pageName}
</span>
<div className="flex-1 bg-line rounded-full h-3" role="progressbar" aria-label={pageName}
aria-valuenow={count} aria-valuemax={maxPageCount}>
<div
className="h-full rounded-full bg-primary-300"
style={{ width: `${(count / maxPageCount) * 100}%` }}
/>
</div>
<span className="text-meta font-bold tabular-nums text-ink w-10 text-right">{count}</span>
</div>
))
})()}
</div>
</AdminChartWrapper>
)}
</>
)}
</div>

View file

@ -25,7 +25,7 @@ export default function Landing() {
const tools = serviceMetadata.filter(service => !service.hub && service.active)
const previewSrc = marketingAssetUrl(`/marketing/hub-${preview}-${lang}.webp`)
useEffect(() => { void trackGrowthEvent('landing_view', 'hub') }, [])
useEffect(() => { void trackGrowthEvent('landing_view', 'hub', false, 'home') }, [])
usePageMeta({
title: t('landing.meta_title'),

View file

@ -46,6 +46,7 @@ export function trackGrowthEvent(
eventName: GrowthEventName,
source: GrowthSource,
includeExperiment = false,
page?: string,
): Promise<void> {
const experiment = guestCtaCopyExperiment
if (!isGrowthMeasurementEnabled()) return Promise.resolve()
@ -56,6 +57,7 @@ export function trackGrowthEvent(
source,
device: window.innerWidth < 768 ? 'mobile' : 'desktop',
locale: currentLang(),
...(page ? { page } : {}),
...(includeExperiment ? {
experiment_key: experiment.key,
variant,

View file

@ -1,5 +1,7 @@
import { useEffect } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { trackGrowthEvent } from '@shared/api/growth'
import { currentLang, DEFAULT_LANG } from '../../i18n/config'
import { getLandingByKey, landingPreviewDimensions, landingPreviewImage, type LandingConfig } from '@shared/config/landings'
import { getServiceMetadata } from '@shared/config/serviceMetadata'
@ -75,6 +77,10 @@ function LandingBody({ landing }: { landing: LandingConfig }) {
const createLink = useCreateEventLink(landing.primaryService, landing.hubTemplate ? landing.key : undefined)
const page = (suffix: string) => t(`pages.${landing.key}.${suffix}`)
useEffect(() => {
void trackGrowthEvent('landing_view', landing.primaryService, false, landing.key)
}, [landing.key, landing.primaryService])
const steps = t(`pages.${landing.key}.steps`, { returnObjects: true }) as Step[]
const faq = t(`pages.${landing.key}.faq`, { returnObjects: true }) as LandingFaqEntry[]
const comparison = landing.comparison

View file

@ -1,6 +1,7 @@
import type { ReactNode } from 'react'
import { type ReactNode, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { trackGrowthEvent } from '@shared/api/growth'
import { usePageMeta } from '@shared/hooks/usePageMeta'
import { useCreateEventLink } from '@shared/hooks/useCreateEventLink'
import { getServiceTheme } from '@shared/config/serviceThemes'
@ -42,6 +43,10 @@ export default function ServiceVitrine({ serviceId, label, icon, tagline, descri
const guides = landings.filter(landing => landing.primaryService === serviceId)
const theme = getServiceTheme(serviceId)
const createLink = useCreateEventLink(serviceId)
useEffect(() => {
void trackGrowthEvent('landing_view', serviceId, false, `vitrine/${serviceId}`)
}, [serviceId])
const previewContent: ServicePreviewContent = {
label: ts('vitrine.preview_label'),
title: ts('vitrine.preview_title'),

View file

@ -24,13 +24,14 @@ class GrowthEvent(Base):
variant: Mapped[str] = mapped_column(String(30), default="", nullable=False)
device: Mapped[str] = mapped_column(String(10), default="unknown", server_default="unknown", nullable=False)
locale: Mapped[str] = mapped_column(String(5), default="", server_default="", nullable=False)
page: Mapped[str] = mapped_column(String(120), default="", server_default="", nullable=False)
occurred_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
__table_args__ = (
UniqueConstraint(
"anonymous_id", "event_name", "source", "experiment_key",
"anonymous_id", "event_name", "source", "experiment_key", "page",
name="uq_growth_event_dedupe",
),
)

View file

@ -626,11 +626,19 @@ async def get_funnel(
if locale else None
)
page_rows = (await db.execute(
select(GrowthEvent.page, func.count(distinct(GrowthEvent.anonymous_id)))
.where(GrowthEvent.event_name == "landing_view", GrowthEvent.page != "", *filters)
.group_by(GrowthEvent.page)
.order_by(func.count(distinct(GrowthEvent.anonymous_id)).desc())
)).all()
return {
"steps": steps,
"by_source": await segment(GrowthEvent.source),
"by_device": await segment(GrowthEvent.device),
"by_locale": await segment(GrowthEvent.locale),
"by_page": {page: int(cnt) for page, cnt in page_rows},
"viral": {
"guest_opened_per_organizer": round(len(guest_ids) / organizers, 4) if organizers else None,
"guest_to_organizer_rate": round(len(guest_ids & converted_ids) / len(guest_ids), 4) if guest_ids else None,

View file

@ -26,6 +26,7 @@ async def create_growth_event(
experiment_key=body.experiment_key,
variant=body.variant,
locale=body.locale,
page=body.page,
)
await db.commit()
return {"accepted": True}

View file

@ -29,6 +29,7 @@ class GrowthEventCreate(BaseModel):
experiment_key: str | None = Field(default=None, min_length=1, max_length=80, pattern=r"^[a-z0-9_-]+$")
variant: str | None = Field(default=None, min_length=1, max_length=30, pattern=r"^[a-z0-9_-]+$")
locale: str = Field(default="", max_length=5, pattern=r"^([a-z]{2})?$")
page: str = Field(default="", max_length=120, pattern=r"^[a-z0-9/_-]*$")
@model_validator(mode="after")
def coherent_experiment(self):

View file

@ -33,8 +33,9 @@ async def record_growth_event(
experiment_key: str | None = None,
variant: str | None = None,
locale: str | None = None,
page: str | None = None,
) -> None:
"""Insère au plus une occurrence par visiteur, événement, source et expérience."""
"""Insère au plus une occurrence par visiteur, événement, source, expérience et page."""
statement = (
insert(GrowthEvent)
@ -47,6 +48,7 @@ async def record_growth_event(
experiment_key=experiment_key or "",
variant=variant or "",
locale=locale or "",
page=page or "",
)
.on_conflict_do_update(
constraint="uq_growth_event_dedupe",
@ -96,10 +98,18 @@ async def activation_dashboard(db: AsyncSession) -> dict:
)).all())
organizers = counts.get("hub_created", 0)
page_rows = (await db.execute(
select(GrowthEvent.page, func.count(GrowthEvent.id))
.where(GrowthEvent.event_name == "landing_view", GrowthEvent.page != "")
.group_by(GrowthEvent.page)
.order_by(func.count(GrowthEvent.id).desc())
)).all()
return {
"steps": steps,
"by_source": await segment(GrowthEvent.source),
"by_device": await segment(GrowthEvent.device),
"by_page": {page: count for page, count in page_rows},
"viral": {
"guest_opened_per_organizer": round(len(guest_ids) / organizers, 4) if organizers else None,
"guest_to_organizer_rate": round(len(guest_ids & converted_ids) / len(guest_ids), 4) if guest_ids else None,