feat(blog): add PostHog analytics events for blog pages

- Add analytics.ts with blog-specific tracking events:
  - trackBlogIndexView: Track blog index views with post count
  - trackBlogPostView: Track individual post views with metadata
  - trackBlogPostScrollDepth: Track reading progress (25%, 50%, 75%, 100%)
  - trackBlogPostTimeSpent: Track time spent on posts
  - trackBlogPostShare: Track social share clicks
  - trackBlogPostCTAClick: Track CTA engagement

- Add BlogIndexAnalytics and BlogPostAnalytics client components
- Integrate analytics into /blog and /blog/[slug] pages

Attribution is handled by PostHog save_referrer and save_campaign_params.

MKT-74
This commit is contained in:
Roo Code 2026-01-27 19:52:24 +00:00
parent 6f782056ad
commit bd1b3ad995
5 changed files with 221 additions and 0 deletions

View file

@ -12,6 +12,7 @@ import {
getArticleStructuredData,
getBlogPostBreadcrumbStructuredData,
} from "@/lib/blog"
import { BlogPostAnalytics } from "@/components/blog/blog-analytics"
// Force dynamic rendering to evaluate publish gating at request-time
export const dynamic = "force-dynamic"
@ -87,6 +88,17 @@ export default async function BlogPostPage({ params }: BlogPostPageProps) {
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(articleSchema) }} />
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }} />
{/* PostHog Analytics */}
<BlogPostAnalytics
post={{
slug: post.slug,
title: post.title,
description: post.description,
tags: post.tags,
publish_date: post.publish_date,
}}
/>
<div className="container mx-auto px-4 py-12 sm:px-6 lg:px-8">
<article className="mx-auto max-w-3xl">
{/* Back link */}

View file

@ -8,6 +8,7 @@ import {
getBlogCollectionStructuredData,
getBlogBreadcrumbStructuredData,
} from "@/lib/blog"
import { BlogIndexAnalytics } from "@/components/blog/blog-analytics"
// Force dynamic rendering to evaluate publish gating at request-time
export const dynamic = "force-dynamic"
@ -65,6 +66,9 @@ export default function BlogIndex() {
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(collectionSchema) }} />
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }} />
{/* PostHog Analytics */}
<BlogIndexAnalytics postCount={posts.length} />
<div className="container mx-auto px-4 py-12 sm:px-6 lg:px-8">
<div className="mx-auto max-w-4xl">
{/* Page Header */}

View file

