From cb8720ea34d37a58a286c959d1e07e82b53727ad Mon Sep 17 00:00:00 2001 From: Eric johnson Date: Sun, 14 Dec 2025 13:37:52 -0800 Subject: [PATCH] feat: integrate @vercel/analytics for enhanced tracking and analytics capabilities; refactor layout and components for improved structure and UX --- ...home_page_intent_tracking_dcdd06f4.plan.md | 293 +++++++++++++++++ apps/web/package.json | 1 + apps/web/src/app/layout.tsx | 7 +- apps/web/src/app/page.tsx | 217 +------------ apps/web/src/components/CodePreview.tsx | 20 +- apps/web/src/components/FeaturesSection.tsx | 224 ++++++++----- apps/web/src/components/HomePageClient.tsx | 307 ++++++++++++++++++ apps/web/src/components/IntentProvider.tsx | 186 +++++++++++ apps/web/src/components/PackagesSection.tsx | 192 ++++++----- apps/web/src/components/QuickStartSection.tsx | 31 +- apps/web/src/components/RoadmapSummary.tsx | 35 +- .../ImmersiveWorkstreamShowcase.tsx | 48 ++- apps/web/src/lib/intents/homepage.ts | 85 +++++ apps/web/src/lib/intents/index.ts | 7 + apps/web/src/lib/tracking/hooks/index.ts | 10 + .../src/lib/tracking/hooks/useScrollDepth.ts | 85 +++++ .../src/lib/tracking/hooks/useSectionView.ts | 191 +++++++++++ .../src/lib/tracking/hooks/useTimeOnPage.ts | 85 +++++ .../src/lib/tracking/hooks/useTrackIntent.ts | 73 +++++ apps/web/src/lib/tracking/index.ts | 59 ++++ apps/web/src/lib/tracking/manager.ts | 282 ++++++++++++++++ .../web/src/lib/tracking/providers/console.ts | 46 +++ apps/web/src/lib/tracking/providers/index.ts | 8 + apps/web/src/lib/tracking/providers/vercel.ts | 63 ++++ apps/web/src/lib/tracking/types.ts | 111 +++++++ yarn.lock | 5 + 26 files changed, 2284 insertions(+), 387 deletions(-) create mode 100644 .cursor/plans/home_page_intent_tracking_dcdd06f4.plan.md create mode 100644 apps/web/src/components/HomePageClient.tsx create mode 100644 apps/web/src/components/IntentProvider.tsx create mode 100644 apps/web/src/lib/intents/homepage.ts create mode 100644 apps/web/src/lib/intents/index.ts create mode 100644 apps/web/src/lib/tracking/hooks/index.ts create mode 100644 apps/web/src/lib/tracking/hooks/useScrollDepth.ts create mode 100644 apps/web/src/lib/tracking/hooks/useSectionView.ts create mode 100644 apps/web/src/lib/tracking/hooks/useTimeOnPage.ts create mode 100644 apps/web/src/lib/tracking/hooks/useTrackIntent.ts create mode 100644 apps/web/src/lib/tracking/index.ts create mode 100644 apps/web/src/lib/tracking/manager.ts create mode 100644 apps/web/src/lib/tracking/providers/console.ts create mode 100644 apps/web/src/lib/tracking/providers/index.ts create mode 100644 apps/web/src/lib/tracking/providers/vercel.ts create mode 100644 apps/web/src/lib/tracking/types.ts diff --git a/.cursor/plans/home_page_intent_tracking_dcdd06f4.plan.md b/.cursor/plans/home_page_intent_tracking_dcdd06f4.plan.md new file mode 100644 index 0000000..0fabf31 --- /dev/null +++ b/.cursor/plans/home_page_intent_tracking_dcdd06f4.plan.md @@ -0,0 +1,293 @@ +--- +name: Home Page Intent Tracking +overview: Integrate protocol intents into the home page with comprehensive tracking (active interactions, passive views, engagement metrics) using a custom extensible tracking layer that sends to Vercel Analytics and supports additional providers. +todos: + - id: create-intents + content: Create web-specific intents enum in apps/web/src/lib/intents/homepage.ts + status: completed + - id: create-tracking-types + content: Create tracking types (TrackingEvent, TrackingProvider) in apps/web/src/lib/tracking/types.ts + status: completed + - id: create-tracking-manager + content: Build tracking manager singleton with provider registration and batching + status: completed + - id: create-vercel-provider + content: Create Vercel Analytics provider adapter + status: completed + - id: create-tracking-hooks + content: Create useTrackIntent, useScrollDepth, and useSectionView hooks + status: completed + - id: create-intent-provider + content: Create IntentProvider context component for scroll/time tracking + status: completed + - id: update-layout + content: Update apps/web/src/app/layout.tsx to add Analytics and IntentProvider + status: completed + - id: update-homepage + content: Update page.tsx with intents for nav, hero, footer sections + status: completed + - id: update-features-section + content: Add section view and card hover intents to FeaturesSection + status: completed + - id: update-packages-section + content: Add section view and card hover intents to PackagesSection + status: completed + - id: update-quickstart-section + content: Add intents to QuickStartSection + status: completed + - id: update-roadmap-summary + content: Add intents to RoadmapSummary + status: completed + - id: update-immersive-workstream + content: Add intents to ImmersiveWorkstreamShowcase + status: completed + - id: update-code-preview + content: Add copy tracking to CodePreview component + status: completed + - id: test-tracking + content: Test tracking by verifying events in browser console and Vercel dashboard + status: completed +--- + +# Home Page Intent Tracking Integration + +## Architecture Overview + +```mermaid +flowchart TD + subgraph components [Home Page Components] + Nav[Navigation] + Hero[Hero Section] + Immersive[ImmersiveWorkstream] + Features[FeaturesSection] + Packages[PackagesSection] + QuickStart[QuickStartSection] + Footer[Footer] + end + + subgraph tracking [Tracking Layer] + IntentContext[IntentProvider Context] + TrackingHooks[useTrackIntent Hook] + Observers[Intersection/Scroll Observers] + end + + subgraph analytics [Analytics Providers] + Vercel[Vercel Analytics] + Custom[Custom/Future Providers] + end + + components --> IntentContext + IntentContext --> TrackingHooks + TrackingHooks --> Observers + Observers --> Vercel + Observers --> Custom +``` + +## 1. Create Web-Specific Intents + +Create `apps/web/src/lib/intents/` with homepage-specific intents that may later be promoted to the protocol package. + +**File: `apps/web/src/lib/intents/homepage.ts`** + +```typescript +export enum HomepageIntent { + // Navigation + NAV_LOGO_CLICK = "web.homepage.nav.logo.click", + NAV_DOCS_CLICK = "web.homepage.nav.docs.click", + NAV_EXAMPLES_CLICK = "web.homepage.nav.examples.click", + NAV_GITHUB_CLICK = "web.homepage.nav.github.click", + NAV_GET_STARTED_CLICK = "web.homepage.nav.getStarted.click", + + // Hero Section + HERO_VIEW = "web.homepage.hero.view", + HERO_CTA_CLICK = "web.homepage.hero.cta.click", + HERO_CODE_COPY = "web.homepage.hero.code.copy", + + // Immersive Workstream + WORKSTREAM_SECTION_VIEW = "web.homepage.workstream.section.view", + WORKSTREAM_NAV_CLICK = "web.homepage.workstream.nav.click", + WORKSTREAM_CARD_INTERACT = "web.homepage.workstream.card.interact", + + // Features Section + FEATURES_SECTION_VIEW = "web.homepage.features.section.view", + FEATURES_CARD_HOVER = "web.homepage.features.card.hover", + + // Packages Section + PACKAGES_SECTION_VIEW = "web.homepage.packages.section.view", + PACKAGES_CARD_HOVER = "web.homepage.packages.card.hover", + + // Quick Start Section + QUICKSTART_SECTION_VIEW = "web.homepage.quickstart.section.view", + QUICKSTART_CODE_COPY = "web.homepage.quickstart.code.copy", + QUICKSTART_DOCS_CLICK = "web.homepage.quickstart.docs.click", + + // Roadmap Summary + ROADMAP_SECTION_VIEW = "web.homepage.roadmap.section.view", + ROADMAP_FEATURE_REQUEST_CLICK = "web.homepage.roadmap.featureRequest.click", + ROADMAP_LINK_CLICK = "web.homepage.roadmap.link.click", + + // Footer + FOOTER_VIEW = "web.homepage.footer.view", + FOOTER_LINK_CLICK = "web.homepage.footer.link.click", + + // Engagement Metrics + SCROLL_DEPTH_25 = "web.homepage.scroll.depth.25", + SCROLL_DEPTH_50 = "web.homepage.scroll.depth.50", + SCROLL_DEPTH_75 = "web.homepage.scroll.depth.75", + SCROLL_DEPTH_100 = "web.homepage.scroll.depth.100", + TIME_ON_PAGE_30S = "web.homepage.engagement.time.30s", + TIME_ON_PAGE_60S = "web.homepage.engagement.time.60s", + TIME_ON_PAGE_120S = "web.homepage.engagement.time.120s", +} +``` + +## 2. Build Custom Tracking Layer + +**File: `apps/web/src/lib/tracking/`** + +### Core Types (`types.ts`) + +```typescript +export interface TrackingEvent { + intent: string; + timestamp: string; + sessionId: string; + properties?: Record; + engagement?: { + scrollDepth?: number; + timeOnSection?: number; + viewportVisible?: boolean; + }; +} + +export interface TrackingProvider { + name: string; + track: (event: TrackingEvent) => void | Promise; + identify?: (userId: string, traits?: Record) => void; +} +``` + +### Tracking Manager (`manager.ts`) + +- Singleton pattern for managing providers +- Batching events for performance +- Session management +- Provider registration (Vercel, custom, future) + +### Vercel Provider (`providers/vercel.ts`) + +```typescript +import { track } from '@vercel/analytics'; + +export const vercelProvider: TrackingProvider = { + name: 'vercel', + track: (event) => { + track(event.intent, { + ...event.properties, + sessionId: event.sessionId, + engagement: event.engagement, + }); + }, +}; +``` + +### React Integration (`hooks/useTrackIntent.ts`) + +```typescript +export function useTrackIntent() { + const track = (intent: string, properties?: Record) => { ... }; + const trackView = (intent: string, ref: RefObject) => { ... }; + const trackEngagement = (sectionId: string) => { ... }; + return { track, trackView, trackEngagement }; +} +``` + +## 3. Create IntentProvider Context + +**File: `apps/web/src/components/IntentProvider.tsx`** + +- Wraps the app to provide tracking context +- Manages scroll depth tracking +- Handles time-on-page metrics +- Uses Intersection Observer for section views + +## 4. Update Home Page Components + +### Components to Update + +| Component | Intents to Add | Type | + +|-----------|---------------|------| + +| `page.tsx` | Wrap with IntentProvider, scroll depth | Container | + +| Navigation (in page.tsx) | NAV_* intents | Active | + +| Hero Section | HERO_VIEW, HERO_CTA_CLICK, HERO_CODE_COPY | Active + Passive | + +| `ImmersiveWorkstreamShowcase` | WORKSTREAM_* intents | Active + Passive | + +| `FeaturesSection` | FEATURES_* intents | Passive + Hover | + +| `PackagesSection` | PACKAGES_* intents | Passive + Hover | + +| `QuickStartSection` | QUICKSTART_* intents | Active + Passive | + +| `RoadmapSummary` | ROADMAP_* intents | Active + Passive | + +| Footer | FOOTER_* intents | Active + Passive | + +### Pattern for Adding Intents + +```tsx +// Active interaction example + track(HomepageIntent.NAV_DOCS_CLICK)} + data-ac-intent={HomepageIntent.NAV_DOCS_CLICK} +> + Docs + + +// Passive view example (using ref + IntersectionObserver) +
+ +
+``` + +## 5. Install Dependencies + +```bash +yarn workspace web-app add @vercel/analytics +``` + +## Key Files to Create/Modify + +### New Files + +- `apps/web/src/lib/intents/homepage.ts` - Homepage-specific intents +- `apps/web/src/lib/intents/index.ts` - Intent exports +- `apps/web/src/lib/tracking/types.ts` - Tracking types +- `apps/web/src/lib/tracking/manager.ts` - Tracking manager +- `apps/web/src/lib/tracking/providers/vercel.ts` - Vercel provider +- `apps/web/src/lib/tracking/hooks/useTrackIntent.ts` - React hook +- `apps/web/src/lib/tracking/hooks/useScrollDepth.ts` - Scroll tracking +- `apps/web/src/lib/tracking/hooks/useSectionView.ts` - Section view tracking +- `apps/web/src/lib/tracking/index.ts` - Exports +- `apps/web/src/components/IntentProvider.tsx` - Context provider + +### Files to Modify + +- `apps/web/src/app/layout.tsx` - Add Vercel Analytics and IntentProvider +- `apps/web/src/app/page.tsx` - Add intents to nav, hero, footer, wrap sections +- `apps/web/src/components/FeaturesSection.tsx` - Add section view + card hover intents +- `apps/web/src/components/PackagesSection.tsx` - Add section view + card hover intents +- `apps/web/src/components/QuickStartSection.tsx` - Add intents +- `apps/web/src/components/RoadmapSummary.tsx` - Add intents +- `apps/web/src/components/ImmersiveWorkstreamShowcase.tsx` - Re-export with intents +- `apps/web/src/components/immersive-workstream/ImmersiveWorkstreamShowcase.tsx` - Add intents +- `apps/web/src/components/CodePreview.tsx` - Add copy intent tracking \ No newline at end of file diff --git a/apps/web/package.json b/apps/web/package.json index 364f8e2..994da31 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -37,6 +37,7 @@ "@radix-ui/react-slot": "^1.0.2", "@tailwindcss/postcss": "^4.1.13", "@uiw/react-codemirror": "^4.25.3", + "@vercel/analytics": "^1.6.1", "ai": "^4.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 89b83f0..e913961 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,6 +1,8 @@ import { AnyclickProviderWrapper } from "@/components/AnyclickProviderWrapper"; +import { IntentProvider } from "@/components/IntentProvider"; import { cn } from "@/lib/utils"; import type { ReactNode } from "react"; +import { Analytics } from "@vercel/analytics/next"; import type { Metadata, Viewport } from "next"; import { Inter, Lato, Montserrat } from "next/font/google"; import "./globals.css"; @@ -55,7 +57,10 @@ export default function RootLayout({ children }: { children: ReactNode }) { lato.className, )} > - {children} + + {children} + + ); diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 2d35ae8..16ceee4 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -1,218 +1,5 @@ -import { AnyclickLogo } from "@/components/AnyclickLogo"; -import { HeroCodeBlock } from "@/components/CodePreview"; -import FeaturesSection from "@/components/FeaturesSection"; -import { ImmersiveWorkstreamShowcase } from "@/components/ImmersiveWorkstreamShowcase"; -import PackagesSection from "@/components/PackagesSection"; -import QuickStartSection from "@/components/QuickStartSection"; -import RoadmapSummary from "@/components/RoadmapSummary"; -import { ArrowRight, Box, GitBranch, Terminal } from "lucide-react"; -import Link from "next/link"; +import { HomePageClient } from "@/components/HomePageClient"; export default function Home() { - return ( -
- {/* Animated gradient background */} -
-
-
-
-
-
- - {/* Navigation */} - - - {/* Hero Section */} -
-
- {/* Badge */} - {/*
- - Now with screenshot capture & AI agent integration -
*/} - - {/* Headline */} -

- - UX done right - -
- - Why not click on everything? - -

- - {/* Subheadline */} -

- Right-click any element in your app to get the right context, - anyclick will format it for consumers and adapters will - automagically route it to the appropriate system. Issues or AI - agents. -

- - {/* CTA Buttons */} -
- - Get Started - - -
-
- - {/* Code Preview */} - - {children} - - ); -}`} - /> -
- - {/* Immersive Workstream Section */} -
-
-

