Skip to content

Commit 5360ecd

Browse files
committed
feat: add analytics tracking
1 parent c48f1d4 commit 5360ecd

14 files changed

Lines changed: 1275 additions & 24 deletions

File tree

.claude/features/analytics-events-tracking/tasks.md

Lines changed: 464 additions & 0 deletions
Large diffs are not rendered by default.

.claude/settings.local.json

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,28 @@
1515
"Bash(node:*)",
1616
"WebFetch(domain:github.com)",
1717
"WebFetch(domain:nodesource.com)",
18-
"Bash(ls:*)"
18+
"Bash(ls:*)",
19+
"Skill(marketing-skills:analytics-tracking)",
20+
"Bash(bash scripts/check-task-prerequisites.sh:*)",
21+
"mcp__analytics-mcp__get_account_summaries",
22+
"mcp__analytics-mcp__get_property_details",
23+
"mcp__analytics-mcp__get_custom_dimensions_and_metrics",
24+
"mcp__analytics-mcp__run_report",
25+
"Bash(scripts/check-task-prerequisites.sh:*)",
26+
"Bash(git -C /Users/luciano/Documents/nodejsdesignpatterns.com log --oneline -5)",
27+
"Bash(git -C /Users/luciano/Documents/nodejsdesignpatterns.com diff --name-only HEAD~1..HEAD)",
28+
"Bash(pnpm run build:*)",
29+
"Bash(pnpm run lint:*)",
30+
"Bash(npx eslint:*)",
31+
"mcp__playwright__browser_navigate",
32+
"mcp__playwright__browser_console_messages",
33+
"mcp__playwright__browser_snapshot",
34+
"mcp__playwright__browser_press_key",
35+
"mcp__playwright__browser_evaluate",
36+
"mcp__analytics-mcp__run_realtime_report",
37+
"Bash(python3:*)",
38+
"mcp__playwright__browser_click",
39+
"mcp__playwright__browser_type"
1940
]
2041
}
2142
}