@ -0,0 +1,99 @@
"use client"
import { useEffect, useRef } from "react"
import type { BlogPost } from "@/lib/blog"
import { trackBlogIndexView, trackBlogPostView, trackBlogPostScrollDepth, trackBlogPostTimeSpent } from "@/lib/blog"
interface BlogIndexAnalyticsProps {
postCount: number
}
/**
* Client component that tracks blog index page view
* Place this inside the blog index page
*/
export function BlogIndexAnalytics({ postCount }: BlogIndexAnalyticsProps) {
const tracked = useRef(false)
useEffect(() => {
if (!tracked.current) {
trackBlogIndexView(postCount)
tracked.current = true
}
}, [postCount])
return null
}
interface BlogPostAnalyticsProps {
post: {
slug: string
title: string
description: string
tags: string[]
publish_date: string
}
}
/**
* Client component that tracks blog post view, scroll depth, and time spent
* Place this inside the blog post page
*/
export function BlogPostAnalytics({ post }: BlogPostAnalyticsProps) {
const trackedView = useRef(false)
const trackedDepths = useRef<Set<25 | 50 | 75 | 100>>(new Set())
const startTime = useRef<number>(Date.now())
useEffect(() => {
// Capture start time for cleanup function
const effectStartTime = startTime.current
// Track page view on mount
if (!trackedView.current) {
trackBlogPostView(post as BlogPost)
trackedView.current = true
}
// Track scroll depth
const handleScroll = () => {
const scrollHeight = document.documentElement.scrollHeight - window.innerHeight
if (scrollHeight <= 0) return
const scrollPercent = (window.scrollY / scrollHeight) * 100
const depths: (25 | 50 | 75 | 100)[] = [25, 50, 75, 100]
for (const depth of depths) {
if (scrollPercent >= depth && !trackedDepths.current.has(depth)) {
trackedDepths.current.add(depth)
trackBlogPostScrollDepth(post as BlogPost, depth)
}
}
}
// Track time spent on page when leaving
const handleVisibilityChange = () => {
if (document.visibilityState === "hidden") {
const timeSpent = Date.now() - effectStartTime
trackBlogPostTimeSpent(post as BlogPost, timeSpent)
}
}
window.addEventListener("scroll", handleScroll, { passive: true })
document.addEventListener("visibilitychange", handleVisibilityChange)
// Check initial scroll position
handleScroll()
return () => {
window.removeEventListener("scroll", handleScroll)
document.removeEventListener("visibilitychange", handleVisibilityChange)
// Track time spent when component unmounts
const timeSpent = Date.now() - effectStartTime
trackBlogPostTimeSpent(post as BlogPost, timeSpent)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [post.slug])
return null
}

View file

@ -0,0 +1,96 @@
/**
* Blog Analytics Events
*
* PostHog tracking events for the blog section.
* These events help understand blog engagement and attribution.
*/
import posthog from "posthog-js"
import type { BlogPost } from "./types"
/**
* Track blog index page view
* Called when user views /blog
*/
export function trackBlogIndexView(postCount: number): void {
if (typeof window === "undefined" || !posthog.__loaded) return
posthog.capture("blog_index_viewed", {
post_count: postCount,
page_type: "blog_index",
})
}
/**
* Track blog post view
* Called when user views /blog/[slug]
*/
export function trackBlogPostView(post: BlogPost): void {
if (typeof window === "undefined" || !posthog.__loaded) return
posthog.capture("blog_post_viewed", {
post_slug: post.slug,
post_title: post.title,
post_tags: post.tags,
publish_date: post.publish_date,
page_type: "blog_post",
})
}
/**
* Track blog post scroll depth
* Called at various scroll thresholds (25%, 50%, 75%, 100%)
*/
export function trackBlogPostScrollDepth(post: BlogPost, depth: 25 | 50 | 75 | 100): void {
if (typeof window === "undefined" || !posthog.__loaded) return
posthog.capture("blog_post_scroll_depth", {
post_slug: post.slug,
post_title: post.title,
scroll_depth: depth,
})
}
/**
* Track blog post share
* Called when user clicks a share button
*/
export function trackBlogPostShare(post: BlogPost, platform: string): void {
if (typeof window === "undefined" || !posthog.__loaded) return
posthog.capture("blog_post_shared", {
post_slug: post.slug,
post_title: post.title,
share_platform: platform,
})
}
/**
* Track blog post CTA click
* Called when user clicks a CTA within a blog post
*/
export function trackBlogPostCTAClick(post: BlogPost, ctaType: string, ctaTarget: string): void {
if (typeof window === "undefined" || !posthog.__loaded) return
posthog.capture("blog_post_cta_click", {
post_slug: post.slug,
post_title: post.title,
cta_type: ctaType,
cta_target: ctaTarget,
})
}
/**
* Track time spent on blog post
* Called when user leaves the page (or at intervals)
*/
export function trackBlogPostTimeSpent(post: BlogPost, timeMs: number): void {
if (typeof window === "undefined" || !posthog.__loaded) return
posthog.capture("blog_post_time_spent", {
post_slug: post.slug,
post_title: post.title,
time_spent_ms: timeMs,
time_spent_seconds: Math.round(timeMs / 1000),
})
}

View file

@ -44,3 +44,13 @@ export {
getBlogPostBreadcrumbStructuredData,
getBlogPostUrl,
} from "./structured-data"
// Analytics (PostHog events)
export {
trackBlogIndexView,
trackBlogPostView,
trackBlogPostScrollDepth,
trackBlogPostShare,
trackBlogPostCTAClick,
trackBlogPostTimeSpent,
} from "./analytics"