- Built to extend browser workflows -

-

- Scroll through immersive experiences. Each workflow has its own - visual identity and context menu. Right-click to try it. -

-
- - -
- - {/* Roadmap Summary */} -
-
- -
-
- - {/* Features Section */} -
-
- -
- - {/* Packages Section */} -
- -
- - {/* - Quick Start Section - Hidden on mobile cause you wouldn't be taking the actions of starting on mobile - */} -
-
- -
- - {/* Footer */} -
-
-
- - anyclick -
-
- - Documentation - - - Roadmap - - - Examples - - - GitHub - - - npm - -
-

- MIT License © {new Date().getFullYear()} -

-
-
-
- ); + return ; } diff --git a/apps/web/src/components/CodePreview.tsx b/apps/web/src/components/CodePreview.tsx index f3523fc..6ae955c 100644 --- a/apps/web/src/components/CodePreview.tsx +++ b/apps/web/src/components/CodePreview.tsx @@ -55,6 +55,8 @@ export interface CodePreviewProps extends React.HTMLAttributes { showLineNumbers?: boolean; /** Custom label for terminal variant */ terminalLabel?: string; + /** Callback when code is copied */ + onCopy?: () => void; /** @deprecated Use `code` prop instead. Kept for backward compatibility */ children?: string; } @@ -445,6 +447,7 @@ export const CodePreview = React.forwardRef( showCopy = true, showLineNumbers = false, terminalLabel = "Terminal", + onCopy, className, ...props }, @@ -459,6 +462,8 @@ export const CodePreview = React.forwardRef( await navigator.clipboard.writeText(code); setCopied(true); setTimeout(() => setCopied(false), 2000); + // Call the onCopy callback if provided + onCopy?.(); } catch { // Fallback for older browsers const textArea = document.createElement("textarea"); @@ -469,8 +474,10 @@ export const CodePreview = React.forwardRef( document.body.removeChild(textArea); setCopied(true); setTimeout(() => setCopied(false), 2000); + // Call the onCopy callback if provided + onCopy?.(); } - }, [code]); + }, [code, onCopy]); const tokenizedLines = React.useMemo( () => tokenize(code.trim(), language), @@ -649,12 +656,13 @@ export interface TerminalBlockProps export const TerminalBlock = React.forwardRef< HTMLDivElement, TerminalBlockProps ->(({ label, children, ...props }, ref) => ( +>(({ label, children, onCopy, ...props }, ref) => ( {children} @@ -666,8 +674,8 @@ TerminalBlock.displayName = "TerminalBlock"; export interface CodeBlockProps extends Omit {} export const CodeBlock = React.forwardRef( - ({ children, ...props }, ref) => ( - + ({ children, onCopy, ...props }, ref) => ( + {children} ), @@ -680,8 +688,8 @@ export interface HeroCodeBlockProps extends Omit {} export const HeroCodeBlock = React.forwardRef< HTMLDivElement, HeroCodeBlockProps ->(({ children, ...props }, ref) => ( - +>(({ children, onCopy, ...props }, ref) => ( + {children} )); diff --git a/apps/web/src/components/FeaturesSection.tsx b/apps/web/src/components/FeaturesSection.tsx index 873f675..37007a7 100644 --- a/apps/web/src/components/FeaturesSection.tsx +++ b/apps/web/src/components/FeaturesSection.tsx @@ -1,3 +1,5 @@ +"use client"; + import { Camera, GitBranch, @@ -6,10 +8,153 @@ import { Terminal, Zap, } from "lucide-react"; +import { useRef } from "react"; +import { HomepageIntent } from "@/lib/intents"; +import { useTrackIntent, useSectionViewWithRef } from "@/lib/tracking"; + +type Feature = { + id: string; + title: string; + description: string; + icon: typeof Camera; + color: string; + hoverBorder: string; +}; + +const features: Feature[] = [ + { + id: "context-capture", + title: "Context-Aware Capture", + description: + "Right-click any element to capture its full DOM context, including selectors, data attributes, ancestors, and surrounding page information.", + icon: MousePointerClick, + color: "violet", + hoverBorder: "hover:border-violet-500/30", + }, + { + id: "visual-capture", + title: "Visual Capture", + description: + "Automatically capture screenshots of the target element, its container, and the full page—all included in the feedback payload.", + icon: Camera, + color: "cyan", + hoverBorder: "hover:border-cyan-500/30", + }, + { + id: "code-integration", + title: "Code Source Integration", + description: + "Automatically create GitHub Issues with rich context, formatted markdown, and embedded screenshots for seamless issue tracking.", + icon: GitBranch, + color: "emerald", + hoverBorder: "hover:border-emerald-500/30", + }, + { + id: "ai-agent", + title: "AI Agent", + description: + "Launch Cursor's AI agent directly from feedback—locally during development or via cloud agent for instant code fixes.", + icon: Terminal, + color: "amber", + hoverBorder: "hover:border-amber-500/30", + }, + { + id: "framework-agnostic", + title: "Framework Agnostic", + description: + "Core library works with any JavaScript framework. Use the React provider for React apps, or build your own integration.", + icon: Layers, + color: "rose", + hoverBorder: "hover:border-rose-500/30", + }, + { + id: "zero-config", + title: "Zero Config", + description: + "Works out of the box with sensible defaults. Right-click anywhere in your app and start capturing feedback immediately.", + icon: Zap, + color: "indigo", + hoverBorder: "hover:border-indigo-500/30", + }, +]; + +const colorClasses: Record = { + violet: { + icon: "text-violet-400", + bg: "from-violet-500/20 to-violet-500/5", + }, + cyan: { + icon: "text-cyan-400", + bg: "from-cyan-500/20 to-cyan-500/5", + }, + emerald: { + icon: "text-emerald-400", + bg: "from-emerald-500/20 to-emerald-500/5", + }, + amber: { + icon: "text-amber-400", + bg: "from-amber-500/20 to-amber-500/5", + }, + rose: { + icon: "text-rose-400", + bg: "from-rose-500/20 to-rose-500/5", + }, + indigo: { + icon: "text-indigo-400", + bg: "from-indigo-500/20 to-indigo-500/5", + }, +}; + +function FeatureCard({ feature }: { feature: Feature }) { + const { track } = useTrackIntent(); + const Icon = feature.icon; + const colors = colorClasses[feature.color]; + const hoveredRef = useRef(false); + + const handleMouseEnter = () => { + if (!hoveredRef.current) { + hoveredRef.current = true; + track(HomepageIntent.FEATURES_CARD_HOVER, { + properties: { featureId: feature.id, featureTitle: feature.title }, + }); + } + }; + + return ( +
+
+ +
+

{feature.title}

+

+ {feature.description} +

+
+ ); +} const FeaturesSection = () => { + const sectionRef = useRef(null); + + // Track section view + useSectionViewWithRef(sectionRef, { + intent: HomepageIntent.FEATURES_SECTION_VIEW, + threshold: 0.3, + }); + return ( -
+

Everything you need for UI context @@ -21,80 +166,9 @@ const FeaturesSection = () => {

- {/* Feature 1 */} -
-
- -
-

Context-Aware Capture

-

- Right-click any element to capture its full DOM context, including - selectors, data attributes, ancestors, and surrounding page - information. -

-
- - {/* Feature 2 */} -
-
- -
-

Visual Capture

-

- Automatically capture screenshots of the target element, its - container, and the full page—all included in the feedback payload. -

-
- - {/* Feature 3 */} -
-
- -
-

- Code Source Integration -

-

- Automatically create GitHub Issues with rich context, formatted - markdown, and embedded screenshots for seamless issue tracking. -

-
- - {/* Feature 4 */} -
-
- -
-

AI Agent

-

- Launch Cursor's AI agent directly from feedback—locally during - development or via cloud agent for instant code fixes. -

-
- - {/* Feature 5 */} -
-
- -
-

Framework Agnostic

-

- Core library works with any JavaScript framework. Use the React - provider for React apps, or build your own integration. -

-
- - {/* Feature 6 */} -
-
- -
-

Zero Config

-

- Works out of the box with sensible defaults. Right-click anywhere in - your app and start capturing feedback immediately. -

-
+ {features.map((feature) => ( + + ))}
); diff --git a/apps/web/src/components/HomePageClient.tsx b/apps/web/src/components/HomePageClient.tsx new file mode 100644 index 0000000..b9abaa2 --- /dev/null +++ b/apps/web/src/components/HomePageClient.tsx @@ -0,0 +1,307 @@ +"use client"; + +/** + * HomePageClient - Client-side wrapper for homepage with intent tracking. + * + * @module components/HomePageClient + */ + +import { AnyclickLogo } from "@/components/AnyclickLogo"; +import { HeroCodeBlock } from "@/components/CodePreview"; +import FeaturesSection from "@/components/FeaturesSection"; +import { ImmersiveWorkstreamShowcase } from "@/components/ImmersiveWorkstreamShowcase"; +import PackagesSection from "@/components/PackagesSection"; +import QuickStartSection from "@/components/QuickStartSection"; +import RoadmapSummary from "@/components/RoadmapSummary"; +import { HomepageTracking } from "@/components/IntentProvider"; +import { HomepageIntent } from "@/lib/intents"; +import { useTrackIntent, useSectionView } from "@/lib/tracking"; +import { ArrowRight } from "lucide-react"; +import Link from "next/link"; + +export function HomePageClient() { + const { track } = useTrackIntent(); + + // Section view tracking refs + const heroRef = useSectionView({ + intent: HomepageIntent.HERO_VIEW, + threshold: 0.3, + }); + + const footerRef = useSectionView({ + intent: HomepageIntent.FOOTER_VIEW, + threshold: 0.5, + }); + + return ( +
+ {/* Track scroll depth and time on page */} + + + {/* Animated gradient background */} +
+
+
+
+
+
+ + {/* Navigation */} + + + {/* Hero Section */} +
+
+ {/* Headline */} +

+ + UX done right + +
+ + Why not click on everything? + +

+ + {/* Subheadline */} +

+ Right-click any element in your app to get the right context, + anyclick will format it for consumers and adapters will + automagically route it to the appropriate system. Issues or AI + agents. +

+ + {/* CTA Buttons */} +
+ track(HomepageIntent.HERO_CTA_CLICK)} + data-ac-intent={HomepageIntent.HERO_CTA_CLICK} + > + Get Started + + +
+
+ + {/* Code Preview */} + + {children} + + ); +}`} + onCopy={() => track(HomepageIntent.HERO_CODE_COPY)} + /> +
+ + {/* Immersive Workstream Section */} +
+
+

+ Built to extend browser workflows +

+

+ Scroll through immersive experiences. Each workflow has its own + visual identity and context menu. Right-click to try it. +

+
+ + +
+ + {/* Roadmap Summary */} +
+
+ +
+
+ + {/* Features Section */} +
+
+ +
+ + {/* Packages Section */} +
+ +
+ + {/* Quick Start Section (hidden on mobile) */} +
+
+ +
+ + {/* Footer */} + +
+ ); +} diff --git a/apps/web/src/components/IntentProvider.tsx b/apps/web/src/components/IntentProvider.tsx new file mode 100644 index 0000000..16aed27 --- /dev/null +++ b/apps/web/src/components/IntentProvider.tsx @@ -0,0 +1,186 @@ +"use client"; + +/** + * IntentProvider - Context provider for intent tracking. + * + * Manages tracking initialization, scroll depth, and time-on-page metrics. + * + * @module components/IntentProvider + */ +import { HomepageIntent } from "@/lib/intents"; +import { + type TrackingConfig, + consoleProvider, + getTrackingManager, + useScrollDepth, + useTimeOnPage, + vercelProvider, +} from "@/lib/tracking"; +import { type ReactNode, createContext, useContext, useEffect } from "react"; + +// ============================================================================ +// Context +// ============================================================================ + +interface IntentContextValue { + /** Whether tracking is enabled */ + enabled: boolean; + /** Current session ID */ + sessionId: string; +} + +const IntentContext = createContext(null); + +/** + * Hook to access intent tracking context. + */ +export function useIntentContext(): IntentContextValue { + const context = useContext(IntentContext); + if (!context) { + throw new Error("useIntentContext must be used within an IntentProvider"); + } + return context; +} + +// ============================================================================ +// Provider Props +// ============================================================================ + +interface IntentProviderProps { + children: ReactNode; + /** + * Tracking configuration overrides. + */ + config?: TrackingConfig; + /** + * Whether to enable scroll depth tracking. + * @default true + */ + enableScrollDepth?: boolean; + /** + * Whether to enable time-on-page tracking. + * @default true + */ + enableTimeOnPage?: boolean; + /** + * Custom scroll depth milestones (percentages). + * @default [25, 50, 75, 100] + */ + scrollMilestones?: number[]; + /** + * Custom time-on-page milestones (seconds). + * @default [30, 60, 120] + */ + timeMilestones?: number[]; +} + +// ============================================================================ +// Provider Component +// ============================================================================ + +/** + * IntentProvider - Wraps the app to enable intent tracking. + * + * @example + * ```tsx + * + * + * + * ``` + */ +export function IntentProvider({ + children, + config, + enableScrollDepth = true, + enableTimeOnPage = true, + scrollMilestones = [25, 50, 75, 100], + timeMilestones = [30, 60, 120], +}: IntentProviderProps) { + // Initialize tracking on mount + useEffect(() => { + const manager = getTrackingManager(); + + // Apply config if provided + if (config) { + manager.configure(config); + } + + // Register providers + // In production, use Vercel Analytics + // In development, also add console provider for debugging + manager.registerProvider(vercelProvider); + + if (process.env.NODE_ENV === "development") { + manager.registerProvider(consoleProvider); + } + + // Cleanup on unmount + return () => { + manager.flush(); + }; + }, [config]); + + // Track scroll depth + useScrollDepth( + enableScrollDepth + ? { + milestones: scrollMilestones, + intentPrefix: "web.homepage.scroll.depth", + } + : { milestones: [] }, + ); + + // Track time on page + useTimeOnPage( + enableTimeOnPage + ? { + milestones: timeMilestones, + intentPrefix: "web.homepage.engagement.time", + } + : { milestones: [] }, + ); + + // Get context value + const contextValue: IntentContextValue = { + enabled: config?.enabled !== false, + sessionId: getTrackingManager().getSessionId(), + }; + + return ( + + {children} + + ); +} + +// ============================================================================ +// Homepage Tracking Component +// ============================================================================ + +/** + * HomepageTracking - Enables scroll and time tracking for the homepage. + * + * Use this component on the homepage to track engagement metrics. + * This is a client component that should be rendered within the page. + * + * @example + * ```tsx + * // In page.tsx + * + * ``` + */ +export function HomepageTracking() { + // Track scroll depth with homepage-specific intents + useScrollDepth({ + milestones: [25, 50, 75, 100], + intentPrefix: "web.homepage.scroll.depth", + }); + + // Track time on page with homepage-specific intents + useTimeOnPage({ + milestones: [30, 60, 120], + intentPrefix: "web.homepage.engagement.time", + }); + + return null; +} diff --git a/apps/web/src/components/PackagesSection.tsx b/apps/web/src/components/PackagesSection.tsx index d9d3864..caabc8f 100644 --- a/apps/web/src/components/PackagesSection.tsx +++ b/apps/web/src/components/PackagesSection.tsx @@ -1,3 +1,5 @@ +"use client"; + import { Bot, Box, @@ -11,6 +13,9 @@ import { UploadCloud, Wrench, } from "lucide-react"; +import { useRef } from "react"; +import { HomepageIntent } from "@/lib/intents"; +import { useTrackIntent, useSectionViewWithRef } from "@/lib/tracking"; type PackageItem = { name: string; @@ -78,7 +83,7 @@ const extensionPackages: PackageItem[] = [ description: "Jira adapter for submitting UI feedback directly to Jira issues.", features: ["Jira API", "Issue Creation", "Direct Feedback"], - icon: Bot, // Using Bot as a placeholder for Jira robot/automation feel + icon: Bot, color: "blue", }, { @@ -113,89 +118,104 @@ const extensionPackages: PackageItem[] = [ }, ]; +const getColors = (color: string) => { + switch (color) { + case "violet": + return { + bg: "from-violet-500/10 to-transparent border-violet-500/20", + icon: "text-violet-400", + title: "text-violet-300", + }; + case "cyan": + return { + bg: "from-cyan-500/10 to-transparent border-cyan-500/20", + icon: "text-cyan-400", + title: "text-cyan-300", + }; + case "emerald": + return { + bg: "from-emerald-500/10 to-transparent border-emerald-500/20", + icon: "text-emerald-400", + title: "text-emerald-300", + }; + case "amber": + return { + bg: "from-amber-500/10 to-transparent border-amber-500/20", + icon: "text-amber-400", + title: "text-amber-300", + }; + case "pink": + return { + bg: "from-pink-500/10 to-transparent border-pink-500/20", + icon: "text-pink-400", + title: "text-pink-300", + }; + case "orange": + return { + bg: "from-orange-500/10 to-transparent border-orange-500/20", + icon: "text-orange-400", + title: "text-orange-300", + }; + case "indigo": + return { + bg: "from-indigo-500/10 to-transparent border-indigo-500/20", + icon: "text-indigo-400", + title: "text-indigo-300", + }; + case "blue": + return { + bg: "from-blue-500/10 to-transparent border-blue-500/20", + icon: "text-blue-400", + title: "text-blue-300", + }; + case "teal": + return { + bg: "from-teal-500/10 to-transparent border-teal-500/20", + icon: "text-teal-400", + title: "text-teal-300", + }; + case "red": + return { + bg: "from-red-500/10 to-transparent border-red-500/20", + icon: "text-red-400", + title: "text-red-300", + }; + case "fuchsia": + return { + bg: "from-fuchsia-500/10 to-transparent border-fuchsia-500/20", + icon: "text-fuchsia-400", + title: "text-fuchsia-300", + }; + default: + return { + bg: "from-gray-500/10 to-transparent border-gray-500/20", + icon: "text-gray-400", + title: "text-gray-300", + }; + } +}; + const PackageCard = ({ item }: { item: PackageItem }) => { - // Direct mapping for tailwind classes - const getColors = (color: string) => { - switch (color) { - case "violet": - return { - bg: "from-violet-500/10 to-transparent border-violet-500/20", - icon: "text-violet-400", - title: "text-violet-300", - }; - case "cyan": - return { - bg: "from-cyan-500/10 to-transparent border-cyan-500/20", - icon: "text-cyan-400", - title: "text-cyan-300", - }; - case "emerald": - return { - bg: "from-emerald-500/10 to-transparent border-emerald-500/20", - icon: "text-emerald-400", - title: "text-emerald-300", - }; - case "amber": - return { - bg: "from-amber-500/10 to-transparent border-amber-500/20", - icon: "text-amber-400", - title: "text-amber-300", - }; - case "pink": - return { - bg: "from-pink-500/10 to-transparent border-pink-500/20", - icon: "text-pink-400", - title: "text-pink-300", - }; - case "orange": - return { - bg: "from-orange-500/10 to-transparent border-orange-500/20", - icon: "text-orange-400", - title: "text-orange-300", - }; - case "indigo": - return { - bg: "from-indigo-500/10 to-transparent border-indigo-500/20", - icon: "text-indigo-400", - title: "text-indigo-300", - }; - case "blue": - return { - bg: "from-blue-500/10 to-transparent border-blue-500/20", - icon: "text-blue-400", - title: "text-blue-300", - }; - case "teal": - return { - bg: "from-teal-500/10 to-transparent border-teal-500/20", - icon: "text-teal-400", - title: "text-teal-300", - }; - case "red": - return { - bg: "from-red-500/10 to-transparent border-red-500/20", - icon: "text-red-400", - title: "text-red-300", - }; - case "fuchsia": - return { - bg: "from-fuchsia-500/10 to-transparent border-fuchsia-500/20", - icon: "text-fuchsia-400", - title: "text-fuchsia-300", - }; - default: - return { - bg: "from-gray-500/10 to-transparent border-gray-500/20", - icon: "text-gray-400", - title: "text-gray-300", - }; + const { track } = useTrackIntent(); + const hoveredRef = useRef(false); + const colors = getColors(item.color); + + const handleMouseEnter = () => { + if (!hoveredRef.current) { + hoveredRef.current = true; + track(HomepageIntent.PACKAGES_CARD_HOVER, { + properties: { packageName: item.name }, + }); } }; - const colors = getColors(item.color); - return ( -
+
{item.name} @@ -216,8 +236,20 @@ const PackageCard = ({ item }: { item: PackageItem }) => { }; const PackagesSection = () => { + const sectionRef = useRef(null); + + // Track section view + useSectionViewWithRef(sectionRef, { + intent: HomepageIntent.PACKAGES_SECTION_VIEW, + threshold: 0.3, + }); + return ( -
+

Modular Architecture diff --git a/apps/web/src/components/QuickStartSection.tsx b/apps/web/src/components/QuickStartSection.tsx index 95a1fde..aada3c3 100644 --- a/apps/web/src/components/QuickStartSection.tsx +++ b/apps/web/src/components/QuickStartSection.tsx @@ -1,10 +1,34 @@ +"use client"; + import { CodeBlock, TerminalBlock } from "@/components/CodePreview"; +import { HomepageIntent } from "@/lib/intents"; +import { useTrackIntent, useSectionViewWithRef } from "@/lib/tracking"; import { ArrowRight } from "lucide-react"; import Link from "next/link"; +import { useRef } from "react"; const QuickStartSection = () => { + const { track } = useTrackIntent(); + const sectionRef = useRef(null); + + // Track section view + useSectionViewWithRef(sectionRef, { + intent: HomepageIntent.QUICKSTART_SECTION_VIEW, + threshold: 0.3, + }); + + const handleCodeCopy = (step: string) => { + track(HomepageIntent.QUICKSTART_CODE_COPY, { + properties: { step }, + }); + }; + return ( -
+

Quick Start

Get up and running in under 2 minutes

@@ -21,6 +45,7 @@ const QuickStartSection = () => { handleCodeCopy("install")} />
@@ -48,6 +73,7 @@ export async function POST(req) { const result = await github.submit(payload); return Response.json(result); }`} + onCopy={() => handleCodeCopy("api-route")} />

@@ -74,6 +100,7 @@ export default function Layout({ children }) { ); }`} + onCopy={() => handleCodeCopy("layout")} />
@@ -98,6 +125,8 @@ export default function Layout({ children }) { track(HomepageIntent.QUICKSTART_DOCS_CLICK)} + data-ac-intent={HomepageIntent.QUICKSTART_DOCS_CLICK} > Read the full documentation diff --git a/apps/web/src/components/RoadmapSummary.tsx b/apps/web/src/components/RoadmapSummary.tsx index e32216d..af39252 100644 --- a/apps/web/src/components/RoadmapSummary.tsx +++ b/apps/web/src/components/RoadmapSummary.tsx @@ -1,6 +1,11 @@ +"use client"; + import roadmapRawData from "@/data/roadmap-items.json"; -import { memo } from "react"; -import { ArrowRight, Clock, Link } from "lucide-react"; +import { HomepageIntent } from "@/lib/intents"; +import { useTrackIntent, useSectionViewWithRef } from "@/lib/tracking"; +import { ArrowRight, Clock } from "lucide-react"; +import NextLink from "next/link"; +import { memo, useRef } from "react"; type RoadmapData = { items: RoadmapItem[]; @@ -32,6 +37,15 @@ const getTags = (item: { tags?: unknown }): string[] => { }; const RoadmapSummary = ({ roadmapData }: { roadmapData?: RoadmapData }) => { + const { track } = useTrackIntent(); + const sectionRef = useRef(null); + + // Track section view + useSectionViewWithRef(sectionRef, { + intent: HomepageIntent.ROADMAP_SECTION_VIEW, + threshold: 0.5, + }); + const data = roadmapData ?? (roadmapRawData as RoadmapData); const upcomingItems = data.items.filter( (item) => @@ -45,7 +59,11 @@ const RoadmapSummary = ({ roadmapData }: { roadmapData?: RoadmapData }) => { .join(", "); return ( -
+

@@ -56,9 +74,14 @@ const RoadmapSummary = ({ roadmapData }: { roadmapData?: RoadmapData }) => { {upcomingTitles} {upcomingItems.length > 5 ? ", and more" : ""}. See what's next on our{" "} - + track(HomepageIntent.ROADMAP_LINK_CLICK)} + data-ac-intent={HomepageIntent.ROADMAP_LINK_CLICK} + > roadmap - +

{ target="_blank" rel="noopener noreferrer" className="shrink-0 px-5 py-2.5 rounded-lg bg-amber-500/10 hover:bg-amber-500/20 border border-amber-500/20 text-amber-400 text-sm font-medium transition-colors flex items-center gap-2" + onClick={() => track(HomepageIntent.ROADMAP_FEATURE_REQUEST_CLICK)} + data-ac-intent={HomepageIntent.ROADMAP_FEATURE_REQUEST_CLICK} > Request a feature diff --git a/apps/web/src/components/immersive-workstream/ImmersiveWorkstreamShowcase.tsx b/apps/web/src/components/immersive-workstream/ImmersiveWorkstreamShowcase.tsx index db91474..b2ca448 100644 --- a/apps/web/src/components/immersive-workstream/ImmersiveWorkstreamShowcase.tsx +++ b/apps/web/src/components/immersive-workstream/ImmersiveWorkstreamShowcase.tsx @@ -1,7 +1,9 @@ "use client"; import { cn } from "@/lib/utils"; -import { useEffect, useMemo, useState } from "react"; +import { HomepageIntent } from "@/lib/intents"; +import { useTrackIntent, useSectionViewWithRef } from "@/lib/tracking"; +import { useEffect, useMemo, useRef, useState } from "react"; import { NavigationRail } from "./NavigationRail"; import { WorkstreamSection } from "./sections/WorkstreamSection"; import { getImmersiveThemes } from "./themes"; @@ -11,9 +13,18 @@ export function ImmersiveWorkstreamShowcase({ }: { className?: string; }) { + const { track } = useTrackIntent(); const [activeIndex, setActiveIndex] = useState(0); const [isRailVisible, setIsRailVisible] = useState(false); const immersiveThemes = useMemo(() => getImmersiveThemes(), []); + const containerRef = useRef(null); + const trackedSectionsRef = useRef>(new Set()); + + // Track overall section view + useSectionViewWithRef(containerRef, { + intent: HomepageIntent.WORKSTREAM_SECTION_VIEW, + threshold: 0.1, + }); useEffect(() => { const sectionIds = immersiveThemes.map((t) => t.id); @@ -42,6 +53,20 @@ export function ImmersiveWorkstreamShowcase({ const sectionId = entry.target.id; if (entry.isIntersecting) { intersectingSections.add(sectionId); + + // Track individual workstream section views (once per session) + if (!trackedSectionsRef.current.has(sectionId)) { + trackedSectionsRef.current.add(sectionId); + track(HomepageIntent.WORKSTREAM_CARD_INTERACT, { + properties: { + action: "view", + sectionId, + sectionTitle: + immersiveThemes.find((t) => t.id === sectionId)?.title ?? + sectionId, + }, + }); + } } else { intersectingSections.delete(sectionId); } @@ -80,15 +105,30 @@ export function ImmersiveWorkstreamShowcase({ activeObserver.disconnect(); window.removeEventListener("scroll", handleScroll); }; - }, [immersiveThemes]); + }, [immersiveThemes, track]); const handleNavigate = (index: number) => { - const section = document.getElementById(immersiveThemes[index].id); + const theme = immersiveThemes[index]; + const section = document.getElementById(theme.id); + + // Track navigation click + track(HomepageIntent.WORKSTREAM_NAV_CLICK, { + properties: { + sectionIndex: index, + sectionId: theme.id, + sectionTitle: theme.title, + }, + }); + section?.scrollIntoView({ behavior: "smooth" }); }; return ( -
+
.
.` + * and may be promoted to the protocol package if they prove useful across apps. + * + * @module intents/homepage + */ + +export enum HomepageIntent { + // ============================================================================ + // Navigation + // ============================================================================ + NAV_LOGO_CLICK = "web.homepage.nav.logo.click", + NAV_DOCS_CLICK = "web.homepage.nav.docs.click", + NAV_EXAMPLES_CLICK = "web.homepage.nav.examples.click", + NAV_GITHUB_CLICK = "web.homepage.nav.github.click", + NAV_GET_STARTED_CLICK = "web.homepage.nav.getStarted.click", + + // ============================================================================ + // Hero Section + // ============================================================================ + HERO_VIEW = "web.homepage.hero.view", + HERO_CTA_CLICK = "web.homepage.hero.cta.click", + HERO_CODE_COPY = "web.homepage.hero.code.copy", + + // ============================================================================ + // Immersive Workstream Section + // ============================================================================ + WORKSTREAM_SECTION_VIEW = "web.homepage.workstream.section.view", + WORKSTREAM_NAV_CLICK = "web.homepage.workstream.nav.click", + WORKSTREAM_CARD_INTERACT = "web.homepage.workstream.card.interact", + + // ============================================================================ + // Features Section + // ============================================================================ + FEATURES_SECTION_VIEW = "web.homepage.features.section.view", + FEATURES_CARD_HOVER = "web.homepage.features.card.hover", + + // ============================================================================ + // Packages Section + // ============================================================================ + PACKAGES_SECTION_VIEW = "web.homepage.packages.section.view", + PACKAGES_CARD_HOVER = "web.homepage.packages.card.hover", + + // ============================================================================ + // Quick Start Section + // ============================================================================ + QUICKSTART_SECTION_VIEW = "web.homepage.quickstart.section.view", + QUICKSTART_CODE_COPY = "web.homepage.quickstart.code.copy", + QUICKSTART_DOCS_CLICK = "web.homepage.quickstart.docs.click", + + // ============================================================================ + // Roadmap Summary Section + // ============================================================================ + ROADMAP_SECTION_VIEW = "web.homepage.roadmap.section.view", + ROADMAP_FEATURE_REQUEST_CLICK = "web.homepage.roadmap.featureRequest.click", + ROADMAP_LINK_CLICK = "web.homepage.roadmap.link.click", + + // ============================================================================ + // Footer + // ============================================================================ + FOOTER_VIEW = "web.homepage.footer.view", + FOOTER_LINK_CLICK = "web.homepage.footer.link.click", + + // ============================================================================ + // Engagement Metrics (Scroll Depth) + // ============================================================================ + SCROLL_DEPTH_25 = "web.homepage.scroll.depth.25", + SCROLL_DEPTH_50 = "web.homepage.scroll.depth.50", + SCROLL_DEPTH_75 = "web.homepage.scroll.depth.75", + SCROLL_DEPTH_100 = "web.homepage.scroll.depth.100", + + // ============================================================================ + // Engagement Metrics (Time on Page) + // ============================================================================ + TIME_ON_PAGE_30S = "web.homepage.engagement.time.30s", + TIME_ON_PAGE_60S = "web.homepage.engagement.time.60s", + TIME_ON_PAGE_120S = "web.homepage.engagement.time.120s", +} + +/** + * Type alias for homepage intent values. + */ +export type HomepageIntentType = `${HomepageIntent}`; diff --git a/apps/web/src/lib/intents/index.ts b/apps/web/src/lib/intents/index.ts new file mode 100644 index 0000000..df5513e --- /dev/null +++ b/apps/web/src/lib/intents/index.ts @@ -0,0 +1,7 @@ +/** + * Web app intents - custom intents for the Anyclick marketing site. + * + * @module intents + */ + +export * from "./homepage"; diff --git a/apps/web/src/lib/tracking/hooks/index.ts b/apps/web/src/lib/tracking/hooks/index.ts new file mode 100644 index 0000000..133c4a7 --- /dev/null +++ b/apps/web/src/lib/tracking/hooks/index.ts @@ -0,0 +1,10 @@ +/** + * Tracking hooks for React components. + * + * @module tracking/hooks + */ + +export { useTrackIntent } from "./useTrackIntent"; +export { useScrollDepth } from "./useScrollDepth"; +export { useSectionView, useSectionViewWithRef } from "./useSectionView"; +export { useTimeOnPage } from "./useTimeOnPage"; diff --git a/apps/web/src/lib/tracking/hooks/useScrollDepth.ts b/apps/web/src/lib/tracking/hooks/useScrollDepth.ts new file mode 100644 index 0000000..da0b383 --- /dev/null +++ b/apps/web/src/lib/tracking/hooks/useScrollDepth.ts @@ -0,0 +1,85 @@ +"use client"; + +/** + * useScrollDepth - Track scroll depth milestones. + * + * @module tracking/hooks/useScrollDepth + */ + +import { useEffect, useRef } from "react"; +import { getTrackingManager } from "../manager"; +import type { ScrollDepthConfig } from "../types"; + +const DEFAULT_MILESTONES = [25, 50, 75, 100]; + +/** + * Hook for tracking scroll depth milestones. + * + * @example + * ```tsx + * // In a page component + * useScrollDepth({ + * milestones: [25, 50, 75, 100], + * intentPrefix: 'web.homepage.scroll.depth', + * }); + * ``` + */ +export function useScrollDepth(config: ScrollDepthConfig = {}) { + const { + milestones = DEFAULT_MILESTONES, + intentPrefix = "web.scroll.depth", + } = config; + + // Track which milestones have been reached + const reachedRef = useRef>(new Set()); + + useEffect(() => { + if (typeof window === "undefined") { + return; + } + + const handleScroll = () => { + const scrollHeight = document.documentElement.scrollHeight; + const clientHeight = document.documentElement.clientHeight; + const scrollTop = window.scrollY; + + // Calculate scroll percentage + const scrollableHeight = scrollHeight - clientHeight; + if (scrollableHeight <= 0) { + return; + } + + const scrollPercent = Math.round((scrollTop / scrollableHeight) * 100); + + // Check each milestone + for (const milestone of milestones) { + if (scrollPercent >= milestone && !reachedRef.current.has(milestone)) { + reachedRef.current.add(milestone); + + // Track the milestone + getTrackingManager().track(`${intentPrefix}.${milestone}`, { + engagement: { + scrollDepth: scrollPercent, + }, + }); + } + } + }; + + // Initial check + handleScroll(); + + // Listen for scroll events (throttled via passive listener) + window.addEventListener("scroll", handleScroll, { passive: true }); + + return () => { + window.removeEventListener("scroll", handleScroll); + }; + }, [milestones, intentPrefix]); + + return { + reset: () => { + reachedRef.current.clear(); + }, + }; +} diff --git a/apps/web/src/lib/tracking/hooks/useSectionView.ts b/apps/web/src/lib/tracking/hooks/useSectionView.ts new file mode 100644 index 0000000..3406279 --- /dev/null +++ b/apps/web/src/lib/tracking/hooks/useSectionView.ts @@ -0,0 +1,191 @@ +"use client"; + +/** + * useSectionView - Track when a section becomes visible. + * + * @module tracking/hooks/useSectionView + */ + +import { useEffect, useRef, type RefObject } from "react"; +import { getTrackingManager } from "../manager"; +import type { SectionViewConfig } from "../types"; + +/** + * Hook for tracking when a section becomes visible in the viewport. + * + * @example + * ```tsx + * function FeaturesSection() { + * const sectionRef = useSectionView({ + * intent: HomepageIntent.FEATURES_SECTION_VIEW, + * threshold: 0.5, + * once: true, + * }); + * + * return ( + *
+ * ... + *
+ * ); + * } + * ``` + */ +export function useSectionView( + config: SectionViewConfig +): RefObject { + const { intent, threshold = 0.5, once = true, minVisibleTime = 0 } = config; + + const ref = useRef(null); + const trackedRef = useRef(false); + const visibleTimerRef = useRef | null>(null); + + useEffect(() => { + if (typeof window === "undefined" || !ref.current) { + return; + } + + const element = ref.current; + + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + + if (entry?.isIntersecting) { + // Element is visible + if (trackedRef.current && once) { + return; + } + + if (minVisibleTime > 0) { + // Wait for minimum visible time + visibleTimerRef.current = setTimeout(() => { + if (!trackedRef.current || !once) { + trackedRef.current = true; + getTrackingManager().track(intent, { + engagement: { + viewportVisible: true, + timeOnSection: minVisibleTime, + }, + }); + } + }, minVisibleTime); + } else { + // Track immediately + trackedRef.current = true; + getTrackingManager().track(intent, { + engagement: { + viewportVisible: true, + }, + }); + } + } else { + // Element left viewport - cancel pending timer + if (visibleTimerRef.current) { + clearTimeout(visibleTimerRef.current); + visibleTimerRef.current = null; + } + } + }, + { + threshold, + rootMargin: "0px", + } + ); + + observer.observe(element); + + return () => { + observer.disconnect(); + if (visibleTimerRef.current) { + clearTimeout(visibleTimerRef.current); + } + }; + }, [intent, threshold, once, minVisibleTime]); + + return ref; +} + +/** + * Hook for tracking when an existing ref element becomes visible. + * + * @example + * ```tsx + * function Component() { + * const myRef = useRef(null); + * + * useSectionViewWithRef(myRef, { + * intent: HomepageIntent.FEATURES_SECTION_VIEW, + * }); + * + * return
...
; + * } + * ``` + */ +export function useSectionViewWithRef( + ref: RefObject, + config: SectionViewConfig +): void { + const { intent, threshold = 0.5, once = true, minVisibleTime = 0 } = config; + + const trackedRef = useRef(false); + const visibleTimerRef = useRef | null>(null); + + useEffect(() => { + if (typeof window === "undefined" || !ref.current) { + return; + } + + const element = ref.current; + + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + + if (entry?.isIntersecting) { + if (trackedRef.current && once) { + return; + } + + if (minVisibleTime > 0) { + visibleTimerRef.current = setTimeout(() => { + if (!trackedRef.current || !once) { + trackedRef.current = true; + getTrackingManager().track(intent, { + engagement: { + viewportVisible: true, + timeOnSection: minVisibleTime, + }, + }); + } + }, minVisibleTime); + } else { + trackedRef.current = true; + getTrackingManager().track(intent, { + engagement: { + viewportVisible: true, + }, + }); + } + } else { + if (visibleTimerRef.current) { + clearTimeout(visibleTimerRef.current); + visibleTimerRef.current = null; + } + } + }, + { + threshold, + rootMargin: "0px", + } + ); + + observer.observe(element); + + return () => { + observer.disconnect(); + if (visibleTimerRef.current) { + clearTimeout(visibleTimerRef.current); + } + }; + }, [ref, intent, threshold, once, minVisibleTime]); +} diff --git a/apps/web/src/lib/tracking/hooks/useTimeOnPage.ts b/apps/web/src/lib/tracking/hooks/useTimeOnPage.ts new file mode 100644 index 0000000..352caa4 --- /dev/null +++ b/apps/web/src/lib/tracking/hooks/useTimeOnPage.ts @@ -0,0 +1,85 @@ +"use client"; + +/** + * useTimeOnPage - Track time-on-page milestones. + * + * @module tracking/hooks/useTimeOnPage + */ + +import { useEffect, useRef } from "react"; +import { getTrackingManager } from "../manager"; +import type { TimeOnPageConfig } from "../types"; + +const DEFAULT_MILESTONES = [30, 60, 120]; // seconds + +/** + * Hook for tracking time-on-page milestones. + * + * @example + * ```tsx + * // In a page component + * useTimeOnPage({ + * milestones: [30, 60, 120], // seconds + * intentPrefix: 'web.homepage.engagement.time', + * }); + * ``` + */ +export function useTimeOnPage(config: TimeOnPageConfig = {}) { + const { + milestones = DEFAULT_MILESTONES, + intentPrefix = "web.engagement.time", + } = config; + + const reachedRef = useRef>(new Set()); + const startTimeRef = useRef(Date.now()); + const intervalRef = useRef | null>(null); + + useEffect(() => { + if (typeof window === "undefined") { + return; + } + + startTimeRef.current = Date.now(); + + // Check milestones every second + intervalRef.current = setInterval(() => { + const elapsedSeconds = Math.floor( + (Date.now() - startTimeRef.current) / 1000 + ); + + for (const milestone of milestones) { + if (elapsedSeconds >= milestone && !reachedRef.current.has(milestone)) { + reachedRef.current.add(milestone); + + // Track the milestone + getTrackingManager().track(`${intentPrefix}.${milestone}s`, { + engagement: { + timeOnPage: elapsedSeconds * 1000, // Convert to ms + }, + }); + } + } + + // Stop checking if all milestones reached + const maxMilestone = Math.max(...milestones); + if (elapsedSeconds > maxMilestone && intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }, 1000); + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + }; + }, [milestones, intentPrefix]); + + return { + reset: () => { + reachedRef.current.clear(); + startTimeRef.current = Date.now(); + }, + getElapsedTime: () => Date.now() - startTimeRef.current, + }; +} diff --git a/apps/web/src/lib/tracking/hooks/useTrackIntent.ts b/apps/web/src/lib/tracking/hooks/useTrackIntent.ts new file mode 100644 index 0000000..25a2e3a --- /dev/null +++ b/apps/web/src/lib/tracking/hooks/useTrackIntent.ts @@ -0,0 +1,73 @@ +"use client"; + +/** + * useTrackIntent - React hook for tracking intents. + * + * @module tracking/hooks/useTrackIntent + */ + +import { useCallback } from "react"; +import { getTrackingManager } from "../manager"; +import type { TrackOptions } from "../types"; + +/** + * Hook for tracking intents with the tracking manager. + * + * @example + * ```tsx + * const { track } = useTrackIntent(); + * + * // Track a click + * + * + * // Track with properties + * track(HomepageIntent.NAV_LINK_CLICK, { properties: { linkTarget: '/docs' } }); + * ``` + */ +export function useTrackIntent() { + /** + * Track an intent event. + */ + const track = useCallback((intent: string, options?: TrackOptions) => { + getTrackingManager().track(intent, options); + }, []); + + /** + * Track a click event on a specific intent. + * Returns an onClick handler. + */ + const trackClick = useCallback( + (intent: string, properties?: Record) => { + return () => { + getTrackingManager().track(intent, { properties }); + }; + }, + [] + ); + + /** + * Track a hover event on a specific intent. + * Returns onMouseEnter handler. + */ + const trackHover = useCallback( + (intent: string, properties?: Record) => { + let tracked = false; + return () => { + // Only track once per mount + if (!tracked) { + tracked = true; + getTrackingManager().track(intent, { properties }); + } + }; + }, + [] + ); + + return { + track, + trackClick, + trackHover, + }; +} diff --git a/apps/web/src/lib/tracking/index.ts b/apps/web/src/lib/tracking/index.ts new file mode 100644 index 0000000..5b1b2f9 --- /dev/null +++ b/apps/web/src/lib/tracking/index.ts @@ -0,0 +1,59 @@ +/** + * Anyclick Web Tracking Layer + * + * Custom tracking infrastructure for intent-based analytics. + * Supports multiple providers (Vercel Analytics, custom, etc.) + * + * @example + * ```tsx + * import { IntentProvider } from '@/components/IntentProvider'; + * import { useTrackIntent, HomepageIntent } from '@/lib/tracking'; + * + * // In layout + * + * + * + * + * // In components + * const { track } = useTrackIntent(); + * track(HomepageIntent.HERO_CTA_CLICK); + * ``` + * + * @module tracking + */ + +// Types +export type { + TrackingEvent, + TrackingProvider, + TrackingConfig, + TrackOptions, + EngagementData, + SectionViewConfig, + ScrollDepthConfig, + TimeOnPageConfig, +} from "./types"; + +// Manager +export { + getTrackingManager, + trackEvent, + identifyUser, +} from "./manager"; + +// Providers +export { + createVercelProvider, + vercelProvider, + createConsoleProvider, + consoleProvider, +} from "./providers"; + +// Hooks +export { + useTrackIntent, + useScrollDepth, + useSectionView, + useSectionViewWithRef, + useTimeOnPage, +} from "./hooks"; diff --git a/apps/web/src/lib/tracking/manager.ts b/apps/web/src/lib/tracking/manager.ts new file mode 100644 index 0000000..20ece86 --- /dev/null +++ b/apps/web/src/lib/tracking/manager.ts @@ -0,0 +1,282 @@ +/** + * Tracking Manager - Singleton for managing analytics providers. + * + * Handles event batching, session management, and provider registration. + * + * @module tracking/manager + */ + +import type { + TrackingConfig, + TrackingEvent, + TrackingProvider, + TrackOptions, +} from "./types"; + +const DEFAULT_CONFIG: Required = { + enabled: true, + debug: process.env.NODE_ENV === "development", + batching: false, + batchInterval: 5000, + maxBatchSize: 10, +}; + +/** + * Generate a unique session ID. + */ +function generateSessionId(): string { + if (typeof crypto !== "undefined" && crypto.randomUUID) { + return crypto.randomUUID(); + } + // Fallback for older browsers + return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; +} + +/** + * Get or create a session ID from sessionStorage. + */ +function getSessionId(): string { + if (typeof window === "undefined") { + return "server"; + } + + const STORAGE_KEY = "ac_session_id"; + let sessionId = sessionStorage.getItem(STORAGE_KEY); + + if (!sessionId) { + sessionId = generateSessionId(); + sessionStorage.setItem(STORAGE_KEY, sessionId); + } + + return sessionId; +} + +/** + * Tracking Manager class - manages providers and event dispatch. + */ +class TrackingManager { + private static instance: TrackingManager | null = null; + + private providers: Map = new Map(); + private config: Required = DEFAULT_CONFIG; + private eventQueue: TrackingEvent[] = []; + private batchTimer: ReturnType | null = null; + private sessionId: string; + + private constructor() { + this.sessionId = getSessionId(); + + // Setup batch timer if batching is enabled + if (typeof window !== "undefined") { + this.setupBatchTimer(); + } + } + + /** + * Get the singleton instance. + */ + static getInstance(): TrackingManager { + if (!TrackingManager.instance) { + TrackingManager.instance = new TrackingManager(); + } + return TrackingManager.instance; + } + + /** + * Configure the tracking manager. + */ + configure(config: TrackingConfig): void { + this.config = { ...DEFAULT_CONFIG, ...config }; + + // Reset batch timer with new config + this.setupBatchTimer(); + } + + /** + * Register an analytics provider. + */ + registerProvider(provider: TrackingProvider): void { + if (this.providers.has(provider.name)) { + this.log(`Provider "${provider.name}" already registered, replacing...`); + } + this.providers.set(provider.name, provider); + this.log(`Provider "${provider.name}" registered`); + } + + /** + * Unregister an analytics provider. + */ + unregisterProvider(name: string): void { + if (this.providers.has(name)) { + this.providers.delete(name); + this.log(`Provider "${name}" unregistered`); + } + } + + /** + * Track an event with all registered providers. + */ + track(intent: string, options: TrackOptions = {}): void { + if (!this.config.enabled) { + return; + } + + const event: TrackingEvent = { + intent, + timestamp: options.timestamp ?? new Date().toISOString(), + sessionId: this.sessionId, + properties: options.properties, + engagement: options.engagement, + }; + + this.log("Track event:", event); + + if (this.config.batching) { + this.queueEvent(event); + } else { + this.dispatchEvent(event); + } + } + + /** + * Identify a user across all providers. + */ + identify(userId: string, traits?: Record): void { + if (!this.config.enabled) { + return; + } + + this.log("Identify user:", userId, traits); + + const providers = Array.from(this.providers.values()); + for (const provider of providers) { + if (provider.identify) { + try { + provider.identify(userId, traits); + } catch (error) { + console.error( + `[Tracking] Error identifying with provider "${provider.name}":`, + error + ); + } + } + } + } + + /** + * Flush all queued events immediately. + */ + async flush(): Promise { + if (this.eventQueue.length === 0) { + return; + } + + this.log(`Flushing ${this.eventQueue.length} queued events`); + + const events = [...this.eventQueue]; + this.eventQueue = []; + + for (const event of events) { + await this.dispatchEvent(event); + } + + // Also flush individual providers + const allProviders = Array.from(this.providers.values()); + for (const provider of allProviders) { + if (provider.flush) { + try { + await provider.flush(); + } catch (error) { + console.error( + `[Tracking] Error flushing provider "${provider.name}":`, + error + ); + } + } + } + } + + /** + * Get the current session ID. + */ + getSessionId(): string { + return this.sessionId; + } + + /** + * Get registered provider names. + */ + getProviders(): string[] { + return Array.from(this.providers.keys()); + } + + // ============================================================================ + // Private Methods + // ============================================================================ + + private setupBatchTimer(): void { + if (this.batchTimer) { + clearInterval(this.batchTimer); + this.batchTimer = null; + } + + if (this.config.batching && typeof window !== "undefined") { + this.batchTimer = setInterval(() => { + this.flush(); + }, this.config.batchInterval); + } + } + + private queueEvent(event: TrackingEvent): void { + this.eventQueue.push(event); + + // Force flush if queue is full + if (this.eventQueue.length >= this.config.maxBatchSize) { + this.flush(); + } + } + + private async dispatchEvent(event: TrackingEvent): Promise { + const providers = Array.from(this.providers.values()); + for (const provider of providers) { + try { + await provider.track(event); + } catch (error) { + console.error( + `[Tracking] Error dispatching to provider "${provider.name}":`, + error + ); + } + } + } + + private log(...args: unknown[]): void { + if (this.config.debug) { + console.log("[Tracking]", ...args); + } + } +} + +/** + * Get the tracking manager singleton. + */ +export function getTrackingManager(): TrackingManager { + return TrackingManager.getInstance(); +} + +/** + * Convenience function to track an event. + */ +export function trackEvent(intent: string, options?: TrackOptions): void { + getTrackingManager().track(intent, options); +} + +/** + * Convenience function to identify a user. + */ +export function identifyUser( + userId: string, + traits?: Record +): void { + getTrackingManager().identify(userId, traits); +} diff --git a/apps/web/src/lib/tracking/providers/console.ts b/apps/web/src/lib/tracking/providers/console.ts new file mode 100644 index 0000000..c4c7416 --- /dev/null +++ b/apps/web/src/lib/tracking/providers/console.ts @@ -0,0 +1,46 @@ +/** + * Console Provider - For development/debugging + * + * Logs all tracking events to the console. + * + * @module tracking/providers/console + */ + +import type { TrackingEvent, TrackingProvider } from "../types"; + +/** + * Create a console logging provider for development. + */ +export function createConsoleProvider(): TrackingProvider { + return { + name: "console", + + track: (event: TrackingEvent) => { + console.log( + "%c[Intent Track]", + "color: #8b5cf6; font-weight: bold;", + event.intent, + { + sessionId: event.sessionId, + timestamp: event.timestamp, + properties: event.properties, + engagement: event.engagement, + } + ); + }, + + identify: (userId: string, traits?: Record) => { + console.log( + "%c[Intent Identify]", + "color: #06b6d4; font-weight: bold;", + userId, + traits + ); + }, + }; +} + +/** + * Default console provider instance. + */ +export const consoleProvider = createConsoleProvider(); diff --git a/apps/web/src/lib/tracking/providers/index.ts b/apps/web/src/lib/tracking/providers/index.ts new file mode 100644 index 0000000..d5a0ab1 --- /dev/null +++ b/apps/web/src/lib/tracking/providers/index.ts @@ -0,0 +1,8 @@ +/** + * Analytics providers for the tracking layer. + * + * @module tracking/providers + */ + +export { createVercelProvider, vercelProvider } from "./vercel"; +export { createConsoleProvider, consoleProvider } from "./console"; diff --git a/apps/web/src/lib/tracking/providers/vercel.ts b/apps/web/src/lib/tracking/providers/vercel.ts new file mode 100644 index 0000000..dfe4b2f --- /dev/null +++ b/apps/web/src/lib/tracking/providers/vercel.ts @@ -0,0 +1,63 @@ +/** + * Vercel Analytics Provider + * + * Adapter for sending tracking events to Vercel Analytics. + * + * @module tracking/providers/vercel + */ +import { track } from "@vercel/analytics"; +import type { TrackingEvent, TrackingProvider } from "../types"; + +// vercel/analytics type AllowedPropertyValues, not currently exported +// https://github.com/vercel/analytics/blob/2275e2743d2827b786b4e47c4ab337dae1d2d75b/packages/web/src/types.ts#L13 +type AllowedPropertyValues = string | number | boolean | null | undefined; +/** + * Create a Vercel Analytics tracking provider. + */ +export function createVercelProvider(): TrackingProvider { + return { + name: "vercel", + + track: (event: TrackingEvent) => { + // Vercel Analytics has limits on property depth/size + // Flatten engagement data into the properties object + const properties: Record = { + ...event.properties, + sessionId: event.sessionId, + timestamp: event.timestamp, + }; + + if (event.engagement) { + if (event.engagement.scrollDepth !== undefined) { + properties.scrollDepth = event.engagement.scrollDepth; + } + if (event.engagement.timeOnPage !== undefined) { + properties.timeOnPage = event.engagement.timeOnPage; + } + if (event.engagement.timeOnSection !== undefined) { + properties.timeOnSection = event.engagement.timeOnSection; + } + if (event.engagement.viewportVisible !== undefined) { + properties.viewportVisible = event.engagement.viewportVisible; + } + } + + // Use intent as the event name for Vercel Analytics + track(event.intent, properties); + }, + + // Vercel Analytics doesn't have a built-in identify method + // but we can track an identify event + identify: (userId: string, traits?: Record) => { + track("user.identify", { + userId, + ...traits, + }); + }, + }; +} + +/** + * Default Vercel provider instance. + */ +export const vercelProvider = createVercelProvider(); diff --git a/apps/web/src/lib/tracking/types.ts b/apps/web/src/lib/tracking/types.ts new file mode 100644 index 0000000..d2be4c0 --- /dev/null +++ b/apps/web/src/lib/tracking/types.ts @@ -0,0 +1,111 @@ +/** + * Type definitions for the custom tracking layer. + * + * @module tracking/types + */ + +/** + * Engagement data captured with tracking events. + */ +export interface EngagementData { + /** Current scroll depth as a percentage (0-100) */ + scrollDepth?: number; + /** Time spent on section in milliseconds */ + timeOnSection?: number; + /** Whether the element is currently visible in the viewport */ + viewportVisible?: boolean; + /** Time spent on the page in milliseconds */ + timeOnPage?: number; +} + +/** + * A tracking event to be sent to analytics providers. + */ +export interface TrackingEvent { + /** The intent identifier (e.g., "web.homepage.nav.docs.click") */ + intent: string; + /** ISO timestamp when the event occurred */ + timestamp: string; + /** Unique session identifier */ + sessionId: string; + /** Additional properties for the event */ + properties?: Record; + /** Engagement metrics at the time of the event */ + engagement?: EngagementData; +} + +/** + * Interface for analytics providers (Vercel, custom, etc.). + */ +export interface TrackingProvider { + /** Unique name for the provider */ + name: string; + /** Send a tracking event */ + track: (event: TrackingEvent) => void | Promise; + /** Optional: Identify a user */ + identify?: (userId: string, traits?: Record) => void; + /** Optional: Cleanup/flush events on unmount */ + flush?: () => void | Promise; +} + +/** + * Configuration for the tracking manager. + */ +export interface TrackingConfig { + /** Whether tracking is enabled (default: true) */ + enabled?: boolean; + /** Enable debug logging to console (default: false in production) */ + debug?: boolean; + /** Batch events and send periodically (default: false) */ + batching?: boolean; + /** Batch interval in milliseconds (default: 5000) */ + batchInterval?: number; + /** Maximum batch size before forcing flush (default: 10) */ + maxBatchSize?: number; +} + +/** + * Options for tracking a specific event. + */ +export interface TrackOptions { + /** Additional properties to include with the event */ + properties?: Record; + /** Engagement data to include */ + engagement?: EngagementData; + /** Override the timestamp (defaults to now) */ + timestamp?: string; +} + +/** + * Section view tracking configuration. + */ +export interface SectionViewConfig { + /** The intent to fire when section becomes visible */ + intent: string; + /** Intersection threshold (0-1, default: 0.5) */ + threshold?: number; + /** Only fire once per session (default: true) */ + once?: boolean; + /** Minimum time visible before firing (ms, default: 0) */ + minVisibleTime?: number; +} + +/** + * Scroll depth milestone configuration. + */ +export interface ScrollDepthConfig { + /** Milestones to track (default: [25, 50, 75, 100]) */ + milestones?: number[]; + /** Intent prefix for scroll events */ + intentPrefix?: string; +} + +/** + * Time on page milestone configuration. + */ +export interface TimeOnPageConfig { + /** Time milestones in seconds to track (default: [30, 60, 120]) */ + milestones?: number[]; + /** Intent prefix for time events */ + intentPrefix?: string; +} diff --git a/yarn.lock b/yarn.lock index eed61f4..68f5101 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2402,6 +2402,11 @@ effect "3.17.7" sqids "^0.3.0" +"@vercel/analytics@^1.6.1": + version "1.6.1" + resolved "https://registry.yarnpkg.com/@vercel/analytics/-/analytics-1.6.1.tgz#b4e16daf445cf6cd365a91e43d86e9e5b3428ddc" + integrity sha512-oH9He/bEM+6oKlv3chWuOOcp8Y6fo6/PSro8hEkgCW3pu9/OiCXiUpRUogDh3Fs3LH2sosDrx8CxeOLBEE+afg== + "@vitejs/plugin-react@^4.3.0": version "4.7.0" resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz#647af4e7bb75ad3add578e762ad984b90f4a24b9"