From 5360ecd1a3fd97c9b9e3ed97fb6e98821cbab94b Mon Sep 17 00:00:00 2001 From: Luciano Mammino Date: Fri, 30 Jan 2026 12:40:00 +0100 Subject: [PATCH 1/6] feat: add analytics tracking --- .../analytics-events-tracking/tasks.md | 464 ++++++++++++++++++ .claude/settings.local.json | 23 +- src/Layout.astro | 113 +++++ src/components/blog/BlogLayout.astro | 104 +++- src/components/blog/BookPromo.astro | 78 ++- src/components/pages/Home/ActionPlan.astro | 2 +- src/components/pages/Home/Chapters.astro | 22 +- src/components/pages/Home/FreeChapter.astro | 40 +- src/components/pages/Home/Hero.astro | 2 +- .../pages/Home/ProblemStatement.astro | 2 +- src/components/pages/Home/Quotes.astro | 2 +- src/components/pages/Home/Reviews.astro | 2 +- .../pages/Home/components/BuyButtons.astro | 79 ++- src/lib/analytics.ts | 366 ++++++++++++++ 14 files changed, 1275 insertions(+), 24 deletions(-) create mode 100644 .claude/features/analytics-events-tracking/tasks.md create mode 100644 src/lib/analytics.ts diff --git a/.claude/features/analytics-events-tracking/tasks.md b/.claude/features/analytics-events-tracking/tasks.md new file mode 100644 index 0000000..4488ea6 --- /dev/null +++ b/.claude/features/analytics-events-tracking/tasks.md @@ -0,0 +1,464 @@ +# Analytics Events Tracking - Implementation Tasks + +**Feature**: Analytics Events Tracking for nodejsdesignpatterns.com +**Branch**: `feat/analytics-events-tracking` +**Goal**: Implement comprehensive GA4 event tracking to measure user engagement and conversions + +--- + +## Overview + +The site currently has basic GA4 setup (property G-NFE37ZH2W3) via Partytown but lacks custom event tracking. Based on analytics analysis, we need to track: + +- Book purchase intent (buy button views + clicks → CTR) +- Free chapter downloads (form views + submissions → conversion rate) +- Blog CTA performance (views + clicks → CTR) +- Outbound link clicks (Amazon, Packt, etc.) +- Blog engagement (scroll depth, read completion) +- Internal navigation patterns + +### Tracking Philosophy + +For all CTAs, we track both **impressions** (view events via Intersection Observer) and **actions** (click/submit events). This enables calculating click-through rates and conversion rates, not just absolute numbers. + +--- + +## Tasks + +### Phase 1: Setup & Infrastructure + +#### T001 - Create Analytics Utility Module [P] +**File**: `src/lib/analytics.ts` +**Description**: Create a TypeScript module with type-safe helper functions for sending GA4 events via gtag. Must handle Partytown's async nature. + +**Requirements**: +- Export typed `trackEvent()` function that wraps `gtag('event', ...)` +- Handle window.gtag potentially being undefined (Partytown delay) +- Define TypeScript interfaces for all event parameters +- Include event name constants to prevent typos + +**Events to support**: +```typescript +type AnalyticsEvent = + // Conversion events + | 'view_buy_buttons' + | 'click_buy_button' + | 'view_free_chapter_form' + | 'submit_free_chapter_form' + // Engagement events + | 'click_outbound_link' + | 'scroll_depth' + | 'blog_read_complete' + // CTA events (view + click for CTR calculation) + | 'view_blog_cta' + | 'click_blog_cta' +``` + +--- + +#### T002 - Define Event Schema Documentation [P] +**File**: `src/lib/analytics.ts` (inline JSDoc) or `.claude/features/analytics-events-tracking/events-schema.md` +**Description**: Document all custom events with their parameters for future reference. + +**Requirements**: +- Event name +- When it fires +- Parameters and their types +- Expected values + +--- + +### Phase 2: Core Conversion Events + +#### T003 - Track Buy Button Views and Clicks +**Files**: +- `src/components/pages/Home/components/BuyButtons.astro` +- `src/components/blog/BookPromo.astro` + +**Description**: Track when buy buttons become visible (impression) and when clicked. This enables CTR calculation. + +**Event Schema**: +```javascript +// Fired when buttons enter viewport (once per page load) +gtag('event', 'view_buy_buttons', { + source_page: '/blog/some-article' | '/', + button_location: 'hero' | 'sidebar' | 'footer' | 'blog_promo' +}) + +// Fired on click +gtag('event', 'click_buy_button', { + book_format: 'print' | 'ebook', + source_page: '/blog/some-article' | '/', + button_location: 'hero' | 'sidebar' | 'footer' | 'blog_promo' +}) +``` + +**Implementation**: +- Use Intersection Observer to track when buttons enter viewport +- Fire `view_buy_buttons` once per page load when visible +- Add `onclick` handlers for click tracking +- Pass format ('print' or 'ebook') based on button clicked +- Include page path for attribution + +**Metrics enabled**: +- Buy button CTR = `click_buy_button` / `view_buy_buttons` +- Format preference = print clicks vs ebook clicks + +--- + +#### T004 - Track Free Chapter Form Views and Submissions +**File**: `src/components/pages/Home/FreeChapter.astro` + +**Description**: Track when the free chapter form becomes visible (impression) and when submitted. This enables conversion rate calculation. + +**Event Schema**: +```javascript +// Fired when form section enters viewport (once per page load) +gtag('event', 'view_free_chapter_form', { + form_location: 'homepage_free_chapter_section' +}) + +// Fired on form submission +gtag('event', 'submit_free_chapter_form', { + form_location: 'homepage_free_chapter_section' +}) +``` + +**Implementation**: +- Use Intersection Observer to track when form enters viewport +- Fire `view_free_chapter_form` once per page load when visible +- Add `onsubmit` handler to the form +- Fire `submit_free_chapter_form` before form submission (form posts to external Kit.com) +- Consider also tracking form field focus as a micro-conversion + +**Metrics enabled**: +- Form conversion rate = `submit_free_chapter_form` / `view_free_chapter_form` + +--- + +#### T005 - Track Outbound Link Clicks +**Files**: +- `src/components/pages/Home/components/BuyButtons.astro` +- `src/components/blog/BookPromo.astro` +- `src/components/Footer.astro` + +**Description**: Track clicks on links that leave the site (Amazon, Packt, social profiles, etc.) + +**Event Schema**: +```javascript +gtag('event', 'click_outbound_link', { + link_url: 'https://amazon.com/...', + link_domain: 'amazon.com', + link_text: 'Buy print', + source_page: window.location.pathname +}) +``` + +**Implementation**: +- Can use event delegation at document level +- Filter for links with `href` starting with `http` but not matching site domain +- Extract domain from URL for grouping in reports + +--- + +### Phase 3: Engagement Events + +#### T006 - Track Scroll Depth on Blog Posts [P] +**File**: `src/components/blog/BlogLayout.astro` (new script) + +**Description**: Track how far users scroll on blog articles (25%, 50%, 75%, 100%) + +**Event Schema**: +```javascript +gtag('event', 'scroll_depth', { + percent_scrolled: 25 | 50 | 75 | 100, + page_path: '/blog/reading-writing-files-nodejs', + content_type: 'blog_post' +}) +``` + +**Implementation**: +- Use Intersection Observer for performance +- Only fire each threshold once per page load +- Only implement on blog pages (check route) + +--- + +#### T007 - Track Blog Read Completion [P] +**File**: `src/components/blog/BlogLayout.astro` + +**Description**: Fire event when user reaches the end of a blog post (indicates genuine read) + +**Event Schema**: +```javascript +gtag('event', 'blog_read_complete', { + page_path: '/blog/reading-writing-files-nodejs', + estimated_read_time: 8, // minutes + time_on_page: 240 // seconds actually spent +}) +``` + +**Implementation**: +- Trigger when user scrolls to article footer or "related posts" section +- Track time spent on page for validation +- Helps identify content quality vs. bounce rate + +--- + +#### T008 - Track Blog CTA Views and Clicks (with Variant Tracking) +**File**: `src/components/blog/BookPromo.astro` + +**Description**: Track when the book promo card becomes visible (impression) and when clicked. The component has **10 different variants** (promo01-promo10) with unique images and messages that are randomly selected. We need to track which variant performs best. + +**Current variants** (from `BookPromo.astro`): +- `promo01` - "Get the Node.js Design Patterns book. Your playbook to senior-level Node.js." +- `promo02` - "Buy Node.js Design Patterns and master async, streams, and scaling Node.js." +- `promo03` - "Upgrade your Node.js skills: get the book professional developers trust." +- `promo04` - "Join 30,000+ developers. Get the book they use to level up..." +- `promo05` - "The Node.js book with 4.6★ from 780+ reviews. Get your copy." +- `promo06` - "732 pages. 170 examples. 54 exercises..." +- `promo07` - "Stop stitching together half-baked tutorials..." +- `promo08` - "Master all there is to know about Node.js..." +- `promo09` - "Build production-grade Node.js with confidence..." +- `promo10` - "Serious about Node.js? This is the book..." + +**Event Schema**: +```javascript +// Fired when promo card enters viewport (once per page load) +gtag('event', 'view_blog_cta', { + cta_type: 'book_promo_card', + cta_position: 'sidebar', + cta_variant: 'promo05', // variant ID for A/B analysis + page_path: window.location.pathname +}) + +// Fired on click +gtag('event', 'click_blog_cta', { + cta_type: 'book_promo_card', + cta_position: 'sidebar', + cta_variant: 'promo05', // same variant ID + page_path: window.location.pathname +}) +``` + +**Implementation**: +- Extract variant ID from the selected promo key (e.g., `promo05` from `/src/images/promo/promo05.png`) +- Pass variant ID to the client via `data-variant` attribute on the promo card +- Use Intersection Observer to track when promo card enters viewport +- Fire `view_blog_cta` once per page load when visible +- Add click handler to track `click_blog_cta` +- Both events must include the same `cta_variant` for accurate CTR calculation + +**Metrics enabled**: +- Blog CTA CTR by variant = `click_blog_cta (variant X)` / `view_blog_cta (variant X)` +- Best performing variant identification +- Variant performance by blog post (some messages may resonate better with certain content) + +**Future optimization**: +Once we have enough data, we can adjust the random selection to favor higher-performing variants (weighted random selection). + +--- + +### Phase 4: Navigation & Discovery Events + +#### T009 - Track Internal Navigation Patterns [P] +**File**: `src/Layout.astro` or new `src/scripts/analytics-navigation.ts` + +**Description**: Track navigation between key sections (blog, homepage, chapters) + +**Event Schema**: +```javascript +gtag('event', 'internal_navigation', { + from_page: '/', + to_page: '/blog', + navigation_type: 'header_link' | 'footer_link' | 'inline_link' +}) +``` + +**Implementation**: +- Use click event delegation +- Only track internal links (same domain) +- Categorize by navigation location + +--- + +#### T010 - Track Chapter Section Engagement +**File**: `src/components/pages/Home/Chapters.astro` + +**Description**: Track when users interact with the chapters accordion/section + +**Event Schema**: +```javascript +gtag('event', 'view_chapter_details', { + chapter_number: 6, + chapter_title: 'Coding with Streams' +}) +``` + +--- + +### Phase 5: Testing & Validation + +#### T011 - Add GA4 Debug Mode Helper +**File**: `src/lib/analytics.ts` + +**Description**: Add development helper to enable GA4 debug mode and log events to console + +**Requirements**: +- Check for `?debug_analytics=true` URL param or dev environment +- Log all events to console with formatted output +- Enable GA4 debug mode for DebugView in GA4 interface + +--- + +#### T012 - Create Analytics Test Page (Dev Only) +**File**: `src/pages/dev/analytics-test.astro` (add to .gitignore or protect) + +**Description**: Create a test page that triggers all events for validation + +**Requirements**: +- Buttons to trigger each event type +- Display console output +- Only accessible in development + +--- + +#### T013 - Validate Events in GA4 DebugView +**Description**: Manual testing task - verify all events appear correctly in GA4 + +**Checklist**: +- [ ] `view_buy_buttons` fires when buttons enter viewport +- [ ] `click_buy_button` fires with correct format parameter +- [ ] `view_free_chapter_form` fires when form enters viewport +- [ ] `submit_free_chapter_form` fires on form submission +- [ ] `click_outbound_link` captures external links +- [ ] `scroll_depth` fires at correct thresholds (25/50/75/100%) +- [ ] `blog_read_complete` fires at article end +- [ ] `view_blog_cta` fires when promo card enters viewport with correct `cta_variant` +- [ ] `click_blog_cta` fires on promo card click with matching `cta_variant` +- [ ] Variant ID matches between view and click events for the same page load +- [ ] All events have correct page attribution +- [ ] View events only fire once per page load + +--- + +### Phase 6: Documentation & Cleanup + +#### T014 - Update README with Analytics Documentation [P] +**File**: `README.md` or `.claude/features/analytics-events-tracking/README.md` + +**Description**: Document the analytics implementation for future maintainers + +**Include**: +- List of all custom events +- How to add new events +- How to test events +- GA4 property ID and access info + +--- + +#### T015 - Create GA4 Custom Dimensions (Manual) +**Description**: Configure custom dimensions in GA4 admin for richer reporting + +**Dimensions to create**: +- `book_format` (event-scoped) - "print" or "ebook" +- `button_location` (event-scoped) - "hero", "sidebar", "footer", "blog_promo" +- `content_type` (event-scoped) - "blog_post", "homepage", etc. +- `cta_variant` (event-scoped) - "promo01" through "promo10" for A/B analysis +- `cta_position` (event-scoped) - "sidebar", "inline", etc. + +--- + +## Parallel Execution Guide + +The following tasks can be executed in parallel as they modify different files: + +### Parallel Group 1 (Infrastructure) +``` +T001 - Analytics Utility Module +T002 - Event Schema Documentation +``` + +### Parallel Group 2 (Conversion Events) +``` +T003 - Buy Button Clicks +T004 - Free Chapter Form +T005 - Outbound Links +``` + +### Parallel Group 3 (Engagement) +``` +T006 - Scroll Depth (blog) +T007 - Read Completion (blog) +T008 - Blog CTA Clicks +``` + +### Parallel Group 4 (Final) +``` +T011 - Debug Mode Helper +T014 - Documentation +``` + +--- + +## Dependencies + +``` +T001 (Analytics Module) + └── T003, T004, T005, T006, T007, T008, T009, T010 (all event implementations) + └── T011 (Debug Helper) + └── T012, T013 (Testing) + └── T014, T015 (Documentation) +``` + +--- + +## Files Modified Summary + +| File | Tasks | +|------|-------| +| `src/lib/analytics.ts` (NEW) | T001, T002, T011 | +| `src/components/pages/Home/components/BuyButtons.astro` | T003, T005 | +| `src/components/blog/BookPromo.astro` | T003, T005, T008 | +| `src/components/pages/Home/FreeChapter.astro` | T004 | +| `src/components/Footer.astro` | T005 | +| `src/components/blog/BlogLayout.astro` | T006, T007 | +| `src/components/pages/Home/Chapters.astro` | T010 | +| `src/Layout.astro` | T009 | + +--- + +## Success Metrics + +After implementation, we should be able to answer: + +1. **What's the buy button CTR?** (`click_buy_button` / `view_buy_buttons`) and which format is preferred +2. **What's the free chapter form conversion rate?** (`submit_free_chapter_form` / `view_free_chapter_form`) +3. **What's the blog CTA CTR?** (`click_blog_cta` / `view_blog_cta`) and which promo messages perform best +4. **Which blog posts drive the most purchase intent?** (clicks by page_path) +5. **How engaged are blog readers?** (scroll depth distribution, completion rate) +6. **What's the user journey?** (navigation patterns to purchase) + +### Example Calculated Metrics + +| Metric | Formula | +|--------|---------| +| Buy Button CTR | `click_buy_button` ÷ `view_buy_buttons` | +| Free Chapter Conversion | `submit_free_chapter_form` ÷ `view_free_chapter_form` | +| Blog CTA CTR | `click_blog_cta` ÷ `view_blog_cta` | +| **Blog CTA CTR by Variant** | `click_blog_cta (variant X)` ÷ `view_blog_cta (variant X)` | +| Blog Completion Rate | `blog_read_complete` ÷ `page_view (blog)` | +| Print vs Ebook Preference | `click_buy_button (print)` ÷ `click_buy_button (total)` | + +### Variant Performance Analysis + +With 10 promo variants tracked, you can build a report like: + +| Variant | Views | Clicks | CTR | +|---------|-------|--------|-----| +| promo05 (ratings) | 1,200 | 48 | 4.0% | +| promo04 (social proof) | 1,150 | 41 | 3.6% | +| promo06 (stats) | 980 | 29 | 3.0% | +| ... | ... | ... | ... | + +This data can later inform weighted random selection to show higher-performing variants more frequently. diff --git a/.claude/settings.local.json b/.claude/settings.local.json index df9512d..e8d24b1 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -15,7 +15,28 @@ "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" ] } } diff --git a/src/Layout.astro b/src/Layout.astro index fb4f136..4de0936 100644 --- a/src/Layout.astro +++ b/src/Layout.astro @@ -163,6 +163,119 @@ const { import { initTheme } from './lib/theme.ts' initTheme() + + + + + + diff --git a/src/components/blog/BlogLayout.astro b/src/components/blog/BlogLayout.astro index fd02814..6c7f7e9 100644 --- a/src/components/blog/BlogLayout.astro +++ b/src/components/blog/BlogLayout.astro @@ -145,7 +145,11 @@ if (post.rendered?.metadata?.headings) {
-
+
{ @@ -206,8 +210,11 @@ if (post.rendered?.metadata?.headings) {
- -
- +
diff --git a/src/components/pages/Home/Chapters.astro b/src/components/pages/Home/Chapters.astro index c27c925..681bd65 100644 --- a/src/components/pages/Home/Chapters.astro +++ b/src/components/pages/Home/Chapters.astro @@ -45,7 +45,11 @@ const renderedChapters = await Promise.all(
{ renderedChapters.map((chapter) => ( -
+
@@ -137,6 +141,8 @@ const renderedChapters = await Promise.all(
diff --git a/src/components/pages/Home/Hero.astro b/src/components/pages/Home/Hero.astro index 4c0b7f4..619aebd 100644 --- a/src/components/pages/Home/Hero.astro +++ b/src/components/pages/Home/Hero.astro @@ -54,7 +54,7 @@ const numberFormatter = new Intl.NumberFormat('en-US', {
- +
diff --git a/src/components/pages/Home/Quotes.astro b/src/components/pages/Home/Quotes.astro index 8bd65cc..4d0d634 100644 --- a/src/components/pages/Home/Quotes.astro +++ b/src/components/pages/Home/Quotes.astro @@ -91,7 +91,7 @@ const renderedQuotes = (
- +
diff --git a/src/components/pages/Home/Reviews.astro b/src/components/pages/Home/Reviews.astro index 6cd5201..86a7cf3 100644 --- a/src/components/pages/Home/Reviews.astro +++ b/src/components/pages/Home/Reviews.astro @@ -79,7 +79,7 @@ const renderedReviews = await Promise.all(
- +
diff --git a/src/components/pages/Home/components/BuyButtons.astro b/src/components/pages/Home/components/BuyButtons.astro index 210b011..8391b57 100644 --- a/src/components/pages/Home/components/BuyButtons.astro +++ b/src/components/pages/Home/components/BuyButtons.astro @@ -2,16 +2,89 @@ import Button from '@components/ui/button.astro' import { Book, TabletSmartphone } from '@lucide/astro' import { BUY_LINK_PRINT, BUY_LINK_EBOOK } from '@lib/const' +import type { ButtonLocation } from '@lib/analytics' + +interface Props { + location?: ButtonLocation +} + +const { location = 'hero' } = Astro.props --- -
- -
+ + diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts new file mode 100644 index 0000000..899ee95 --- /dev/null +++ b/src/lib/analytics.ts @@ -0,0 +1,366 @@ +/** + * Analytics Utility Module for GA4 Event Tracking + * + * This module provides type-safe helper functions for sending GA4 events. + * It handles Partytown's async nature and provides debug mode for development. + * + * @module analytics + */ + +// Type for gtag function (loaded by Google Tag Manager via Partytown) +type GtagFunction = ( + command: string, + eventNameOrConfig: string, + params?: Record, +) => void + +// ============================================================================ +// Types & Interfaces +// ============================================================================ + +/** Book format options for purchase tracking */ +export type BookFormat = 'print' | 'ebook' + +/** Button/CTA location identifiers */ +export type ButtonLocation = + | 'hero' + | 'problem_statement' + | 'action_plan' + | 'quotes' + | 'reviews' + | 'sidebar' + | 'footer' + | 'blog_promo' + | 'free_chapter_section' + +/** CTA position within a page */ +export type CtaPosition = 'sidebar' | 'inline' | 'footer' + +/** Blog promo variant identifiers (promo01-promo10) */ +export type PromoVariant = + | 'promo01' + | 'promo02' + | 'promo03' + | 'promo04' + | 'promo05' + | 'promo06' + | 'promo07' + | 'promo08' + | 'promo09' + | 'promo10' + +/** Scroll depth thresholds */ +export type ScrollDepthThreshold = 25 | 50 | 75 | 100 + +/** Navigation type identifiers */ +export type NavigationType = 'header_link' | 'footer_link' | 'inline_link' + +// ============================================================================ +// Event Parameter Interfaces +// ============================================================================ + +export interface ViewBuyButtonsParams { + source_page: string + button_location: ButtonLocation +} + +export interface ClickBuyButtonParams { + book_format: BookFormat + source_page: string + button_location: ButtonLocation +} + +export interface ViewFreeChapterFormParams { + form_location: string +} + +export interface SubmitFreeChapterFormParams { + form_location: string +} + +export interface ClickOutboundLinkParams { + link_url: string + link_domain: string + link_text: string + source_page: string +} + +export interface ScrollDepthParams { + percent_scrolled: ScrollDepthThreshold + page_path: string + content_type: string +} + +export interface BlogReadCompleteParams { + page_path: string + estimated_read_time: number + time_on_page: number +} + +export interface ViewBlogCtaParams { + cta_type: string + cta_position: CtaPosition + cta_variant: PromoVariant + page_path: string +} + +export interface ClickBlogCtaParams { + cta_type: string + cta_position: CtaPosition + cta_variant: PromoVariant + page_path: string +} + +export interface InternalNavigationParams { + from_page: string + to_page: string + navigation_type: NavigationType +} + +export interface ViewChapterDetailsParams { + chapter_number: number + chapter_title: string +} + +// ============================================================================ +// Event Names (constants to prevent typos) +// ============================================================================ + +export const ANALYTICS_EVENTS = { + // Conversion events + VIEW_BUY_BUTTONS: 'view_buy_buttons', + CLICK_BUY_BUTTON: 'click_buy_button', + VIEW_FREE_CHAPTER_FORM: 'view_free_chapter_form', + SUBMIT_FREE_CHAPTER_FORM: 'submit_free_chapter_form', + // Engagement events + CLICK_OUTBOUND_LINK: 'click_outbound_link', + SCROLL_DEPTH: 'scroll_depth', + BLOG_READ_COMPLETE: 'blog_read_complete', + // CTA events + VIEW_BLOG_CTA: 'view_blog_cta', + CLICK_BLOG_CTA: 'click_blog_cta', + // Navigation events + INTERNAL_NAVIGATION: 'internal_navigation', + VIEW_CHAPTER_DETAILS: 'view_chapter_details', +} as const + +export type AnalyticsEventName = + (typeof ANALYTICS_EVENTS)[keyof typeof ANALYTICS_EVENTS] + +// ============================================================================ +// Debug Mode +// ============================================================================ + +/** + * Check if analytics debug mode is enabled. + * Enable via URL param: ?debug_analytics=true + * Or in development mode (localhost) + */ +export function isDebugMode(): boolean { + if (typeof window === 'undefined') return false + + const urlParams = new URLSearchParams(window.location.search) + const debugParam = urlParams.get('debug_analytics') === 'true' + const isDev = + window.location.hostname === 'localhost' || + window.location.hostname === '127.0.0.1' + + return debugParam || isDev +} + +/** + * Log analytics event to console in debug mode + */ +function debugLog(eventName: string, params: Record): void { + if (!isDebugMode()) return + + console.group(`📊 Analytics Event: ${eventName}`) + console.log('Parameters:', params) + console.log('Timestamp:', new Date().toISOString()) + console.groupEnd() +} + +// ============================================================================ +// Core Tracking Function +// ============================================================================ + +/** + * Safely get the gtag function, handling Partytown's async loading + */ +function getGtag(): GtagFunction | null { + if (typeof window === 'undefined') return null + // gtag is loaded via Partytown, may not be immediately available + return (window as unknown as { gtag?: GtagFunction }).gtag ?? null +} + +/** + * Generic event tracking function + * Handles cases where gtag might not be loaded yet (Partytown delay) + */ +export function trackEvent( + eventName: string, + params: Record, +): void { + debugLog(eventName, params) + + const gtagFn = getGtag() + if (gtagFn) { + gtagFn('event', eventName, params) + } else { + // If gtag not ready, queue the event for when it becomes available + // This is a fallback for slow Partytown initialization + const checkInterval = setInterval(() => { + const fn = getGtag() + if (fn) { + fn('event', eventName, params) + clearInterval(checkInterval) + } + }, 100) + + // Give up after 5 seconds + setTimeout(() => clearInterval(checkInterval), 5000) + } +} + +// ============================================================================ +// Typed Event Tracking Functions +// ============================================================================ + +/** Track when buy buttons become visible */ +export function trackViewBuyButtons(params: ViewBuyButtonsParams): void { + trackEvent(ANALYTICS_EVENTS.VIEW_BUY_BUTTONS, params) +} + +/** Track buy button clicks */ +export function trackClickBuyButton(params: ClickBuyButtonParams): void { + trackEvent(ANALYTICS_EVENTS.CLICK_BUY_BUTTON, params) +} + +/** Track when free chapter form becomes visible */ +export function trackViewFreeChapterForm( + params: ViewFreeChapterFormParams, +): void { + trackEvent(ANALYTICS_EVENTS.VIEW_FREE_CHAPTER_FORM, params) +} + +/** Track free chapter form submissions */ +export function trackSubmitFreeChapterForm( + params: SubmitFreeChapterFormParams, +): void { + trackEvent(ANALYTICS_EVENTS.SUBMIT_FREE_CHAPTER_FORM, params) +} + +/** Track outbound link clicks */ +export function trackClickOutboundLink(params: ClickOutboundLinkParams): void { + trackEvent(ANALYTICS_EVENTS.CLICK_OUTBOUND_LINK, params) +} + +/** Track scroll depth milestones */ +export function trackScrollDepth(params: ScrollDepthParams): void { + trackEvent(ANALYTICS_EVENTS.SCROLL_DEPTH, params) +} + +/** Track blog article read completion */ +export function trackBlogReadComplete(params: BlogReadCompleteParams): void { + trackEvent(ANALYTICS_EVENTS.BLOG_READ_COMPLETE, params) +} + +/** Track when blog CTA becomes visible */ +export function trackViewBlogCta(params: ViewBlogCtaParams): void { + trackEvent(ANALYTICS_EVENTS.VIEW_BLOG_CTA, params) +} + +/** Track blog CTA clicks */ +export function trackClickBlogCta(params: ClickBlogCtaParams): void { + trackEvent(ANALYTICS_EVENTS.CLICK_BLOG_CTA, params) +} + +/** Track internal navigation */ +export function trackInternalNavigation( + params: InternalNavigationParams, +): void { + trackEvent(ANALYTICS_EVENTS.INTERNAL_NAVIGATION, params) +} + +/** Track chapter details view */ +export function trackViewChapterDetails( + params: ViewChapterDetailsParams, +): void { + trackEvent(ANALYTICS_EVENTS.VIEW_CHAPTER_DETAILS, params) +} + +// ============================================================================ +// Utility Functions +// ============================================================================ + +/** + * Extract domain from a URL + */ +export function extractDomain(url: string): string { + try { + const urlObj = new URL(url) + return urlObj.hostname.replace('www.', '') + } catch { + return 'unknown' + } +} + +/** + * Check if a URL is external (not same domain) + */ +export function isExternalUrl(url: string): boolean { + if (typeof window === 'undefined') return false + try { + const urlObj = new URL(url, window.location.origin) + return urlObj.hostname !== window.location.hostname + } catch { + return false + } +} + +/** + * Get current page path + */ +export function getPagePath(): string { + if (typeof window === 'undefined') return '/' + return window.location.pathname +} + +/** + * Create an Intersection Observer that fires a callback once when element is visible + * @param element - DOM element to observe + * @param callback - Function to call when element becomes visible + * @param threshold - Visibility threshold (0-1), default 0.5 (50% visible) + */ +export function observeOnce( + element: Element, + callback: () => void, + threshold = 0.5, +): IntersectionObserver { + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + callback() + observer.disconnect() + } + }) + }, + { threshold }, + ) + + observer.observe(element) + return observer +} + +/** + * Extract promo variant ID from image path + * e.g., "/src/images/promo/promo05.png" -> "promo05" + */ +export function extractPromoVariant(imagePath: string): PromoVariant { + const match = imagePath.match(/promo(\d+)/) + if (match) { + return `promo${match[1].padStart(2, '0')}` as PromoVariant + } + return 'promo01' // fallback +} From c8b5c42567c91176fddab1424aa037509a465c62 Mon Sep 17 00:00:00 2001 From: Luciano Mammino Date: Fri, 30 Jan 2026 12:48:44 +0100 Subject: [PATCH 2/6] chore: typing improvements --- .claude/settings.local.json | 4 +++- src/lib/analytics.ts | 20 +++++++++++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index e8d24b1..80973d3 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -36,7 +36,9 @@ "mcp__analytics-mcp__run_realtime_report", "Bash(python3:*)", "mcp__playwright__browser_click", - "mcp__playwright__browser_type" + "mcp__playwright__browser_type", + "Bash(pnpm typecheck:*)", + "Bash(pnpm lint:*)" ] } } diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts index 899ee95..85d1821 100644 --- a/src/lib/analytics.ts +++ b/src/lib/analytics.ts @@ -60,25 +60,30 @@ export type NavigationType = 'header_link' | 'footer_link' | 'inline_link' // ============================================================================ export interface ViewBuyButtonsParams { + [key: string]: unknown source_page: string button_location: ButtonLocation } export interface ClickBuyButtonParams { + [key: string]: unknown book_format: BookFormat source_page: string button_location: ButtonLocation } export interface ViewFreeChapterFormParams { + [key: string]: unknown form_location: string } export interface SubmitFreeChapterFormParams { + [key: string]: unknown form_location: string } export interface ClickOutboundLinkParams { + [key: string]: unknown link_url: string link_domain: string link_text: string @@ -86,18 +91,21 @@ export interface ClickOutboundLinkParams { } export interface ScrollDepthParams { + [key: string]: unknown percent_scrolled: ScrollDepthThreshold page_path: string content_type: string } export interface BlogReadCompleteParams { + [key: string]: unknown page_path: string estimated_read_time: number time_on_page: number } export interface ViewBlogCtaParams { + [key: string]: unknown cta_type: string cta_position: CtaPosition cta_variant: PromoVariant @@ -105,6 +113,7 @@ export interface ViewBlogCtaParams { } export interface ClickBlogCtaParams { + [key: string]: unknown cta_type: string cta_position: CtaPosition cta_variant: PromoVariant @@ -112,12 +121,14 @@ export interface ClickBlogCtaParams { } export interface InternalNavigationParams { + [key: string]: unknown from_page: string to_page: string navigation_type: NavigationType } export interface ViewChapterDetailsParams { + [key: string]: unknown chapter_number: number chapter_title: string } @@ -171,7 +182,10 @@ export function isDebugMode(): boolean { /** * Log analytics event to console in debug mode */ -function debugLog(eventName: string, params: Record): void { +function debugLog>( + eventName: string, + params: T, +): void { if (!isDebugMode()) return console.group(`📊 Analytics Event: ${eventName}`) @@ -197,9 +211,9 @@ function getGtag(): GtagFunction | null { * Generic event tracking function * Handles cases where gtag might not be loaded yet (Partytown delay) */ -export function trackEvent( +export function trackEvent>( eventName: string, - params: Record, + params: T, ): void { debugLog(eventName, params) From 6fb64e824f1f15ac6d82c1477bef6487612fe9ea Mon Sep 17 00:00:00 2001 From: Luciano Mammino Date: Fri, 30 Jan 2026 12:58:17 +0100 Subject: [PATCH 3/6] chore: fixes after review --- src/components/blog/BlogLayout.astro | 9 ++++++++- src/lib/analytics.ts | 4 +++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/components/blog/BlogLayout.astro b/src/components/blog/BlogLayout.astro index 6c7f7e9..c5c6b21 100644 --- a/src/components/blog/BlogLayout.astro +++ b/src/components/blog/BlogLayout.astro @@ -402,7 +402,9 @@ if (post.rendered?.metadata?.headings) { const scrollTop = window.scrollY const docHeight = document.documentElement.scrollHeight - window.innerHeight - const scrollPercent = Math.round((scrollTop / docHeight) * 100) + // 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)) { @@ -414,6 +416,11 @@ if (post.rendered?.metadata?.headings) { }) } } + + // Remove listener once all thresholds have been tracked + if (firedThresholds.size === scrollThresholds.length) { + window.removeEventListener('scroll', handleScroll) + } } // Throttled scroll handler diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts index 85d1821..ac29771 100644 --- a/src/lib/analytics.ts +++ b/src/lib/analytics.ts @@ -223,16 +223,18 @@ export function trackEvent>( } else { // If gtag not ready, queue the event for when it becomes available // This is a fallback for slow Partytown initialization + let timeoutId: ReturnType | null = null const checkInterval = setInterval(() => { const fn = getGtag() if (fn) { fn('event', eventName, params) clearInterval(checkInterval) + if (timeoutId) clearTimeout(timeoutId) } }, 100) // Give up after 5 seconds - setTimeout(() => clearInterval(checkInterval), 5000) + timeoutId = setTimeout(() => clearInterval(checkInterval), 5000) } } From 6cf1c173163156b7b5c5795b6751ee35c8930af3 Mon Sep 17 00:00:00 2001 From: Luciano Mammino Date: Fri, 30 Jan 2026 13:20:50 +0100 Subject: [PATCH 4/6] fix: small glitch in book promo (image not clickable) --- .claude/settings.local.json | 5 ++++- src/components/blog/BookPromo.astro | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 80973d3..49b709a 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -38,7 +38,10 @@ "mcp__playwright__browser_click", "mcp__playwright__browser_type", "Bash(pnpm typecheck:*)", - "Bash(pnpm lint:*)" + "Bash(pnpm lint:*)", + "mcp__playwright__browser_wait_for", + "mcp__playwright__browser_network_requests", + "mcp__playwright__browser_close" ] } } diff --git a/src/components/blog/BookPromo.astro b/src/components/blog/BookPromo.astro index 0d86d6f..840d99d 100644 --- a/src/components/blog/BookPromo.astro +++ b/src/components/blog/BookPromo.astro @@ -82,7 +82,7 @@ const promoVariant = extractPromoVariant(selectedPromoKey) >Link to buy Node.js Design Patterns
-
+
Get Your Copy Today → From 0f1f4f32af78005aac42838b9b5dc09515f2f797 Mon Sep 17 00:00:00 2001 From: Luciano Mammino Date: Fri, 30 Jan 2026 14:34:08 +0100 Subject: [PATCH 5/6] chore: various fixes and improvements after review --- .claude/settings.local.json | 4 +- src/Layout.astro | 128 +++++++----------- src/components/blog/BookPromo.astro | 14 +- .../pages/Home/components/BuyButtons.astro | 15 +- src/lib/analytics.ts | 95 +++++++++++++ 5 files changed, 161 insertions(+), 95 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 49b709a..bfa4855 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -41,7 +41,9 @@ "Bash(pnpm lint:*)", "mcp__playwright__browser_wait_for", "mcp__playwright__browser_network_requests", - "mcp__playwright__browser_close" + "mcp__playwright__browser_close", + "mcp__playwright__browser_resize", + "mcp__playwright__browser_run_code" ] } } diff --git a/src/Layout.astro b/src/Layout.astro index 4de0936..2baf451 100644 --- a/src/Layout.astro +++ b/src/Layout.astro @@ -164,58 +164,18 @@ const { initTheme() - + - - - diff --git a/src/components/blog/BookPromo.astro b/src/components/blog/BookPromo.astro index 840d99d..a8f4710 100644 --- a/src/components/blog/BookPromo.astro +++ b/src/components/blog/BookPromo.astro @@ -114,19 +114,21 @@ const promoVariant = extractPromoVariant(selectedPromoKey) trackClickBlogCta, observeOnce, getPagePath, - type PromoVariant, - type CtaPosition, + validatePromoVariant, + validateCtaPosition, } from '@lib/analytics' function initBlogPromoTracking() { const promoCard = document.getElementById('blog-promo-cta') if (!promoCard) return - const variant = (promoCard.dataset.analyticsVariant || - 'promo01') as PromoVariant + const variant = validatePromoVariant( + promoCard.dataset.analyticsVariant || 'promo01', + ) const ctaType = promoCard.dataset.analyticsCtaType || 'book_promo_card' - const ctaPosition = (promoCard.dataset.analyticsCtaPosition || - 'sidebar') as CtaPosition + const ctaPosition = validateCtaPosition( + promoCard.dataset.analyticsCtaPosition || 'sidebar', + ) const pagePath = getPagePath() // Track view when promo card becomes visible diff --git a/src/components/pages/Home/components/BuyButtons.astro b/src/components/pages/Home/components/BuyButtons.astro index 8391b57..e8e0fb3 100644 --- a/src/components/pages/Home/components/BuyButtons.astro +++ b/src/components/pages/Home/components/BuyButtons.astro @@ -44,16 +44,17 @@ const { location = 'hero' } = Astro.props trackClickBuyButton, observeOnce, getPagePath, - type BookFormat, - type ButtonLocation, + validateBookFormat, + validateButtonLocation, } from '@lib/analytics' function initBuyButtonsTracking() { const containers = document.querySelectorAll('.buy-buttons-container') containers.forEach((container) => { - const location = (container.getAttribute('data-analytics-location') || - 'hero') as ButtonLocation + const location = validateButtonLocation( + container.getAttribute('data-analytics-location') || 'hero', + ) const pagePath = getPagePath() // Track view when buttons become visible (once per container) @@ -68,9 +69,9 @@ const { location = 'hero' } = Astro.props const buttons = container.querySelectorAll('a[data-analytics-format]') buttons.forEach((button) => { button.addEventListener('click', () => { - const format = button.getAttribute( - 'data-analytics-format', - ) as BookFormat + const format = validateBookFormat( + button.getAttribute('data-analytics-format') || 'print', + ) trackClickBuyButton({ book_format: format, source_page: pagePath, diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts index ac29771..44fff3a 100644 --- a/src/lib/analytics.ts +++ b/src/lib/analytics.ts @@ -378,5 +378,100 @@ export function extractPromoVariant(imagePath: string): PromoVariant { if (match) { return `promo${match[1].padStart(2, '0')}` as PromoVariant } + if (isDebugMode()) { + console.warn( + `[Analytics] Could not extract promo variant from: ${imagePath}`, + ) + } + return 'promo01' // fallback +} + +// ============================================================================ +// Type Validation Helpers (with debug warnings) +// ============================================================================ + +const VALID_BOOK_FORMATS: BookFormat[] = ['print', 'ebook'] +const VALID_BUTTON_LOCATIONS: ButtonLocation[] = [ + 'hero', + 'problem_statement', + 'action_plan', + 'quotes', + 'reviews', + 'sidebar', + 'footer', + 'blog_promo', + 'free_chapter_section', +] +const VALID_PROMO_VARIANTS: PromoVariant[] = [ + 'promo01', + 'promo02', + 'promo03', + 'promo04', + 'promo05', + 'promo06', + 'promo07', + 'promo08', + 'promo09', + 'promo10', +] +const VALID_CTA_POSITIONS: CtaPosition[] = ['sidebar', 'inline', 'footer'] + +/** + * Validate book format value with debug warning for invalid values + */ +export function validateBookFormat(value: string): BookFormat { + if (VALID_BOOK_FORMATS.includes(value as BookFormat)) { + return value as BookFormat + } + if (isDebugMode()) { + console.warn( + `[Analytics] Invalid book_format: "${value}". Expected one of: ${VALID_BOOK_FORMATS.join(', ')}`, + ) + } + return 'print' // fallback +} + +/** + * Validate button location value with debug warning for invalid values + */ +export function validateButtonLocation(value: string): ButtonLocation { + if (VALID_BUTTON_LOCATIONS.includes(value as ButtonLocation)) { + return value as ButtonLocation + } + if (isDebugMode()) { + console.warn( + `[Analytics] Invalid button_location: "${value}". Expected one of: ${VALID_BUTTON_LOCATIONS.join(', ')}`, + ) + } + return 'hero' // fallback +} + +/** + * Validate promo variant value with debug warning for invalid values + */ +export function validatePromoVariant(value: string): PromoVariant { + if (VALID_PROMO_VARIANTS.includes(value as PromoVariant)) { + return value as PromoVariant + } + if (isDebugMode()) { + console.warn( + `[Analytics] Invalid promo_variant: "${value}". Expected one of: ${VALID_PROMO_VARIANTS.join(', ')}`, + ) + } return 'promo01' // fallback } + +/** + * Validate CTA position value with debug warning for invalid values + */ +export function validateCtaPosition(value: string): CtaPosition { + if (VALID_CTA_POSITIONS.includes(value as CtaPosition)) { + return value as CtaPosition + } + if (isDebugMode()) { + console.warn( + `[Analytics] Invalid cta_position: "${value}". Expected one of: ${VALID_CTA_POSITIONS.join(', ')}`, + ) + } + return 'sidebar' // fallback +} From deeb3fab75918ad7d8f7c5e8fa787ea41987602a Mon Sep 17 00:00:00 2001 From: Luciano Mammino Date: Fri, 30 Jan 2026 15:28:54 +0100 Subject: [PATCH 6/6] chore: small linting fix --- src/components/pages/Home/Chapters.astro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/pages/Home/Chapters.astro b/src/components/pages/Home/Chapters.astro index 681bd65..4c17b03 100644 --- a/src/components/pages/Home/Chapters.astro +++ b/src/components/pages/Home/Chapters.astro @@ -18,7 +18,7 @@ function readingTime(numWords: number): number { } const chapters = (await getCollection('chapters')).sort((a, b) => { - return parseInt(a.data.number) - parseInt(b.data.number) + return Number.parseInt(a.data.number) - Number.parseInt(b.data.number) }) const renderedChapters = await Promise.all( chapters.map(async (chapter) => {