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:
parent
9aff5d8146
commit
c346a6cff2
13 changed files with 106 additions and 5 deletions
39
alembic/versions/0036_growth_events_page.py
Normal file
39
alembic/versions/0036_growth_events_page.py
Normal 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"],
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue