Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
464 changes: 464 additions & 0 deletions .claude/features/analytics-events-tracking/tasks.md

Large diffs are not rendered by default.

30 changes: 29 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,35 @@
"Bash(node:*)",
"WebFetch(domain:github.com)",
"WebFetch(domain:nodesource.com)",
"Bash(ls:*)"
"Bash(ls:*)",
"Skill(marketing-skills:analytics-tracking)",
"Bash(bash scripts/check-task-prerequisites.sh:*)",
"mcp__analytics-mcp__get_account_summaries",
"mcp__analytics-mcp__get_property_details",
"mcp__analytics-mcp__get_custom_dimensions_and_metrics",
"mcp__analytics-mcp__run_report",
"Bash(scripts/check-task-prerequisites.sh:*)",
"Bash(git -C /Users/luciano/Documents/nodejsdesignpatterns.com log --oneline -5)",
"Bash(git -C /Users/luciano/Documents/nodejsdesignpatterns.com diff --name-only HEAD~1..HEAD)",
"Bash(pnpm run build:*)",
"Bash(pnpm run lint:*)",
"Bash(npx eslint:*)",
"mcp__playwright__browser_navigate",
"mcp__playwright__browser_console_messages",
"mcp__playwright__browser_snapshot",
"mcp__playwright__browser_press_key",
"mcp__playwright__browser_evaluate",
"mcp__analytics-mcp__run_realtime_report",
"Bash(python3:*)",
"mcp__playwright__browser_click",
"mcp__playwright__browser_type",
"Bash(pnpm typecheck:*)",
"Bash(pnpm lint:*)",
"mcp__playwright__browser_wait_for",
"mcp__playwright__browser_network_requests",
"mcp__playwright__browser_close",
"mcp__playwright__browser_resize",
"mcp__playwright__browser_run_code"
]
}
}
79 changes: 79 additions & 0 deletions src/Layout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,85 @@ const {
import { initTheme } from './lib/theme.ts'
initTheme()
</script>

<!-- Global Analytics: Link Click Tracking (outbound + internal navigation) -->
<script>
import {
trackClickOutboundLink,
trackInternalNavigation,
isExternalUrl,
extractDomain,
getPagePath,
type NavigationType,
} from './lib/analytics.ts'

function initLinkClickTracking() {
const currentPath = getPagePath()

document.addEventListener('click', (event) => {
const target = event.target as HTMLElement
const link = target.closest('a')

if (!link) return

const href = link.getAttribute('href')
if (!href) return

// Skip if it's a buy button (already tracked separately)
if (link.hasAttribute('data-analytics-format')) return

const isExternal = isExternalUrl(href)

if (isExternal) {
// Track outbound link
trackClickOutboundLink({
link_url: href,
link_domain: extractDomain(href),
link_text: link.textContent?.trim().slice(0, 100) || '',
source_page: currentPath,
})
} else {
// Skip anchor links on same page
if (href.startsWith('#')) return

// Determine navigation type based on element location
let navigationType: NavigationType = 'inline_link'

const nav = link.closest('nav')
const header = link.closest('header')
const footer = link.closest('footer')

if (nav || header) {
navigationType = 'header_link'
} else if (footer) {
navigationType = 'footer_link'
}

// Get the destination path
let toPage = href
try {
const url = new URL(href, window.location.origin)
toPage = url.pathname
} catch {
// Keep original href if URL parsing fails
}

trackInternalNavigation({
from_page: currentPath,
to_page: toPage,
navigation_type: navigationType,
})
}
})
}

// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initLinkClickTracking)
} else {
initLinkClickTracking()
}
</script>
<Font cssVariable="--font-base-sans" preload />
<Font cssVariable="--font-base-serif" preload />
<Font cssVariable="--font-base-mono" preload />
Expand Down
111 changes: 108 additions & 3 deletions src/components/blog/BlogLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,11 @@ if (post.rendered?.metadata?.headings) {
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div class="flex flex-col md:flex-row gap-12">
<!-- Article (left column) -->
<article class="flex-1 min-w-0">
<article
id="blog-article"
class="flex-1 min-w-0"
data-reading-time={readingTime}
>
<!-- ToC -->
<div>
{
Expand Down Expand Up @@ -206,8 +210,11 @@ if (post.rendered?.metadata?.headings) {
</div>
</div>

<!-- Article Footer -->
<aside class="mt-16 pt-8 border-t border-base-300">
<!-- Article Footer (used as read completion marker) -->
<aside
id="article-footer"
class="mt-16 pt-8 border-t border-base-300"
>
<div
class="flex flex-col sm:flex-row items-center justify-between gap-6"
>
Expand Down Expand Up @@ -368,3 +375,101 @@ if (post.rendered?.metadata?.headings) {
</main>
<Footer />
</Layout>

<script>
import {
trackScrollDepth,
trackBlogReadComplete,
getPagePath,
type ScrollDepthThreshold,
} from '@lib/analytics'

function initBlogAnalytics() {
const article = document.getElementById('blog-article')
const articleFooter = document.getElementById('article-footer')

if (!article) return

const pagePath = getPagePath()
const readingTime = parseInt(article.dataset.readingTime || '0', 10)
const pageLoadTime = Date.now()

// Track scroll depth milestones
const scrollThresholds: ScrollDepthThreshold[] = [25, 50, 75, 100]
const firedThresholds = new Set<number>()

function checkScrollDepth() {
const scrollTop = window.scrollY
const docHeight =
document.documentElement.scrollHeight - window.innerHeight
// Handle edge case: very short pages where content fits in viewport
const scrollPercent =
docHeight <= 0 ? 100 : Math.round((scrollTop / docHeight) * 100)

for (const threshold of scrollThresholds) {
if (scrollPercent >= threshold && !firedThresholds.has(threshold)) {
firedThresholds.add(threshold)
trackScrollDepth({
percent_scrolled: threshold,
page_path: pagePath,
content_type: 'blog_post',
})
}
}

// Remove listener once all thresholds have been tracked
if (firedThresholds.size === scrollThresholds.length) {
window.removeEventListener('scroll', handleScroll)
}
}

// Throttled scroll handler
let scrollTimeout: ReturnType<typeof setTimeout> | null = null
function handleScroll() {
if (scrollTimeout) return
scrollTimeout = setTimeout(() => {
checkScrollDepth()
scrollTimeout = null
}, 100)
}

window.addEventListener('scroll', handleScroll, { passive: true })

// Check initial scroll position (in case user starts mid-page)
checkScrollDepth()

// Track blog read completion when article footer becomes visible
if (articleFooter) {
let hasTrackedCompletion = false

const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && !hasTrackedCompletion) {
hasTrackedCompletion = true
const timeOnPage = Math.round((Date.now() - pageLoadTime) / 1000)

trackBlogReadComplete({
page_path: pagePath,
estimated_read_time: readingTime,
time_on_page: timeOnPage,
})

observer.disconnect()
}
})
},
{ threshold: 0.5 },
)

observer.observe(articleFooter)
}
}

// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initBlogAnalytics)
} else {
initBlogAnalytics()
}
</script>
84 changes: 72 additions & 12 deletions src/components/blog/BookPromo.astro
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
EXERCISES,
BUY_LINK_PRINT,
} from '@lib/const'
import { extractPromoVariant } from '@lib/analytics'

const images = import.meta.glob<{ default: ImageMetadata }>(
'/src/images/promo/*.{jpeg,jpg,png}',
Expand All @@ -18,25 +19,25 @@ const imagesIds = Object.keys(images)

const imagesAlt = {
'/src/images/promo/promo01.png':
'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.',
"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.",
'/src/images/promo/promo02.png':
'Man reading Node.js Design Patterns – Fourth Edition on a subway train, standing and smiling; front cover with the purple wave design visible.',
"Man reading 'Node.js Design Patterns – Fourth Edition' on a subway train, standing and smiling; front cover with the purple wave design visible.",
'/src/images/promo/promo03.png':
'Studio render of the Node.js Design Patterns – Fourth Edition hardcover floating against a blue gradient backdrop, front cover with purple wave graphic centered.',
"Studio render of the 'Node.js Design Patterns – Fourth Edition' hardcover floating against a blue gradient backdrop, front cover with purple wave graphic centered.",
'/src/images/promo/promo04.png':
'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.',
"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.",
'/src/images/promo/promo05.png':
'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.',
"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.",
'/src/images/promo/promo06.png':
'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.',
"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.",
'/src/images/promo/promo07.png':
'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.',
"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.",
'/src/images/promo/promo08.png':
'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.',
"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.",
'/src/images/promo/promo09.png':
'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.',
"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.",
'/src/images/promo/promo10.png':
'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.',
"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.",
}

const promoMessages = {
Expand Down Expand Up @@ -65,16 +66,23 @@ const selectedPromoImgAlt =
imagesAlt[selectedPromoKey as keyof typeof imagesAlt]
const selectedPromoMessage =
promoMessages[selectedPromoKey as keyof typeof promoMessages]

// Extract variant ID for analytics (e.g., "promo05" from "/src/images/promo/promo05.png")
const promoVariant = extractPromoVariant(selectedPromoKey)
---

<div
id="blog-promo-cta"
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"
data-analytics-variant={promoVariant}
data-analytics-cta-type="book_promo_card"
data-analytics-cta-position="sidebar"
>
<a class="absolute inset-0 text-[0px]" href={BUY_LINK_PRINT}
>Link to buy Node.js Design Patterns</a
>

<div class="flex flex-col no-underline text-inherit">
<div class="flex flex-col no-underline text-inherit pointer-events-none">
<div class="w-full h-48 overflow-hidden">
<Image
src={selectedPromoImg}
Expand All @@ -87,7 +95,7 @@ const selectedPromoMessage =
{selectedPromoMessage}
</p>
<span
class="inline-flex items-center font-semibold text-primary text-sm mt-auto relative"
class="inline-flex items-center font-semibold text-primary text-sm mt-auto relative pointer-events-auto"
>
<a href={BUY_LINK_PRINT} class="relative"
>Get Your Copy Today →
Expand All @@ -99,3 +107,55 @@ const selectedPromoMessage =
</div>
</div>
</div>

<script>
import {
trackViewBlogCta,
trackClickBlogCta,
observeOnce,
getPagePath,
validatePromoVariant,
validateCtaPosition,
} from '@lib/analytics'

function initBlogPromoTracking() {
const promoCard = document.getElementById('blog-promo-cta')
if (!promoCard) return

const variant = validatePromoVariant(
promoCard.dataset.analyticsVariant || 'promo01',
)
const ctaType = promoCard.dataset.analyticsCtaType || 'book_promo_card'
const ctaPosition = validateCtaPosition(
promoCard.dataset.analyticsCtaPosition || 'sidebar',
)
const pagePath = getPagePath()

// Track view when promo card becomes visible
observeOnce(promoCard, () => {
trackViewBlogCta({
cta_type: ctaType,
cta_position: ctaPosition,
cta_variant: variant,
page_path: pagePath,
})
})

// Track clicks on the promo card (the whole card is clickable)
promoCard.addEventListener('click', () => {
trackClickBlogCta({
cta_type: ctaType,
cta_position: ctaPosition,
cta_variant: variant,
page_path: pagePath,
})
})
}

// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initBlogPromoTracking)
} else {
initBlogPromoTracking()
}
</script>
2 changes: 1 addition & 1 deletion src/components/pages/Home/ActionPlan.astro
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ import { PAGES, EXAMPLES, EXERCISES } from '@lib/const'
</div>

<div class="flex justify-center">
<BuyButtons />
<BuyButtons location="action_plan" />
</div>
</div>
</section>
Loading