diff --git a/.cursor/plans/ac_anyclick_integration.plan.md b/.cursor/plans/ac_anyclick_integration.plan.md new file mode 100644 index 0000000..c3a75a6 --- /dev/null +++ b/.cursor/plans/ac_anyclick_integration.plan.md @@ -0,0 +1,313 @@ +# Ac.Context + AnyclickProvider Integration + +## Overview + +Integrate `Ac.Context` with `AnyclickProvider` so that intent actions appear in the right-click context menu. Uses Zustand to maintain a dynamic registry of intents/actions that the context menu reads from. + +## Architecture + +```mermaid +graph TD + subgraph web_app [apps/web] + AcContext["Ac.Context"] + AcIntent["Ac.Intent"] + AcView["Ac.View"] + IntentStore["useIntentStore (Zustand)"] + end + + subgraph anyclick_react [anyclick-react] + Provider["AnyclickProvider"] + ContextMenu["ContextMenu"] + ProviderStore["useProviderStore"] + end + + AcContext -->|registers actions| IntentStore + AcIntent -->|auto-registers| IntentStore + AcView -->|auto-registers| IntentStore + + ContextMenu -->|reads actions| IntentStore + Provider -->|manages| ContextMenu + Provider -->|uses| ProviderStore +``` + +--- + +## Data Model + +### Action Definition + +```ts +interface AcAction { + /** Unique intent identifier */ + intent: string; + /** Display label in context menu */ + label: string; + /** Optional icon */ + icon?: ReactNode; + /** Priority for sorting (1-10, where 10 = highest priority, default: 5) */ + priority?: number; + /** Show comment input when selected */ + showComment?: boolean; + /** Custom handler (return false to skip default submission) */ + handler?: (context: AcActionContext) => boolean | Promise | void; + /** Action status (available, comingSoon) */ + status?: "available" | "comingSoon"; + /** Required roles to see this action */ + requiredRoles?: string[]; +} + +interface AcActionContext { + intent: string; + metadata: Record; + targetElement: Element | null; + containerElement: Element | null; + closeMenu: () => void; + /** Fire intent tracking event */ + track: () => void; + /** Trigger screenshot capture */ + captureScreenshots: () => Promise; + /** Submit to anyclick adapter */ + submit: (comment?: string) => Promise; +} +``` + +### Intent Registration + +```ts +interface RegisteredIntent { + /** The intent identifier */ + intent: string; + /** Context ID this intent belongs to (for scoping) */ + contextId: string; + /** Metadata from Ac.Context + Ac.Intent merged */ + metadata: Record; + /** DOM element ref for scoping */ + elementRef: RefObject; + /** Whether this intent opts out of context menu (context={false}) */ + optOut: boolean; + /** Event type (click, hover, view) */ + eventType: "click" | "hover" | "view"; +} +``` + +--- + +## Store Design + +### `useIntentStore` (Zustand) + +```ts +interface IntentStore { + // === Registry === + /** Map of contextId -> registered context */ + contexts: Map; + /** Map of intentId -> registered intent */ + intents: Map; + + // === Actions === + registerContext: (context: RegisteredContext) => void; + unregisterContext: (contextId: string) => void; + updateContext: (contextId: string, updates: Partial) => void; + + registerIntent: (intent: RegisteredIntent) => void; + unregisterIntent: (intentId: string) => void; + + // === Queries === + /** Get all actions for an element (walks up context tree) */ + getActionsForElement: (element: Element) => AcAction[]; + /** Get merged metadata for an element */ + getMetadataForElement: (element: Element) => Record; + /** Find nearest context for an element */ + findContextForElement: (element: Element) => RegisteredContext | null; +} + +interface RegisteredContext { + id: string; + name?: string; + metadata: Record; + actions: AcAction[]; + elementRef: RefObject; + parentId: string | null; + depth: number; +} +``` + +--- + +## Component Changes + +### 1. `Ac.Context` - Enhanced + +```tsx +interface AcContextProps { + metadata: Record; + name?: string; + /** Explicit actions for this context's menu */ + actions?: AcAction[]; + /** Disable anyclick in this context */ + disabled?: boolean; + asChild?: boolean; + children: ReactNode; +} +``` + +On mount: +1. Generate unique context ID +2. Find parent context (if any) via store +3. Register with `useIntentStore` +4. Merge parent metadata + actions + +On unmount: +1. Unregister from store +2. Cleanup child intent registrations + +### 2. `Ac.Intent` - Enhanced + +```tsx +interface AcIntentProps { + intent: string; + on?: "click" | "hover" | "both"; + metadata?: Record; + /** Opt out of context menu registration */ + context?: boolean; // default: true + /** Action config when shown in context menu */ + action?: Partial; + disabled?: boolean; + children: React.ReactElement; +} +``` + +On mount (if `context !== false`): +1. Find parent Ac.Context +2. Register intent with store (contextId, metadata, elementRef) + +### 3. `Ac.View` - Enhanced + +Same as `Ac.Intent` but for view tracking. + +--- + +## Integration with AnyclickProvider + +### Option A: Bridge Component (Recommended for apps/web) + +Create `` that reads from `useIntentStore` and injects actions into the nearest `AnyclickProvider`: + +```tsx +function AcMenuBridge() { + const { getActionsForElement } = useIntentStore(); + const anyclick = useAnyclick(); + + // Hook into context menu open event + // Merge Ac actions with provider's menuItems + // ... +} +``` + +### Option B: Custom Hook + +```tsx +function useAcContextMenu(element: Element | null) { + const store = useIntentStore(); + + return useMemo(() => { + if (!element) return []; + return store.getActionsForElement(element); + }, [element, store]); +} +``` + +--- + +## Helper Functions + +### Action Wrappers + +```ts +/** Creates an action that fires tracking + submits to adapter */ +function createTrackAndSubmitAction( + intent: string, + options?: { showComment?: boolean; label?: string } +): AcAction; + +/** Creates an action that only fires tracking */ +function createTrackOnlyAction( + intent: string, + options?: { label?: string } +): AcAction; + +/** Creates an action that captures screenshots before submitting */ +function createScreenshotAction( + intent: string, + options?: { showComment?: boolean; label?: string } +): AcAction; +``` + +### Usage Example + +```tsx + + + + + +``` + +--- + +## File Structure + +``` +apps/web/src/components/tracking/ + - index.ts # Exports Ac namespace + helpers + - context.ts # React context (existing) + - store.ts # NEW: Zustand intent store + - AcContext.tsx # Enhanced with registration + - AcIntent.tsx # Enhanced with registration + - AcView.tsx # Enhanced with registration + - AcMenuBridge.tsx # NEW: Bridge to AnyclickProvider + - helpers.ts # NEW: Action wrapper helpers + - types.ts # NEW: Shared types +``` + +--- + +## Implementation Order + +1. **Create types.ts** - Define AcAction, RegisteredIntent, RegisteredContext +2. **Create store.ts** - Zustand store for intent/context registry +3. **Update Ac.Context** - Add registration, actions prop, parent detection +4. **Update Ac.Intent** - Add auto-registration, context prop, action prop +5. **Update Ac.View** - Same as Ac.Intent +6. **Create helpers.ts** - Action wrapper functions +7. **Create AcMenuBridge.tsx** - Integration with AnyclickProvider +8. **Update index.ts** - Export new components and helpers +9. **Update HomePageClient.tsx** - Add AcMenuBridge +10. **Test end-to-end** - Verify context menu shows Ac actions + +--- + +## Decisions (Confirmed) + +1. **Action ordering**: Actions have a `priority` value (1-10, where 10 = highest priority). Actions are sorted by priority descending in the menu. + +2. **`context={false}` behavior**: Events still pass through the system, but all consumers (logging, tracking, menu actions) ignore intents with `context={false}`. This preserves flexibility for future use cases. + +3. **Implementation scope**: Keep in `apps/web` until the pattern is fully proven, then promote to `anyclick-react` package. diff --git a/.cursor/plans/ac_intent_components_0289acf0.plan.md b/.cursor/plans/ac_intent_components_0289acf0.plan.md new file mode 100644 index 0000000..d0b7f6e --- /dev/null +++ b/.cursor/plans/ac_intent_components_0289acf0.plan.md @@ -0,0 +1,259 @@ +--- +name: Ac Intent Components +overview: Create React component abstractions (`Ac.Intent`, `Ac.Context`, `Ac.View`) to simplify intent tracking declaratively, replacing manual `onClick/onMouseEnter` handlers and `data-ac-*` attributes. +todos: + - id: ac-context-setup + content: Create `context.ts` with React context for Ac.Context data propagation + status: completed + - id: ac-intent-component + content: Create `AcIntent.tsx` with click/hover tracking using Slot pattern + status: completed + - id: ac-context-component + content: Create `AcContext.tsx` provider component + status: completed + - id: ac-view-component + content: Create `AcView.tsx` with IntersectionObserver-based view tracking + status: completed + - id: ac-namespace-export + content: Create `index.ts` exporting Ac namespace object + status: completed + - id: refactor-homepage + content: Refactor HomePageClient.tsx to use Ac components + status: completed + - id: refactor-features + content: Refactor FeaturesSection.tsx to use Ac components + status: completed + - id: refactor-packages + content: Refactor PackagesSection.tsx to use Ac components + status: completed +--- + +# Ac Intent Component Abstractions + +## Problem + +The current implementation requires repetitive boilerplate for intent tracking: + +```tsx +// Current pattern - verbose and error-prone + track(HomepageIntent.NAV_DOCS_CLICK)} + data-ac-intent={HomepageIntent.NAV_DOCS_CLICK} + className="pointer-none" +> + Docs + +``` + +Each tracked element needs: + +1. Manual `onClick`/`onMouseEnter` handler calling `track()` +2. Manual `data-ac-intent` attribute +3. Manual `data-ac-*` properties for context + +## Solution + +Create an `Ac` namespace with declarative components in [apps/web/src/components/tracking/](apps/web/src/components/tracking/): + +```mermaid +graph TD + subgraph components [Ac Namespace Components] + AcContext["Ac.Context"] + AcIntent["Ac.Intent"] + AcView["Ac.View"] + end + + AcContext -->|provides data| AcIntent + AcContext -->|provides data| AcView + AcIntent -->|uses| TrackingManager + AcView -->|uses| TrackingManager + AcView -->|uses| IntersectionObserver +``` + +--- + +## Components to Create + +### 1. `Ac.Intent` - Click/Hover Tracking + +Wraps interactive elements to automatically track clicks and hovers. + +**Target usage (from TODOs):** + +```tsx +// Wrapper pattern + + Docs + + +// With additional data + + Documentation + + +// Hover tracking (once per mount) + + + +``` + +**Props:** + +```ts +interface AcIntentProps { + intent: string; // The intent identifier + on?: "click" | "hover" | "both"; // Event type (default: "click") + data?: Record; // Additional properties + children: React.ReactElement; // Single child (uses Slot/asChild pattern) + disabled?: boolean; // Disable tracking +} +``` + +**Implementation approach:** + +- Use `@radix-ui/react-slot` for `asChild` pattern to merge props onto child +- Attach `onClick`/`onMouseEnter` handlers that call `track()` +- Set `data-ac-intent` and `data-ac-*` attributes for each key in `data` +- For `hover`, track only once per component mount + +--- + +### 2. `Ac.Context` - Hierarchical Context Provider + +Provides context data to nested `Ac.Intent` and `Ac.View` components. + +**Target usage (from TODOs):** + +```tsx + + {/* All nested intents inherit section="packages" in their data */} + + + + +``` + +**Props:** + +```ts +interface AcContextProps { + data: Record; // Context data merged into child intents + name?: string; // Sets data-ac-context attribute + children: React.ReactNode; +} +``` + +**Implementation approach:** + +- Use React Context to provide data that flows to nested components +- Nested contexts merge data (inner overrides outer) +- Set `data-ac-context` attribute on wrapper div + +--- + +### 3. `Ac.View` - Visibility/Section View Tracking + +Tracks when an element enters the viewport. + +**Target usage:** + +```tsx + +
...
+
+``` + +**Props:** + +```ts +interface AcViewProps { + intent: string; // Intent to fire when visible + threshold?: number; // IntersectionObserver threshold (default: 0.5) + once?: boolean; // Fire only once (default: true) + minVisibleTime?: number; // Minimum ms visible before firing + data?: Record; // Additional properties + children: React.ReactElement; +} +``` + +**Implementation approach:** + +- Wrap existing `useSectionView` hook logic +- Use `Slot` pattern to attach ref to child +- Merge context data from `Ac.Context` + +--- + +## File Structure + +``` +apps/web/src/components/tracking/ + - index.ts # Export Ac namespace + - AcIntent.tsx # Click/hover tracking component + - AcContext.tsx # Context provider component + - AcView.tsx # View tracking component + - context.ts # React context for Ac.Context data +``` + +--- + +## Refactoring Examples + +### Before (HomePageClient.tsx): + +```tsx + track(HomepageIntent.NAV_DOCS_CLICK)} + data-ac-intent={HomepageIntent.NAV_DOCS_CLICK} +> + Docs + +``` + +### After: + +```tsx + +Docs + +``` + +### Before (FeaturesSection.tsx): + +```tsx +
+``` + +### After: + +```tsx + +
...
+
+``` + +--- + +## Dependencies + +- `@radix-ui/react-slot` - Already used in codebase for `asChild` pattern +- Existing `@/lib/tracking` hooks - Reuse `getTrackingManager()` \ No newline at end of file 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/AnyclickProviderWrapper.tsx b/apps/web/src/components/AnyclickProviderWrapper.tsx index 73a5b62..7ead181 100644 --- a/apps/web/src/components/AnyclickProviderWrapper.tsx +++ b/apps/web/src/components/AnyclickProviderWrapper.tsx @@ -1,5 +1,6 @@ "use client"; +import { Ac } from "@/components/tracking"; import type { ReactNode } from "react"; import { useMemo } from "react"; import { ModificationIndicator } from "@ewjdev/anyclick-devtools"; @@ -82,6 +83,10 @@ export function AnyclickProviderWrapper({ children }: { children: ReactNode }) { autoApply primaryColor="#3b82f6" /> + {/* Bridge Ac actions with AnyclickProvider context menu */} + + {/* Debug panel for development (only renders in dev mode) */} + {children} 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..12dce07 100644 --- a/apps/web/src/components/FeaturesSection.tsx +++ b/apps/web/src/components/FeaturesSection.tsx @@ -1,3 +1,7 @@ +"use client"; + +import { Ac } from "@/components/tracking"; +import { HomepageIntent } from "@/lib/intents"; import { Camera, GitBranch, @@ -7,96 +11,147 @@ import { Zap, } from "lucide-react"; -const FeaturesSection = () => { - return ( -
-
-

- Everything you need for UI context -

-

- From capturing user intent to automatic code fixes, anyclick - streamlines the entire feedback workflow. -

-
+type Feature = { + id: string; + title: string; + description: string; + icon: typeof Camera; + color: string; + hoverBorder: string; +}; -
- {/* Feature 1 */} -
-
- -
-

Context-Aware Capture

-

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

-
+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", + }, +]; - {/* Feature 2 */} -
-
- -
-

Visual Capture

-

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

-
+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", + }, +}; - {/* Feature 3 */} -
-
- -
-

- Code Source Integration -

-

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

-
+function FeatureCard({ feature }: { feature: Feature }) { + const Icon = feature.icon; + const colors = colorClasses[feature.color]; - {/* Feature 4 */} -
-
- -
-

AI Agent

-

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

+ return ( + +
+
+
+

{feature.title}

+

+ {feature.description} +

+
+
+ ); +} - {/* Feature 5 */} -
-
- -
-

Framework Agnostic

-

- Core library works with any JavaScript framework. Use the React - provider for React apps, or build your own integration. +const FeaturesSection = () => { + return ( + +

+
+

+ Everything you need for UI context +

+

+ From capturing user intent to automatic code fixes, anyclick + streamlines the entire feedback workflow.

- {/* 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..10ff405 --- /dev/null +++ b/apps/web/src/components/HomePageClient.tsx @@ -0,0 +1,290 @@ +"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 { HomepageTracking } from "@/components/IntentProvider"; +import PackagesSection from "@/components/PackagesSection"; +import QuickStartSection from "@/components/QuickStartSection"; +import RoadmapSummary from "@/components/RoadmapSummary"; +import { Ac } from "@/components/tracking"; +import { HomepageIntent } from "@/lib/intents"; +import { useTrackIntent } from "@/lib/tracking"; +import { ArrowRight } from "lucide-react"; +import Link from "next/link"; + +export function HomePageClient() { + // Only need track for non-interactive callbacks like onCopy + const { track } = useTrackIntent(); + + 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 */} +
+ + + 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 */} + + +
+
+
+ + anyclick +
+
+ + + Documentation + + + + + Roadmap + + + + + Examples + + + + + GitHub + + + + + npm + + +
+

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

+
+
+
+
+
+ ); +} 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..9080088 100644 --- a/apps/web/src/components/PackagesSection.tsx +++ b/apps/web/src/components/PackagesSection.tsx @@ -1,3 +1,7 @@ +"use client"; + +import { Ac } from "@/components/tracking"; +import { HomepageIntent } from "@/lib/intents"; import { Bot, Box, @@ -16,7 +20,7 @@ type PackageItem = { name: string; description: string; features: string[]; - icon: any; + icon: React.ComponentType<{ className?: string }>; color: string; tags?: string[]; }; @@ -78,7 +82,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,143 +117,151 @@ const extensionPackages: PackageItem[] = [ }, ]; -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 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 }) => { const colors = getColors(item.color); + const Icon = item.icon; return ( -
-
- - {item.name} -
-

{item.description}

-
- {item.features.map((feature) => ( - - {feature} - - ))} + +
+
+ + {item.name} +
+

{item.description}

+
+ {item.features.map((feature) => ( + + {feature} + + ))} +
-
+ ); }; const PackagesSection = () => { return ( -
-
-

- Modular Architecture -

-

- Pick only the packages you need. All packages are published under the{" "} - @anyclick scope. -

-
+ +
+
+

+ Modular Architecture +

+

+ Pick only the packages you need. All packages are published under + the @anyclick scope. +

+
-
-

- Core Packages -

-
- {corePackages.map((pkg) => ( - - ))} +
+

+ Core Packages +

+
+ {corePackages.map((pkg) => ( + + ))} +
-
-
-

- Extensions & Adapters -

-
- {extensionPackages.map((pkg) => ( - - ))} +
+

+ Extensions & Adapters +

+
+ {extensionPackages.map((pkg) => ( + + ))} +
-
+ ); }; 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 ( -
+
+ * + * + * + * // Tracked data will include: { section: "packages", card: "react" } + * + * + * // With explicit actions for context menu + * + * + * + * + * // Nested contexts (inner overrides outer for duplicate keys) + * + * + * // data = { section: "packages", subsection: "core" } + * + * + * ``` + */ +export const AcContext = React.forwardRef( + ( + { + metadata, + name, + actions = [], + disabled = false, + asChild = false, + children, + className, + }, + forwardedRef, + ) => { + // Get parent context data + const parentContext = useAcContext(); + + // Generate unique ID for this context + const contextIdRef = React.useRef(generateId("context")); + const contextId = contextIdRef.current; + + // Internal ref for registration + const internalRef = React.useRef(null); + + // Combine refs + const setRefs = React.useCallback( + (node: HTMLDivElement | null) => { + internalRef.current = node; + if (typeof forwardedRef === "function") { + forwardedRef(node); + } else if (forwardedRef) { + forwardedRef.current = node; + } + }, + [forwardedRef], + ); + + // Merge parent metadata with this context's metadata + const mergedMetadata = React.useMemo( + () => mergeContextData(parentContext.metadata, metadata), + [parentContext.metadata, metadata], + ); + + // Calculate depth + const depth = parentContext.depth + 1; + + // Store actions + const registerContext = useIntentStore((s) => s.registerContext); + const unregisterContext = useIntentStore((s) => s.unregisterContext); + const updateContext = useIntentStore((s) => s.updateContext); + + // Register with store on mount + React.useEffect(() => { + if (disabled) return; + + registerContext({ + id: contextId, + name, + metadata: mergedMetadata, + actions: actions as AcAction[], + elementRef: internalRef, + parentId: parentContext.contextId, + depth, + }); + + return () => { + unregisterContext(contextId); + }; + }, [ + contextId, + name, + disabled, + registerContext, + unregisterContext, + parentContext.contextId, + depth, + ]); + + // Update store when metadata or actions change + React.useEffect(() => { + if (disabled) return; + + updateContext(contextId, { + metadata: mergedMetadata, + actions: actions as AcAction[], + }); + }, [contextId, mergedMetadata, actions, disabled, updateContext]); + + // Create new context value for children + const contextValue = React.useMemo( + () => ({ + contextId, + metadata: mergedMetadata, + name: name ?? parentContext.name, + depth, + }), + [contextId, mergedMetadata, name, parentContext.name, depth], + ); + + // Build data attributes + const dataAttributes: Record = {}; + if (name) { + dataAttributes["data-ac-context"] = name; + } + + // Render content + const content = ( + + {children} + + ); + + // If asChild, use Slot to merge props onto single child + if (asChild) { + return ( + + {content} + + ); + } + + // Otherwise wrap in a div + return ( +
+ {content} +
+ ); + }, +); + +AcContext.displayName = "Ac.Context"; diff --git a/apps/web/src/components/tracking/AcIntent.tsx b/apps/web/src/components/tracking/AcIntent.tsx new file mode 100644 index 0000000..ae03f2a --- /dev/null +++ b/apps/web/src/components/tracking/AcIntent.tsx @@ -0,0 +1,217 @@ +"use client"; + +/** + * Ac.Intent - Declarative click/hover intent tracking component. + * + * Wraps interactive elements to automatically track clicks and hovers + * without manual event handler wiring. Registers with parent Ac.Context + * for context menu integration. + * + * @module components/tracking/AcIntent + */ +import { getTrackingManager } from "@/lib/tracking/manager"; +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { mergeContextData, useAcContext } from "./context"; +import { generateId, useIntentStore } from "./store"; +import type { AcIntentProps } from "./types"; + +// Re-export props type for external use +export type { AcIntentProps } from "./types"; + +/** + * Ac.Intent - Declarative intent tracking wrapper. + * + * Automatically attaches event handlers and data attributes to track + * user interactions without manual boilerplate. Registers with the + * intent store for context menu integration (unless context={false}). + * + * @example + * ```tsx + * // Click tracking + * + * Docs + * + * + * // Hover tracking (fires once per mount) + * + * + * + * + * // With additional metadata + * + *
Documentation + * + * + * // With action for context menu + * + * + * + * + * // Opt out of context menu (still tracks events) + * + * + * + * ``` + */ +export const AcIntent = React.forwardRef( + ( + { + intent, + on = "click", + metadata, + context: registerWithContext = true, + action, + disabled = false, + children, + }, + forwardedRef, + ) => { + // Get context data from parent Ac.Context providers + const { metadata: contextMetadata, contextId } = useAcContext(); + + // Generate unique ID for this intent + const intentIdRef = React.useRef(generateId("intent")); + const intentId = intentIdRef.current; + + // Internal ref for registration + const internalRef = React.useRef(null); + + // Combine refs + const setRefs = React.useCallback( + (node: HTMLElement | null) => { + internalRef.current = node; + if (typeof forwardedRef === "function") { + forwardedRef(node); + } else if (forwardedRef) { + forwardedRef.current = node; + } + }, + [forwardedRef], + ); + + // Merge context metadata with local metadata (local overrides context) + const mergedMetadata = React.useMemo( + () => mergeContextData(contextMetadata, metadata ?? {}), + [contextMetadata, metadata], + ); + + // Track hover only once per mount + const hasHoveredRef = React.useRef(false); + + // Store actions + const registerIntent = useIntentStore((s) => s.registerIntent); + const unregisterIntent = useIntentStore((s) => s.unregisterIntent); + + // Determine event type for registration + const eventType = on === "both" ? "click" : on; + + // Register with store on mount (unless context={false}) + React.useEffect(() => { + if (disabled) return; + + // Always register, but mark optOut if context={false} + // This allows events to pass through but consumers ignore optOut intents + registerIntent({ + id: intentId, + intent, + contextId, + metadata: mergedMetadata, + elementRef: internalRef, + optOut: !registerWithContext, + eventType, + action, + }); + + return () => { + unregisterIntent(intentId); + }; + }, [ + intentId, + intent, + contextId, + disabled, + registerWithContext, + eventType, + action, + registerIntent, + unregisterIntent, + ]); + + // Track function + const trackIntent = React.useCallback(() => { + if (disabled) return; + + getTrackingManager().track(intent, { + properties: + Object.keys(mergedMetadata).length > 0 ? mergedMetadata : undefined, + }); + }, [intent, mergedMetadata, disabled]); + + // Click handler + const handleClick = React.useCallback( + (event: React.MouseEvent) => { + if (on === "click" || on === "both") { + trackIntent(); + } + + // Call original onClick if it exists + const childProps = children.props as Record; + if (typeof childProps?.onClick === "function") { + (childProps.onClick as (e: React.MouseEvent) => void)(event); + } + }, + [on, trackIntent, children.props], + ); + + // Hover handler (fires once per mount) + const handleMouseEnter = React.useCallback( + (event: React.MouseEvent) => { + if ((on === "hover" || on === "both") && !hasHoveredRef.current) { + hasHoveredRef.current = true; + trackIntent(); + } + + // Call original onMouseEnter if it exists + const childProps = children.props as Record; + if (typeof childProps?.onMouseEnter === "function") { + (childProps.onMouseEnter as (e: React.MouseEvent) => void)(event); + } + }, + [on, trackIntent, children.props], + ); + + // Build data attributes for anyclick + const dataAttributes: Record = { + "data-ac-intent": intent, + }; + + // Add data-ac-* attributes for each key in mergedMetadata + for (const [key, value] of Object.entries(mergedMetadata)) { + if (value !== undefined && value !== null) { + dataAttributes[`data-ac-${key}`] = String(value); + } + } + + // Build props to merge onto child + const slotProps: Record = { + ref: setRefs, + ...dataAttributes, + }; + + // Attach event handlers based on tracking mode + if (on === "click" || on === "both") { + slotProps.onClick = handleClick; + } + if (on === "hover" || on === "both") { + slotProps.onMouseEnter = handleMouseEnter; + } + + return {children}; + }, +); + +AcIntent.displayName = "Ac.Intent"; diff --git a/apps/web/src/components/tracking/AcMenuBridge.tsx b/apps/web/src/components/tracking/AcMenuBridge.tsx new file mode 100644 index 0000000..3f5084e --- /dev/null +++ b/apps/web/src/components/tracking/AcMenuBridge.tsx @@ -0,0 +1,239 @@ +"use client"; + +/** + * AcMenuBridge - Bridges Ac actions with AnyclickProvider context menu. + * + * Reads from the intent store and injects Ac actions into the + * AnyclickProvider's context menu when right-clicking within an Ac.Context. + * + * @module components/tracking/AcMenuBridge + */ +import { getTrackingManager } from "@/lib/tracking/manager"; +import * as React from "react"; +import type { ContextMenuItem } from "@ewjdev/anyclick-react"; +import { useIntentStore } from "./store"; +import type { AcAction, AcActionContext } from "./types"; + +/** + * Props for AcMenuBridge. + */ +export interface AcMenuBridgeProps { + /** + * Callback to get the current target element for the context menu. + * This is called when the menu opens to determine which Ac actions to show. + */ + getTargetElement?: () => Element | null; + /** + * Callback to register dynamic menu items with the parent AnyclickProvider. + * If not provided, the bridge will attempt to use a global registration mechanism. + */ + onRegisterActions?: ( + getActions: (element: Element) => ContextMenuItem[], + ) => () => void; +} + +/** + * Converts an AcAction to a ContextMenuItem for AnyclickProvider. + */ +function convertToMenuItem( + action: AcAction, + targetElement: Element | null, + containerElement: Element | null, + closeMenu: () => void, + metadata: Record, +): ContextMenuItem { + return { + type: action.intent, + label: action.label, + icon: action.icon, + showComment: action.showComment ?? false, + status: action.status, + requiredRoles: action.requiredRoles, + onClick: async (ctx) => { + // Build action context + const actionContext: AcActionContext = { + intent: action.intent, + metadata, + targetElement, + containerElement: ctx.containerElement, + closeMenu: ctx.closeMenu, + track: () => { + getTrackingManager().track(action.intent, { + properties: Object.keys(metadata).length > 0 ? metadata : undefined, + }); + }, + captureScreenshots: async () => { + // This would need to integrate with anyclick's screenshot system + // For now, return empty - the default submission flow handles this + return {}; + }, + submit: async (comment?: string) => { + // This would submit to the anyclick adapter + // For now, let the default flow handle it + }, + }; + + // Call custom handler if provided + if (action.handler) { + const result = await action.handler(actionContext); + // If handler returns false, skip default submission + if (result === false) { + return false; + } + } else { + // No custom handler - fire tracking event + actionContext.track(); + } + + // Let default submission flow continue + return true; + }, + }; +} + +/** + * Hook to get Ac actions for a given element. + * + * @param element - The target element (from context menu) + * @returns Array of ContextMenuItem for AnyclickProvider + */ +export function useAcActions(element: Element | null): ContextMenuItem[] { + const getActionsForElement = useIntentStore((s) => s.getActionsForElement); + const getMetadataForElement = useIntentStore((s) => s.getMetadataForElement); + + return React.useMemo(() => { + if (!element) return []; + + const actions = getActionsForElement(element); + const metadata = getMetadataForElement(element); + + // Convert to ContextMenuItem format + return actions.map((action) => + convertToMenuItem( + action, + element, + null, // Container will be provided by anyclick + () => {}, // closeMenu will be provided by anyclick + metadata, + ), + ); + }, [element, getActionsForElement, getMetadataForElement]); +} + +/** + * Hook to get a function that returns Ac actions for any element. + * + * Use this to dynamically get actions based on right-click target. + */ +export function useAcActionGetter(): (element: Element) => ContextMenuItem[] { + const getActionsForElement = useIntentStore((s) => s.getActionsForElement); + const getMetadataForElement = useIntentStore((s) => s.getMetadataForElement); + + return React.useCallback( + (element: Element) => { + const actions = getActionsForElement(element); + const metadata = getMetadataForElement(element); + + return actions.map((action) => + convertToMenuItem(action, element, null, () => {}, metadata), + ); + }, + [getActionsForElement, getMetadataForElement], + ); +} + +/** + * AcMenuBridge - Component that bridges Ac actions with AnyclickProvider. + * + * Place this inside your AnyclickProvider to enable Ac actions in the + * context menu. The bridge reads from the intent store and provides + * actions based on the right-clicked element's context. + * + * @example + * ```tsx + * + * + * + * + * ``` + * + * Note: Full integration requires AnyclickProvider to support dynamic + * menu item injection. Currently this component provides hooks that + * can be used to get Ac actions for manual integration. + */ +export function AcMenuBridge({ onRegisterActions }: AcMenuBridgeProps) { + const getActions = useAcActionGetter(); + + // Register with parent provider if callback provided + React.useEffect(() => { + if (onRegisterActions) { + const unregister = onRegisterActions(getActions); + return unregister; + } + }, [getActions, onRegisterActions]); + + // This component doesn't render anything visible + // It just registers actions with the intent store + return null; +} + +AcMenuBridge.displayName = "AcMenuBridge"; + +/** + * Debug component to visualize registered contexts and intents. + * Only use in development. + */ +export function AcDebugPanel() { + const contexts = useIntentStore((s) => s.contexts); + const intents = useIntentStore((s) => s.intents); + + if (process.env.NODE_ENV !== "development") { + return null; + } + + return ( +
+

