Skip to content

Commit 9479512

Browse files
authored
feat: add analytics tracking (#30)
* feat: add analytics tracking * chore: typing improvements * chore: fixes after review * fix: small glitch in book promo (image not clickable) * chore: various fixes and improvements after review * chore: small linting fix
1 parent c48f1d4 commit 9479512

14 files changed

Lines changed: 1372 additions & 27 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: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,35 @@
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",
40+
"Bash(pnpm typecheck:*)",
41+
"Bash(pnpm lint:*)",
42+
"mcp__playwright__browser_wait_for",
43+
"mcp__playwright__browser_network_requests",
44+
"mcp__playwright__browser_close",
45+
"mcp__playwright__browser_resize",
46+
"mcp__playwright__browser_run_code"
1947
]
2048
}
2149
}

src/Layout.astro

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,85 @@ const {
163163
import { initTheme } from './lib/theme.ts'
164164
initTheme()
165165
</script>
166+
167+
<!-- Global Analytics: Link Click Tracking (outbound + internal navigation) -->
168+
<script>
169+
import {
170+
trackClickOutboundLink,
171+
trackInternalNavigation,
172+
isExternalUrl,
173+
extractDomain,
174+
getPagePath,
175+
type NavigationType,
176+
} from './lib/analytics.ts'
177+
178+
function initLinkClickTracking() {
179+
const currentPath = getPagePath()
180+
181+
document.addEventListener('click', (event) => {
182+
const target = event.target as HTMLElement
183+
const link = target.closest('a')
184+
185+
if (!link) return
186+
187+
const href = link.getAttribute('href')
188+
if (!href) return
189+
190+
// Skip if it's a buy button (already tracked separately)
191+
if (link.hasAttribute('data-analytics-format')) return
192+
193+
const isExternal = isExternalUrl(href)
194+
195+
if (isExternal) {
196+
// Track outbound link
197+
trackClickOutboundLink({
198+
link_url: href,
199+
link_domain: extractDomain(href),
200+
link_text: link.textContent?.trim().slice(0, 100) || '',
201+
source_page: currentPath,
202+
})
203+
} else {
204+
// Skip anchor links on same page
205+
if (href.startsWith('#')) return
206+
207+
// Determine navigation type based on element location
208+
let navigationType: NavigationType = 'inline_link'
209+
210+
const nav = link.closest('nav')
211+
const header = link.closest('header')
212+
const footer = link.closest('footer')
213+
214+
if (nav || header) {
215+
navigationType = 'header_link'
216+
} else if (footer) {
217+
navigationType = 'footer_link'
218+
}
219+
220+
// Get the destination path
221+
let toPage = href
222+
try {
223+
const url = new URL(href, window.location.origin)
224+
toPage = url.pathname
225+
} catch {
226+
// Keep original href if URL parsing fails
227+
}
228+
229+
trackInternalNavigation({
230+
from_page: currentPath,
231+
to_page: toPage,
232+
navigation_type: navigationType,
233+
})
234+
}
235+
})
236+
}
237+
238+
// Initialize when DOM is ready
239+
if (document.readyState === 'loading') {
240+
document.addEventListener('DOMContentLoaded', initLinkClickTracking)
241+
} else {
242+
initLinkClickTracking()
243+
}
244+
</script>
166245
<Font cssVariable="--font-base-sans" preload />
167246
<Font cssVariable="--font-base-serif" preload />
168247
<Font cssVariable="--font-base-mono" preload />

src/components/blog/BlogLayout.astro

Lines changed: 108 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,101 @@ 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+
// Handle edge case: very short pages where content fits in viewport
406+
const scrollPercent =
407+
docHeight <= 0 ? 100 : Math.round((scrollTop / docHeight) * 100)
408+
409+
for (const threshold of scrollThresholds) {
410+
if (scrollPercent >= threshold && !firedThresholds.has(threshold)) {
411+
firedThresholds.add(threshold)
412+
trackScrollDepth({
413+
percent_scrolled: threshold,
414+
page_path: pagePath,
415+
content_type: 'blog_post',
416+
})
417+
}
418+
}
419+
420+
// Remove listener once all thresholds have been tracked
421+
if (firedThresholds.size === scrollThresholds.length) {
422+
window.removeEventListener('scroll', handleScroll)
423+
}
424+
}
425+
426+
// Throttled scroll handler
427+
let scrollTimeout: ReturnType<typeof setTimeout> | null = null
428+
function handleScroll() {
429+
if (scrollTimeout) return
430+
scrollTimeout = setTimeout(() => {
431+
checkScrollDepth()
432+
scrollTimeout = null
433+
}, 100)
434+
}
435+
436+
window.addEventListener('scroll', handleScroll, { passive: true })
437+
438+
// Check initial scroll position (in case user starts mid-page)
439+
checkScrollDepth()
440+
441+
// Track blog read completion when article footer becomes visible
442+
if (articleFooter) {
443+
let hasTrackedCompletion = false
444+
445+
const observer = new IntersectionObserver(
446+
(entries) => {
447+
entries.forEach((entry) => {
448+
if (entry.isIntersecting && !hasTrackedCompletion) {
449+
hasTrackedCompletion = true
450+
const timeOnPage = Math.round((Date.now() - pageLoadTime) / 1000)
451+
452+
trackBlogReadComplete({
453+
page_path: pagePath,
454+
estimated_read_time: readingTime,
455+
time_on_page: timeOnPage,
456+
})
457+
458+
observer.disconnect()
459+
}
460+
})
461+
},
462+
{ threshold: 0.5 },
463+
)
464+
465+
observer.observe(articleFooter)
466+
}
467+
}
468+
469+
// Initialize when DOM is ready
470+
if (document.readyState === 'loading') {
471+
document.addEventListener('DOMContentLoaded', initBlogAnalytics)
472+
} else {
473+
initBlogAnalytics()
474+
}
475+
</script>