src/Layout.astro

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,119 @@ const {
163163
import { initTheme } from './lib/theme.ts'
164164
initTheme()
165165
</script>
166+
167+
<!-- Global Analytics: Outbound Link Tracking -->
168+
<script>
169+
import {
170+
trackClickOutboundLink,
171+
isExternalUrl,
172+
extractDomain,
173+
getPagePath,
174+
} from './lib/analytics.ts'
175+
176+
function initOutboundLinkTracking() {
177+
document.addEventListener('click', (event) => {
178+
const target = event.target as HTMLElement
179+
const link = target.closest('a')
180+
181+
if (!link) return
182+
183+
const href = link.getAttribute('href')
184+
if (!href) return
185+
186+
// Only track external links
187+
if (!isExternalUrl(href)) return
188+
189+
// Don't track if it's a buy button (already tracked separately)
190+
if (link.hasAttribute('data-analytics-format')) return
191+
192+
trackClickOutboundLink({
193+
link_url: href,
194+
link_domain: extractDomain(href),
195+
link_text: link.textContent?.trim().slice(0, 100) || '',
196+
source_page: getPagePath(),
197+
})
198+
})
199+
}
200+
201+
// Initialize when DOM is ready
202+
if (document.readyState === 'loading') {
203+
document.addEventListener('DOMContentLoaded', initOutboundLinkTracking)
204+
} else {
205+
initOutboundLinkTracking()
206+
}
207+
</script>
208+
209+
<!-- Global Analytics: Internal Navigation Tracking -->
210+
<script>
211+
import {
212+
trackInternalNavigation,
213+
isExternalUrl,
214+
getPagePath,
215+
type NavigationType,
216+
} from './lib/analytics.ts'
217+
218+
function initInternalNavigationTracking() {
219+
const currentPath = getPagePath()
220+
221+
document.addEventListener('click', (event) => {
222+
const target = event.target as HTMLElement
223+
const link = target.closest('a')
224+
225+
if (!link) return
226+
227+
const href = link.getAttribute('href')
228+
if (!href) return
229+
230+
// Only track internal links
231+
if (isExternalUrl(href)) return
232+
233+
// Skip anchor links on same page
234+
if (href.startsWith('#')) return
235+
236+
// Skip if it's a buy button (already tracked)
237+
if (link.hasAttribute('data-analytics-format')) return
238+
239+
// Determine navigation type based on element location
240+
let navigationType: NavigationType = 'inline_link'
241+
242+
const nav = link.closest('nav')
243+
const header = link.closest('header')
244+
const footer = link.closest('footer')
245+
246+
if (nav || header) {
247+
navigationType = 'header_link'
248+
} else if (footer) {
249+
navigationType = 'footer_link'
250+
}
251+
252+
// Get the destination path
253+
let toPage = href
254+
try {
255+
const url = new URL(href, window.location.origin)
256+
toPage = url.pathname
257+
} catch {
258+
// Keep original href if URL parsing fails
259+
}
260+
261+
trackInternalNavigation({
262+
from_page: currentPath,
263+
to_page: toPage,
264+
navigation_type: navigationType,
265+
})
266+
})
267+
}
268+
269+
// Initialize when DOM is ready
270+
if (document.readyState === 'loading') {
271+
document.addEventListener(
272+
'DOMContentLoaded',
273+
initInternalNavigationTracking,
274+
)
275+
} else {
276+
initInternalNavigationTracking()
277+
}
278+
</script>
166279
<Font cssVariable="--font-base-sans" preload />
167280
<Font cssVariable="--font-base-serif" preload />
168281
<Font cssVariable="--font-base-mono" preload />

src/components/blog/BlogLayout.astro

Lines changed: 101 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,11 @@ if (post.rendered?.metadata?.headings) {
145145
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
146146
<div class="flex flex-col md:flex-row gap-12">
147147
<!-- Article (left column) -->
148-
<article class="flex-1 min-w-0">
148+
<article
149+
id="blog-article"
150+
class="flex-1 min-w-0"
151+
data-reading-time={readingTime}
152+
>
149153
<!-- ToC -->
150154
<div>
151155
{
@@ -206,8 +210,11 @@ if (post.rendered?.metadata?.headings) {
206210
</div>
207211
</div>
208212

209-
<!-- Article Footer -->
210-
<aside class="mt-16 pt-8 border-t border-base-300">
213+
<!-- Article Footer (used as read completion marker) -->
214+
<aside
215+
id="article-footer"
216+
class="mt-16 pt-8 border-t border-base-300"
217+
>
211218
<div
212219
class="flex flex-col sm:flex-row items-center justify-between gap-6"
213220
>
@@ -368,3 +375,94 @@ if (post.rendered?.metadata?.headings) {
368375
</main>
369376
<Footer />
370377
</Layout>
378+
379+
<script>
380+
import {
381+
trackScrollDepth,
382+
trackBlogReadComplete,
383+
getPagePath,
384+
type ScrollDepthThreshold,
385+
} from '@lib/analytics'
386+
387+
function initBlogAnalytics() {
388+
const article = document.getElementById('blog-article')
389+
const articleFooter = document.getElementById('article-footer')
390+
391+
if (!article) return
392+
393+
const pagePath = getPagePath()
394+
const readingTime = parseInt(article.dataset.readingTime || '0', 10)
395+
const pageLoadTime = Date.now()
396+
397+
// Track scroll depth milestones
398+
const scrollThresholds: ScrollDepthThreshold[] = [25, 50, 75, 100]
399+
const firedThresholds = new Set<number>()
400+
401+
function checkScrollDepth() {
402+
const scrollTop = window.scrollY
403+
const docHeight =
404+
document.documentElement.scrollHeight - window.innerHeight
405+
const scrollPercent = Math.round((scrollTop / docHeight) * 100)
406+
407+
for (const threshold of scrollThresholds) {
408+
if (scrollPercent >= threshold && !firedThresholds.has(threshold)) {
409+
firedThresholds.add(threshold)
410+
trackScrollDepth({
411+
percent_scrolled: threshold,
412+
page_path: pagePath,
413+
content_type: 'blog_post',
414+
})
415+
}
416+
}
417+
}
418+
419+
// Throttled scroll handler
420+
let scrollTimeout: ReturnType<typeof setTimeout> | null = null
421+
function handleScroll() {
422+
if (scrollTimeout) return
423+
scrollTimeout = setTimeout(() => {
424+
checkScrollDepth()
425+
scrollTimeout = null
426+
}, 100)
427+
}
428+
429+
window.addEventListener('scroll', handleScroll, { passive: true })
430+
431+
// Check initial scroll position (in case user starts mid-page)
432+
checkScrollDepth()
433+
434+
// Track blog read completion when article footer becomes visible
435+
if (articleFooter) {
436+
let hasTrackedCompletion = false
437+
438+
const observer = new IntersectionObserver(
439+
(entries) => {
440+
entries.forEach((entry) => {
441+
if (entry.isIntersecting && !hasTrackedCompletion) {
442+
hasTrackedCompletion = true
443+
const timeOnPage = Math.round((Date.now() - pageLoadTime) / 1000)
444+
445+
trackBlogReadComplete({
446+
page_path: pagePath,
447+
estimated_read_time: readingTime,
448+
time_on_page: timeOnPage,
449+
})
450+
451+
observer.disconnect()
452+
}
453+
})
454+
},
455+
{ threshold: 0.5 },
456+
)
457+
458+
observer.observe(articleFooter)
459+
}
460+
}
461+
462+
// Initialize when DOM is ready
463+
if (document.readyState === 'loading') {
464+
document.addEventListener('DOMContentLoaded', initBlogAnalytics)
465+
} else {
466+
initBlogAnalytics()
467+
}
468+
</script>

src/components/blog/BookPromo.astro

Lines changed: 68 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
EXERCISES,
1111
BUY_LINK_PRINT,
1212
} from '@lib/const'
13+
import { extractPromoVariant } from '@lib/analytics'
1314
1415
const images = import.meta.glob<{ default: ImageMetadata }>(
1516
'/src/images/promo/*.{jpeg,jpg,png}',
@@ -18,25 +19,25 @@ const imagesIds = Object.keys(images)
1819
1920
const imagesAlt = {
2021
'/src/images/promo/promo01.png':
21-
'Stack of two copies of Node.js Design Patterns – Fourth Edition on a purple gradient surface; top book slightly rotated, showing the purple wave cover and authors portraits.',
22+
"Stack of two copies of 'Node.js Design Patterns – Fourth Edition' on a purple gradient surface; top book slightly rotated, showing the purple wave cover and authors' portraits.",
2223
'/src/images/promo/promo02.png':
23-
'Man reading Node.js Design Patterns – Fourth Edition on a subway train, standing and smiling; front cover with the purple wave design visible.',
24+
"Man reading 'Node.js Design Patterns – Fourth Edition' on a subway train, standing and smiling; front cover with the purple wave design visible.",
2425
'/src/images/promo/promo03.png':
25-
'Studio render of the Node.js Design Patterns – Fourth Edition hardcover floating against a blue gradient backdrop, front cover with purple wave graphic centered.',
26+
"Studio render of the 'Node.js Design Patterns – Fourth Edition' hardcover floating against a blue gradient backdrop, front cover with purple wave graphic centered.",
2627
'/src/images/promo/promo04.png':
27-
'Lifestyle shot of a woman in a cafe holding the Node.js Design Patterns – Fourth Edition book; greenery and warm lights in the background, cover fully visible.',
28+
"Lifestyle shot of a woman in a cafe holding the 'Node.js Design Patterns – Fourth Edition' book; greenery and warm lights in the background, cover fully visible.",
2829
'/src/images/promo/promo05.png':
29-
'Person holding the Node.js Design Patterns – Fourth Edition hardcover against a neutral background; tilted view showing authors portraits and Packt logo on the front.',
30+
"Person holding the 'Node.js Design Patterns – Fourth Edition' hardcover against a neutral background; tilted view showing authors' portraits and Packt logo on the front.",
3031
'/src/images/promo/promo06.png':
31-
'Close-up of the top corner of the Node.js Design Patterns cover on a purple gradient background, highlighting the large title text and flowing purple wave graphic.',
32+
"Close-up of the top corner of the 'Node.js Design Patterns' cover on a purple gradient background, highlighting the large title text and flowing purple wave graphic.",
3233
'/src/images/promo/promo07.png':
33-
'Smiling man presenting the Node.js Design Patterns – Fourth Edition book toward the camera in a bright studio setting; cover and Packt logo clearly shown.',
34+
"Smiling man presenting the 'Node.js Design Patterns – Fourth Edition' book toward the camera in a bright studio setting; cover and Packt logo clearly shown.",
3435
'/src/images/promo/promo08.png':
35-
'Angled view of the Node.js Design Patterns – Fourth Edition paperback lying on a light gray surface, showing the spine, title, purple wave design, and Packt branding.',
36+
"Angled view of the 'Node.js Design Patterns – Fourth Edition' paperback lying on a light gray surface, showing the spine, title, purple wave design, and Packt branding.",
3637
'/src/images/promo/promo09.png':
37-
'Hand holding the Node.js Design Patterns – Fourth Edition book on a beige desk next to an open book; front cover with the purple wave motif clearly visible.',
38+
"Hand holding the 'Node.js Design Patterns – Fourth Edition' book on a beige desk next to an open book; front cover with the purple wave motif clearly visible.",
3839
'/src/images/promo/promo10.png':
39-
'Hardcover of Node.js Design Patterns – Fourth Edition on a purple surface with a wooden pencil resting diagonally on the cover; black cover with purple wave graphic, authors portraits, and Packt logo.',
40+
"Hardcover of 'Node.js Design Patterns – Fourth Edition' on a purple surface with a wooden pencil resting diagonally on the cover; black cover with purple wave graphic, authors' portraits, and Packt logo.",
4041
}
4142
4243
const promoMessages = {
@@ -65,10 +66,17 @@ const selectedPromoImgAlt =
6566
imagesAlt[selectedPromoKey as keyof typeof imagesAlt]
6667
const selectedPromoMessage =
6768
promoMessages[selectedPromoKey as keyof typeof promoMessages]
69+
70+
// Extract variant ID for analytics (e.g., "promo05" from "/src/images/promo/promo05.png")
71+
const promoVariant = extractPromoVariant(selectedPromoKey)
6872
---
6973

7074
<div
75+
id="blog-promo-cta"
7176
class="group bg-base-100 border-2 border-base-200 rounded-lg shadow-lg hover:shadow-xl hover:-translate-y-0.5 hover:border-primary transition-all duration-300 relative overflow-hidden"
77+
data-analytics-variant={promoVariant}
78+
data-analytics-cta-type="book_promo_card"
79+
data-analytics-cta-position="sidebar"
7280
>
7381
<a class="absolute inset-0 text-[0px]" href={BUY_LINK_PRINT}
7482
>Link to buy Node.js Design Patterns</a
@@ -99,3 +107,53 @@ const selectedPromoMessage =
99107
</div>
100108
</div>
101109
</div>
110+
111+
<script>
112+
import {
113+
trackViewBlogCta,
114+
trackClickBlogCta,
115+
observeOnce,
116+
getPagePath,
117+
type PromoVariant,
118+
type CtaPosition,
119+
} from '@lib/analytics'
120+
121+
function initBlogPromoTracking() {
122+
const promoCard = document.getElementById('blog-promo-cta')
123+
if (!promoCard) return
124+
125+
const variant = (promoCard.dataset.analyticsVariant ||
126+
'promo01') as PromoVariant
127+
const ctaType = promoCard.dataset.analyticsCtaType || 'book_promo_card'
128+
const ctaPosition = (promoCard.dataset.analyticsCtaPosition ||
129+
'sidebar') as CtaPosition
130+
const pagePath = getPagePath()
131+
132+
// Track view when promo card becomes visible
133+
observeOnce(promoCard, () => {
134+
trackViewBlogCta({
135+
cta_type: ctaType,
136+
cta_position: ctaPosition,
137+
cta_variant: variant,
138+
page_path: pagePath,
139+
})
140+
})
141+
142+
// Track clicks on the promo card (the whole card is clickable)
143+
promoCard.addEventListener('click', () => {
144+
trackClickBlogCta({
145+
cta_type: ctaType,
146+
cta_position: ctaPosition,
147+
cta_variant: variant,
148+
page_path: pagePath,
149+
})
150+
})
151+
}
152+
153+
// Initialize when DOM is ready
154+
if (document.readyState === 'loading') {
155+
document.addEventListener('DOMContentLoaded', initBlogPromoTracking)
156+
} else {
157+
initBlogPromoTracking()
158+
}
159+
</script>

src/components/pages/Home/ActionPlan.astro

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ import { PAGES, EXAMPLES, EXERCISES } from '@lib/const'
106106
</div>
107107

108108
<div class="flex justify-center">
109-
<BuyButtons />
109+
<BuyButtons location="action_plan" />
110110
</div>
111111
</div>
112112
</section>

0 commit comments

Comments
 (0)