Ac Registry

+
+ Contexts ({contexts.size}): +
    + {Array.from(contexts.values()).map((ctx) => ( +
  • + {ctx.name ?? ctx.id} (depth: {ctx.depth}, actions:{" "} + {ctx.actions.length}) +
  • + ))} +
+
+
+ Intents ({intents.size}): +
    + {Array.from(intents.values()) + .slice(0, 10) + .map((intent) => ( +
  • + {intent.intent.split(".").slice(-2).join(".")} + {intent.optOut && " (opt-out)"} +
  • + ))} + {intents.size > 10 &&
  • ...and {intents.size - 10} more
  • } +
+
+
+ ); +} diff --git a/apps/web/src/components/tracking/AcView.tsx b/apps/web/src/components/tracking/AcView.tsx new file mode 100644 index 0000000..f3e93ea --- /dev/null +++ b/apps/web/src/components/tracking/AcView.tsx @@ -0,0 +1,228 @@ +"use client"; + +/** + * Ac.View - Visibility/section view tracking component. + * + * Tracks when an element enters the viewport using IntersectionObserver. + * Registers with parent Ac.Context for context menu integration. + * + * @module components/tracking/AcView + */ +import { getTrackingManager } from "@/lib/tracking/manager"; +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { mergeContextData, useAcContext } from "./context"; +import { generateId, useIntentStore } from "./store"; +import type { AcViewProps } from "./types"; + +// Re-export props type for external use +export type { AcViewProps } from "./types"; + +/** + * Ac.View - Declarative view tracking wrapper. + * + * Automatically tracks when the wrapped element becomes visible + * in the viewport using IntersectionObserver. Registers with the + * intent store for context menu integration (unless context={false}). + * + * @example + * ```tsx + * // Basic view tracking + * + *
...
+ *
+ * + * // With threshold and minimum visible time + * + *
...
+ *
+ * + * // With additional metadata + * + *
...
+ *
+ * + * // Opt out of context menu (still tracks view) + * + *
...
+ *
+ * ``` + */ +export const AcView = React.forwardRef( + ( + { + intent, + threshold = 0.5, + once = true, + minVisibleTime = 0, + metadata, + context: registerWithContext = true, + disabled = false, + children, + }, + forwardedRef, + ) => { + // Get context data from parent Ac.Context providers + const { metadata: contextMetadata, contextId } = useAcContext(); + + // Generate unique ID for this intent + const intentIdRef = React.useRef(generateId("intent")); + const intentId = intentIdRef.current; + + // Merge context metadata with local metadata (local overrides context) + const mergedMetadata = React.useMemo( + () => mergeContextData(contextMetadata, metadata ?? {}), + [contextMetadata, metadata], + ); + + // Internal ref for the element + const internalRef = React.useRef(null); + + // Track if we've already fired + const hasTrackedRef = React.useRef(false); + + // Timer ref for minVisibleTime + const visibleTimerRef = React.useRef | null>( + null, + ); + + // Combine refs + const setRefs = React.useCallback( + (node: HTMLElement | null) => { + internalRef.current = node; + + // Forward ref + if (typeof forwardedRef === "function") { + forwardedRef(node); + } else if (forwardedRef) { + forwardedRef.current = node; + } + }, + [forwardedRef], + ); + + // Store actions + const registerIntent = useIntentStore((s) => s.registerIntent); + const unregisterIntent = useIntentStore((s) => s.unregisterIntent); + + // Register with store on mount (unless context={false}) + React.useEffect(() => { + if (disabled) return; + + // Always register, but mark optOut if context={false} + registerIntent({ + id: intentId, + intent, + contextId, + metadata: mergedMetadata, + elementRef: internalRef, + optOut: !registerWithContext, + eventType: "view", + action: undefined, // Views don't have actions by default + }); + + return () => { + unregisterIntent(intentId); + }; + }, [ + intentId, + intent, + contextId, + mergedMetadata, + disabled, + registerWithContext, + registerIntent, + unregisterIntent, + ]); + + // Track function + const trackView = React.useCallback(() => { + if (disabled || (once && hasTrackedRef.current)) return; + + hasTrackedRef.current = true; + getTrackingManager().track(intent, { + properties: + Object.keys(mergedMetadata).length > 0 ? mergedMetadata : undefined, + engagement: { + viewportVisible: true, + timeOnSection: minVisibleTime > 0 ? minVisibleTime : undefined, + }, + }); + }, [intent, mergedMetadata, disabled, once, minVisibleTime]); + + // Set up IntersectionObserver + React.useEffect(() => { + if (typeof window === "undefined" || !internalRef.current || disabled) { + return; + } + + const element = internalRef.current; + + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + + if (entry?.isIntersecting) { + // Element is visible + if (hasTrackedRef.current && once) { + return; + } + + if (minVisibleTime > 0) { + // Wait for minimum visible time + visibleTimerRef.current = setTimeout(() => { + trackView(); + }, minVisibleTime); + } else { + // Track immediately + trackView(); + } + } 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); + } + }; + }, [threshold, once, minVisibleTime, disabled, trackView]); + + // Build data attributes + const dataAttributes: Record = { + "data-ac-intent": intent, + }; + + // Add data-ac-* attributes for each key in mergedMetadata + for (const [key, value] of Object.entries(mergedMetadata)) { + if (value !== undefined && value !== null) { + dataAttributes[`data-ac-${key}`] = String(value); + } + } + + return ( + + {children} + + ); + }, +); + +AcView.displayName = "Ac.View"; diff --git a/apps/web/src/components/tracking/context.ts b/apps/web/src/components/tracking/context.ts new file mode 100644 index 0000000..6fa6870 --- /dev/null +++ b/apps/web/src/components/tracking/context.ts @@ -0,0 +1,72 @@ +"use client"; + +/** + * React context for Ac.Context data propagation. + * + * Provides hierarchical data that flows to nested Ac.Intent and Ac.View components. + * + * @module components/tracking/context + */ +import { createContext, useContext } from "react"; + +/** + * Data stored in the Ac tracking context. + */ +export interface AcContextData { + /** Unique ID of this context (for store registration) */ + contextId: string | null; + /** Key-value pairs of context data */ + metadata: Record; + /** Optional context name for data-ac-context attribute */ + name?: string; + /** Depth in context tree (0 = no context) */ + depth: number; +} + +/** + * Default context value (empty data). + */ +const defaultContextData: AcContextData = { + contextId: null, + metadata: {}, + name: undefined, + depth: 0, +}; + +/** + * React context for Ac tracking data. + */ +export const AcTrackingContext = + createContext(defaultContextData); + +/** + * Hook to access the current Ac tracking context data. + * + * @returns The current context data, or default if no context provider exists + * + * @example + * ```tsx + * function MyComponent() { + * const { metadata, name, contextId } = useAcContext(); + * // metadata contains merged context from all parent Ac.Context providers + * } + * ``` + */ +export function useAcContext(): AcContextData { + return useContext(AcTrackingContext); +} + +/** + * Merge parent context data with child context data. + * Child data overrides parent data for duplicate keys. + * + * @param parent - Parent context data + * @param child - Child context data to merge + * @returns Merged context data + */ +export function mergeContextData( + parent: Record, + child: Record, +): Record { + return { ...parent, ...child }; +} diff --git a/apps/web/src/components/tracking/helpers.ts b/apps/web/src/components/tracking/helpers.ts new file mode 100644 index 0000000..a92213c --- /dev/null +++ b/apps/web/src/components/tracking/helpers.ts @@ -0,0 +1,245 @@ +"use client"; + +/** + * Helper functions for creating Ac actions. + * + * Provides convenient wrappers for common action patterns like + * track-only, track-and-submit, and screenshot capture. + * + * @module components/tracking/helpers + */ +import { getTrackingManager } from "@/lib/tracking/manager"; +import type { AcAction, AcActionContext } from "./types"; + +/** + * Default priority for actions (middle of 1-10 range). + */ +const DEFAULT_PRIORITY = 5; + +/** + * Options for creating actions. + */ +interface ActionOptions { + /** Display label in context menu */ + label?: string; + /** Priority for sorting (1-10, where 10 = highest) */ + priority?: number; + /** Optional icon */ + icon?: React.ReactNode; + /** Show comment input when selected */ + showComment?: boolean; + /** Required roles to see this action */ + requiredRoles?: string[]; +} + +/** + * Creates an action that only fires the tracking event. + * + * Use this for lightweight tracking without triggering anyclick submission. + * + * @param intent - The intent identifier + * @param options - Optional configuration + * @returns An AcAction that fires tracking only + * + * @example + * ```tsx + * + * ``` + */ +export function createTrackOnlyAction( + intent: string, + options: ActionOptions = {}, +): AcAction { + return { + intent, + label: options.label ?? formatIntentAsLabel(intent), + priority: options.priority ?? DEFAULT_PRIORITY, + icon: options.icon, + showComment: false, + requiredRoles: options.requiredRoles, + handler: (context: AcActionContext) => { + // Fire tracking event + context.track(); + // Close menu + context.closeMenu(); + // Return false to skip default submission + return false; + }, + }; +} + +/** + * Creates an action that fires tracking AND submits to the anyclick adapter. + * + * Use this for actions that should create issues, feedback, etc. + * + * @param intent - The intent identifier + * @param options - Optional configuration + * @returns An AcAction that tracks and submits + * + * @example + * ```tsx + * + * ``` + */ +export function createTrackAndSubmitAction( + intent: string, + options: ActionOptions = {}, +): AcAction { + return { + intent, + label: options.label ?? formatIntentAsLabel(intent), + priority: options.priority ?? DEFAULT_PRIORITY, + icon: options.icon, + showComment: options.showComment ?? false, + requiredRoles: options.requiredRoles, + handler: async (context: AcActionContext) => { + // Fire tracking event + context.track(); + // Let default submission flow continue + return true; + }, + }; +} + +/** + * Creates an action that captures screenshots before submitting. + * + * Use this for visual feedback that needs screenshot context. + * + * @param intent - The intent identifier + * @param options - Optional configuration + * @returns An AcAction that captures screenshots and submits + * + * @example + * ```tsx + * + * ``` + */ +export function createScreenshotAction( + intent: string, + options: ActionOptions = {}, +): AcAction { + return { + intent, + label: options.label ?? formatIntentAsLabel(intent), + priority: options.priority ?? DEFAULT_PRIORITY, + icon: options.icon, + showComment: options.showComment ?? true, + requiredRoles: options.requiredRoles, + handler: async (context: AcActionContext) => { + // Fire tracking event + context.track(); + // Capture screenshots + await context.captureScreenshots(); + // Let default submission flow continue + return true; + }, + }; +} + +/** + * Creates a custom action with full control over the handler. + * + * Use this when you need custom behavior beyond track/submit/screenshot. + * + * @param intent - The intent identifier + * @param handler - Custom handler function + * @param options - Optional configuration + * @returns An AcAction with custom handler + * + * @example + * ```tsx + * { + * ctx.track(); + * await myCustomLogic(ctx.metadata); + * ctx.closeMenu(); + * return false; // Skip default submission + * }, + * { label: "Custom Action", priority: 7 } + * ), + * ]} + * > + * ``` + */ +export function createCustomAction( + intent: string, + handler: AcAction["handler"], + options: ActionOptions = {}, +): AcAction { + return { + intent, + label: options.label ?? formatIntentAsLabel(intent), + priority: options.priority ?? DEFAULT_PRIORITY, + icon: options.icon, + showComment: options.showComment ?? false, + requiredRoles: options.requiredRoles, + handler, + }; +} + +/** + * Helper to fire a tracking event for an intent. + * + * Use this in custom handlers or outside of Ac components. + * + * @param intent - The intent identifier + * @param metadata - Optional metadata to include + * + * @example + * ```ts + * trackIntent(HomepageIntent.BUTTON_CLICK, { buttonId: "cta" }); + * ``` + */ +export function trackIntent( + intent: string, + metadata?: Record, +): void { + getTrackingManager().track(intent, { + properties: metadata, + }); +} + +/** + * Formats an intent string as a human-readable label. + * + * Converts "web.homepage.nav.docs.click" to "Docs Click" + * + * @param intent - The intent identifier + * @returns Human-readable label + */ +function formatIntentAsLabel(intent: string): string { + // Get last two segments (e.g., "docs.click" from "web.homepage.nav.docs.click") + const segments = intent.split("."); + const lastTwo = segments.slice(-2); + + // Capitalize and join + return lastTwo.map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join(" "); +} diff --git a/apps/web/src/components/tracking/index.ts b/apps/web/src/components/tracking/index.ts new file mode 100644 index 0000000..aa06307 --- /dev/null +++ b/apps/web/src/components/tracking/index.ts @@ -0,0 +1,168 @@ +"use client"; + +/** + * Ac - Declarative intent tracking components. + * + * A namespace of React components for tracking user interactions + * with minimal boilerplate. Integrates with AnyclickProvider for + * context menu actions. + * + * @module components/tracking + * + * @example + * ```tsx + * import { Ac } from "@/components/tracking"; + * + * // Click tracking + * + * Docs + * + * + * // Hover tracking + * + * + * + * + * // View tracking + * + *
...
+ *
+ * + * // Context provider with actions + * + * + * + * + * + * ``` + */ +// Components +import { AcContext } from "./AcContext"; +import { AcIntent } from "./AcIntent"; +import { AcDebugPanel, AcMenuBridge } from "./AcMenuBridge"; +import { AcView } from "./AcView"; + +// Export individual components +export { AcContext } from "./AcContext"; +export { AcIntent } from "./AcIntent"; +export { AcView } from "./AcView"; +export { + AcMenuBridge, + AcDebugPanel, + useAcActions, + useAcActionGetter, +} from "./AcMenuBridge"; + +// Export context hook +export { useAcContext } from "./context"; + +// Export store and hooks +export { useIntentStore, generateId } from "./store"; + +// Export helper functions +export { + createTrackOnlyAction, + createTrackAndSubmitAction, + createScreenshotAction, + createCustomAction, + trackIntent, +} from "./helpers"; + +// Export types +export type { + AcAction, + AcActionContext, + AcContextProps, + AcIntentProps, + AcViewProps, + RegisteredContext, + RegisteredIntent, + IntentEventType, + IntentStore, +} from "./types"; + +export type { AcContextData } from "./context"; +export type { AcMenuBridgeProps } from "./AcMenuBridge"; + +/** + * Ac namespace - Declarative intent tracking components. + * + * Use `Ac.Intent` for click/hover tracking, `Ac.View` for visibility tracking, + * `Ac.Context` for hierarchical context and actions, and `Ac.MenuBridge` + * for AnyclickProvider integration. + */ +export const Ac = { + /** + * Wraps interactive elements to track clicks and/or hovers. + * + * @example + * ```tsx + * + * Docs + * + * ``` + */ + Intent: AcIntent, + + /** + * Provides context data and actions to nested Ac.Intent and Ac.View components. + * + * @example + * ```tsx + * + * + * + * + * + * ``` + */ + Context: AcContext, + + /** + * Tracks when an element becomes visible in the viewport. + * + * @example + * ```tsx + * + *
...
+ *
+ * ``` + */ + View: AcView, + + /** + * Bridges Ac actions with AnyclickProvider context menu. + * Place inside AnyclickProvider to enable dynamic menu items. + * + * @example + * ```tsx + * + * + * + * + * ``` + */ + MenuBridge: AcMenuBridge, + + /** + * Debug panel showing registered contexts and intents. + * Only renders in development mode. + * + * @example + * ```tsx + * + * ``` + */ + DebugPanel: AcDebugPanel, +} as const; diff --git a/apps/web/src/components/tracking/store.ts b/apps/web/src/components/tracking/store.ts new file mode 100644 index 0000000..724a3b2 --- /dev/null +++ b/apps/web/src/components/tracking/store.ts @@ -0,0 +1,249 @@ +"use client"; + +/** + * Zustand store for intent/context registration. + * + * Manages a registry of Ac.Context and Ac.Intent components, + * enabling dynamic context menu actions and metadata inheritance. + * + * @module components/tracking/store + */ +import { create } from "zustand"; +import type { + AcAction, + IntentStore, + RegisteredContext, + RegisteredIntent, +} from "./types"; + +/** Counter for generating unique IDs */ +let idCounter = 0; + +/** + * Generates a unique ID for context or intent registration. + */ +export function generateId(prefix: "context" | "intent"): string { + return `ac-${prefix}-${++idCounter}`; +} + +/** + * Default priority for actions (middle of 1-10 range). + */ +const DEFAULT_PRIORITY = 5; + +/** + * Zustand store for managing intent and context registrations. + * + * @example + * ```ts + * // Get actions for an element + * const actions = useIntentStore.getState().getActionsForElement(element); + * + * // Register a context + * const { registerContext } = useIntentStore(); + * registerContext({ id: "ctx-1", ... }); + * ``` + */ +export const useIntentStore = create((set, get) => ({ + contexts: new Map(), + intents: new Map(), + + // ========================================================================= + // Context Registration + // ========================================================================= + + registerContext: (context) => { + set((state) => { + const newContexts = new Map(state.contexts); + newContexts.set(context.id, context); + return { contexts: newContexts }; + }); + }, + + unregisterContext: (contextId) => { + set((state) => { + const newContexts = new Map(state.contexts); + newContexts.delete(contextId); + + // Also remove intents belonging to this context + const newIntents = new Map(state.intents); + Array.from(newIntents.entries()).forEach(([intentId, intent]) => { + if (intent.contextId === contextId) { + newIntents.delete(intentId); + } + }); + + return { contexts: newContexts, intents: newIntents }; + }); + }, + + updateContext: (contextId, updates) => { + set((state) => { + const newContexts = new Map(state.contexts); + const existing = newContexts.get(contextId); + if (existing) { + newContexts.set(contextId, { ...existing, ...updates }); + } + return { contexts: newContexts }; + }); + }, + + // ========================================================================= + // Intent Registration + // ========================================================================= + + registerIntent: (intent) => { + set((state) => { + const newIntents = new Map(state.intents); + newIntents.set(intent.id, intent); + return { intents: newIntents }; + }); + }, + + unregisterIntent: (intentId) => { + set((state) => { + const newIntents = new Map(state.intents); + newIntents.delete(intentId); + return { intents: newIntents }; + }); + }, + + // ========================================================================= + // Queries + // ========================================================================= + + findContextForElement: (element) => { + const { contexts } = get(); + + let nearestContext: RegisteredContext | null = null; + let maxDepth = -1; + + Array.from(contexts.values()).forEach((context) => { + const container = context.elementRef.current; + if (!container) return; + + if (container.contains(element)) { + if (context.depth > maxDepth) { + nearestContext = context; + maxDepth = context.depth; + } + } + }); + + return nearestContext; + }, + + findParentContext: (element, excludeId) => { + const { contexts } = get(); + + if (!element) return null; + + let nearestParent: RegisteredContext | null = null; + let maxDepth = -1; + + Array.from(contexts.values()).forEach((context) => { + if (context.id === excludeId) return; + + const container = context.elementRef.current; + if (!container) return; + + if (container.contains(element)) { + if (context.depth > maxDepth) { + nearestParent = context; + maxDepth = context.depth; + } + } + }); + + return nearestParent; + }, + + getMetadataForElement: (element) => { + const { contexts, findContextForElement } = get(); + + const nearestContext = findContextForElement(element); + if (!nearestContext) return {}; + + // Walk up the context hierarchy and merge metadata + const metadataChain: Record[] = []; + let current: RegisteredContext | undefined = nearestContext; + + while (current) { + metadataChain.unshift(current.metadata); + current = current.parentId ? contexts.get(current.parentId) : undefined; + } + + // Merge from root to leaf (later overrides earlier) + return metadataChain.reduce( + (acc, metadata) => ({ ...acc, ...metadata }), + {}, + ); + }, + + getActionsForElement: (element) => { + const { contexts, intents, findContextForElement } = get(); + + const nearestContext = findContextForElement(element); + if (!nearestContext) return []; + + // Collect all actions from context hierarchy + const allActions: AcAction[] = []; + + // Walk up the context hierarchy + let current: RegisteredContext | undefined = nearestContext; + while (current) { + // Add explicit actions from context + allActions.push(...current.actions); + + // Add actions from intents registered to this context + const currentContextId = current.id; + Array.from(intents.values()).forEach((intent) => { + // Skip intents that opt out + if (intent.optOut) return; + // Skip intents not in this context + if (intent.contextId !== currentContextId) return; + // Skip intents without action config + if (!intent.action) return; + + // Check if element is within this intent's element + const intentElement = intent.elementRef.current; + if (!intentElement || !intentElement.contains(element)) return; + + // Build action from intent + const action: AcAction = { + intent: intent.intent, + label: intent.action.label ?? intent.intent, + priority: intent.action.priority ?? DEFAULT_PRIORITY, + ...intent.action, + }; + allActions.push(action); + }); + + current = current.parentId ? contexts.get(current.parentId) : undefined; + } + + // Sort by priority (highest first) + return allActions.sort((a, b) => { + const priorityA = a.priority ?? DEFAULT_PRIORITY; + const priorityB = b.priority ?? DEFAULT_PRIORITY; + return priorityB - priorityA; + }); + }, +})); + +/** + * Hook to get the current context for a component. + * Used internally by Ac.Intent and Ac.View for auto-registration. + */ +export function useNearestContext( + elementRef: React.RefObject, +): RegisteredContext | null { + const findContextForElement = useIntentStore( + (state) => state.findContextForElement, + ); + + const element = elementRef.current; + if (!element) return null; + + return findContextForElement(element); +} diff --git a/apps/web/src/components/tracking/types.ts b/apps/web/src/components/tracking/types.ts new file mode 100644 index 0000000..a4c2707 --- /dev/null +++ b/apps/web/src/components/tracking/types.ts @@ -0,0 +1,234 @@ +"use client"; + +/** + * Type definitions for Ac tracking components. + * + * @module components/tracking/types + */ +import type { ReactNode, RefObject } from "react"; + +// ============================================================================ +// Action Types +// ============================================================================ + +/** + * Context passed to action handlers when triggered from the context menu. + */ +export interface AcActionContext { + /** The intent identifier */ + intent: string; + /** Merged metadata from context hierarchy */ + metadata: Record; + /** Target element that was right-clicked */ + targetElement: Element | null; + /** Container element identified by anyclick */ + containerElement: Element | null; + /** Close the context menu */ + closeMenu: () => void; + /** Fire the intent tracking event */ + track: () => void; + /** Capture screenshots (returns promise with screenshot data) */ + captureScreenshots: () => Promise; + /** Submit to anyclick adapter */ + submit: (comment?: string) => Promise; +} + +/** + * Action definition for context menu items. + * + * Actions can be defined on Ac.Context or derived from Ac.Intent components. + */ +export interface AcAction { + /** Unique intent identifier */ + intent: string; + /** Display label in context menu */ + label: string; + /** Optional icon */ + icon?: ReactNode; + /** Priority for sorting (1-10, where 10 = highest priority, default: 5) */ + priority?: number; + /** Show comment input when selected */ + showComment?: boolean; + /** + * Custom handler for this action. + * Return false to skip default submission flow. + */ + handler?: ( + context: AcActionContext, + ) => boolean | Promise | void; + /** Action status (available, comingSoon) */ + status?: "available" | "comingSoon"; + /** Required roles to see this action */ + requiredRoles?: string[]; +} + +// ============================================================================ +// Registration Types +// ============================================================================ + +/** + * Event types that can trigger intent tracking. + */ +export type IntentEventType = "click" | "hover" | "view"; + +/** + * Registered intent in the store. + * + * Created when Ac.Intent or Ac.View mounts (unless context={false}). + */ +export interface RegisteredIntent { + /** Unique ID for this registration */ + id: string; + /** The intent identifier */ + intent: string; + /** Context ID this intent belongs to */ + contextId: string | null; + /** Metadata from this intent (merged with context metadata) */ + metadata: Record; + /** Reference to the DOM element */ + elementRef: RefObject; + /** Whether this intent opts out of context features */ + optOut: boolean; + /** Event type (click, hover, view) */ + eventType: IntentEventType; + /** Action config if this should appear in context menu */ + action?: Partial; +} + +/** + * Registered context in the store. + * + * Created when Ac.Context mounts. + */ +export interface RegisteredContext { + /** Unique ID for this context */ + id: string; + /** Optional name (maps to data-ac-context attribute) */ + name?: string; + /** Metadata for this context */ + metadata: Record; + /** Explicit actions defined on this context */ + actions: AcAction[]; + /** Reference to the DOM element */ + elementRef: RefObject; + /** Parent context ID (for hierarchy) */ + parentId: string | null; + /** Depth in context tree (0 = root) */ + depth: number; +} + +// ============================================================================ +// Store Types +// ============================================================================ + +/** + * Intent store state and actions (Zustand). + */ +export interface IntentStoreState { + /** Map of contextId -> registered context */ + contexts: Map; + /** Map of intentId -> registered intent */ + intents: Map; +} + +export interface IntentStoreActions { + // Context registration + registerContext: (context: RegisteredContext) => void; + unregisterContext: (contextId: string) => void; + updateContext: ( + contextId: string, + updates: Partial, + ) => void; + + // Intent registration + registerIntent: (intent: RegisteredIntent) => void; + unregisterIntent: (intentId: string) => void; + + // Queries + /** Get all actions for an element (walks up context tree, sorted by priority) */ + getActionsForElement: (element: Element) => AcAction[]; + /** Get merged metadata for an element */ + getMetadataForElement: (element: Element) => Record; + /** Find nearest context for an element */ + findContextForElement: (element: Element) => RegisteredContext | null; + /** Find parent context for a given element */ + findParentContext: ( + element: Element | null, + excludeId?: string, + ) => RegisteredContext | null; +} + +export type IntentStore = IntentStoreState & IntentStoreActions; + +// ============================================================================ +// Component Prop Types +// ============================================================================ + +/** + * Props for Ac.Context component. + */ +export interface AcContextProps { + /** Metadata to provide to nested components */ + metadata: Record; + /** Optional context name (sets data-ac-context attribute) */ + name?: string; + /** Explicit actions for this context's menu */ + actions?: AcAction[]; + /** Disable anyclick in this context */ + disabled?: boolean; + /** Whether to render as a wrapper div or use Slot pattern */ + asChild?: boolean; + /** Children to render */ + children: ReactNode; + /** Optional className for wrapper element */ + className?: string; +} + +/** + * Props for Ac.Intent component. + */ +export interface AcIntentProps { + /** The intent identifier to track */ + intent: string; + /** Event type to track (default: "click") */ + on?: "click" | "hover" | "both"; + /** Additional properties to include with tracking */ + metadata?: Record; + /** + * Whether to register with context for menu actions. + * When false, events still pass through but consumers ignore this intent. + * @default true + */ + context?: boolean; + /** Action config when shown in context menu */ + action?: Partial; + /** Disable tracking */ + disabled?: boolean; + /** Single child element */ + children: React.ReactElement; +} + +/** + * Props for Ac.View component. + */ +export interface AcViewProps { + /** Intent to fire when element becomes visible */ + intent: string; + /** IntersectionObserver threshold (0-1, default: 0.5) */ + threshold?: number; + /** Fire only once per mount (default: true) */ + once?: boolean; + /** Minimum time visible in ms before firing */ + minVisibleTime?: number; + /** Additional properties to include with tracking */ + metadata?: Record; + /** + * Whether to register with context for menu actions. + * @default true + */ + context?: boolean; + /** Disable tracking */ + disabled?: boolean; + /** Single child element */ + children: React.ReactElement; +} diff --git a/apps/web/src/lib/intents/homepage.ts b/apps/web/src/lib/intents/homepage.ts new file mode 100644 index 0000000..260617e --- /dev/null +++ b/apps/web/src/lib/intents/homepage.ts @@ -0,0 +1,85 @@ +/** + * Homepage-specific intents for the Anyclick web app. + * + * These intents follow the protocol convention: `web..
.` + * 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/packages/anyclick-pointer/src/CustomPointer.tsx b/packages/anyclick-pointer/src/CustomPointer.tsx index a4a9d1b..3cabe89 100644 --- a/packages/anyclick-pointer/src/CustomPointer.tsx +++ b/packages/anyclick-pointer/src/CustomPointer.tsx @@ -104,7 +104,7 @@ export function CustomPointer({ enabled = true, onInteractionChange, }: CustomPointerProps) { - console.count("CustomPointer RENDER"); + // console.count("CustomPointer RENDER"); const mergedTheme = useMemo(() => mergeTheme(theme), [theme]); const mergedConfig = useMemo(() => mergeConfig(config), [config]); 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"