src/components/blog/BookPromo.astro

Lines changed: 72 additions & 12 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,16 +66,23 @@ 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
7583
>
7684

77-
<div class="flex flex-col no-underline text-inherit">
85+
<div class="flex flex-col no-underline text-inherit pointer-events-none">
7886
<div class="w-full h-48 overflow-hidden">
7987
<Image
8088
src={selectedPromoImg}
@@ -87,7 +95,7 @@ const selectedPromoMessage =
8795
{selectedPromoMessage}
8896
</p>
8997
<span
90-
class="inline-flex items-center font-semibold text-primary text-sm mt-auto relative"
98+
class="inline-flex items-center font-semibold text-primary text-sm mt-auto relative pointer-events-auto"
9199
>
92100
<a href={BUY_LINK_PRINT} class="relative"
93101
>Get Your Copy Today →
@@ -99,3 +107,55 @@ const selectedPromoMessage =
99107
</div>
100108
</div>
101109
</div>
110+
111+
<script>
112+
import {
113+
trackViewBlogCta,
114+
trackClickBlogCta,
115+
observeOnce,
116+
getPagePath,
117+
validatePromoVariant,
118+
validateCtaPosition,
119+
} from '@lib/analytics'
120+
121+
function initBlogPromoTracking() {
122+
const promoCard = document.getElementById('blog-promo-cta')
123+
if (!promoCard) return
124+
125+
const variant = validatePromoVariant(
126+
promoCard.dataset.analyticsVariant || 'promo01',
127+
)
128+
const ctaType = promoCard.dataset.analyticsCtaType || 'book_promo_card'
129+
const ctaPosition = validateCtaPosition(
130+
promoCard.dataset.analyticsCtaPosition || 'sidebar',
131+
)
132+
const pagePath = getPagePath()
133+
134+
// Track view when promo card becomes visible
135+
observeOnce(promoCard, () => {
136+
trackViewBlogCta({
137+
cta_type: ctaType,
138+
cta_position: ctaPosition,
139+
cta_variant: variant,
140+
page_path: pagePath,
141+
})
142+
})
143+
144+
// Track clicks on the promo card (the whole card is clickable)
145+
promoCard.addEventListener('click', () => {
146+
trackClickBlogCta({
147+
cta_type: ctaType,
148+
cta_position: ctaPosition,
149+
cta_variant: variant,
150+
page_path: pagePath,
151+
})
152+
})
153+
}
154+
155+
// Initialize when DOM is ready
156+
if (document.readyState === 'loading') {
157+
document.addEventListener('DOMContentLoaded', initBlogPromoTracking)
158+
} else {
159+
initBlogPromoTracking()
160+
}
161+
</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)