diff --git a/README.md b/README.md index 6b9a544..e0730f3 100644 --- a/README.md +++ b/README.md @@ -1,422 +1,440 @@ -# ZoriHQ Tracking Script +# ZoriHQ Analytics -A lightweight analytics tracking script with comprehensive browser fingerprinting and automatic event tracking. +A lightweight, privacy-focused analytics library with scroll heatmaps, session recording, and multi-framework support. ## Features -- 🔍 **Advanced Browser Fingerprinting** - Canvas, WebGL, Audio Context, and hardware fingerprinting -- 📊 **Automatic Event Tracking** - Page views, clicks, visibility changes, and page unloads -- 🆔 **Persistent Visitor Identification** - Cookie-based visitor ID with 2-year expiry -- 🎯 **UTM Parameter Tracking** - Automatic capture of campaign parameters -- 🌐 **Comprehensive Metadata** - User agent, referrer, page URL, and host tracking -- 📍 **Enhanced Click Tracking** - Detects buttons, links, element text, and heatmap data (with screen dimensions) -- 🔌 **JavaScript API** - Custom event tracking and user identification -- 📱 **Cross-Platform Support** - Works on mobile and desktop browsers -- 🔐 **GDPR Compliant** - Built-in consent management, DNT support, and opt-out functionality -- ⏱️ **Session Tracking** - Automatic session management with 30-minute timeout -- 📦 **Event Queue** - Track events before script loads (like gtag.js) -- 🎨 **Heatmap Ready** - Click positions normalized by screen size +- **Automatic Event Tracking** - Page views, clicks, visibility changes, sessions +- **Scroll Depth Tracking** - Normalized scroll positions for heatmap generation +- **Session Recording** - Optional RRWeb integration for session replays +- **Click Heatmaps** - Click positions normalized by screen size +- **Browser Fingerprinting** - Canvas, WebGL, Audio Context fingerprinting +- **Persistent Visitor ID** - Cookie-based identification with 2-year expiry +- **UTM Parameter Tracking** - Automatic campaign attribution +- **Session Management** - 30-minute timeout with automatic restart +- **GDPR Compliant** - Consent management, DNT support, opt-out +- **Framework SDKs** - React, Next.js, Vue, Svelte integrations +- **Event Queue** - Track events before script loads -## Installation - -### Recommended: Async Loading with Event Queue - -For optimal performance, initialize the queue before loading the script: +## Quick Start ```html - + +``` - - +## Framework SDKs - - ``` -### Basic Installation +### Svelte -```html - +```bash +npm install @zorihq/svelte ``` -### Optional Configuration +```svelte + +``` -You can customize the ingestion endpoint and visibility tracking behavior: +--- + +## Configuration + +### Script Tag Attributes ```html ``` -**Configuration Options:** - -- `data-base-url` - Custom ingestion endpoint (default: `https://ingestion.zorihq.com/ingest`) -- `data-comeback-threshold` - Minimum hidden duration (in milliseconds) to trigger `user_comeback` event (default: `30000` / 30 seconds) -- `data-track-quick-switches` - Set to `"true"` to track all visibility changes like the old behavior (default: `"false"`) - -### CDN URLs +| Attribute | Default | Description | +|-----------|---------|-------------| +| `data-key` | *required* | Your ZoriHQ publishable key | +| `data-base-url` | `https://ingestion.zorihq.com/ingest` | Custom ingestion endpoint | +| `data-comeback-threshold` | `30000` | Min hidden duration (ms) to trigger `user_comeback` | +| `data-track-quick-switches` | `false` | Track all visibility changes | +| `data-track-scroll-depth` | `true` | Enable scroll depth tracking | +| `data-scroll-throttle-ms` | `250` | Throttle interval for scroll events | +| `data-scroll-depth-intervals` | `[25, 50, 75, 90, 100]` | Milestone percentages to track | +| `data-enable-session-recording` | `false` | Auto-start session recording on load | +| `data-rrweb-cdn-url` | jsDelivr CDN | Custom RRWeb script URL | -- **Latest stable**: `https://cdn.zorihq.com/script.min.js` -- **Specific version**: `https://cdn.zorihq.com/v1.0.6/script.min.js` -- **Development/Latest**: `https://cdn.zorihq.com/latest/script.min.js` +--- ## JavaScript API -Once loaded, the script exposes a global `window.ZoriHQ` object for custom tracking: - -### Track Custom Events +### Track Events ```javascript -// Direct API (after script loads) -window.ZoriHQ.track('button_clicked', { - button_name: 'Sign Up', - page: 'homepage' -}); +// Track custom event +window.ZoriHQ.track('button_clicked', { button_name: 'Sign Up' }); // Queue method (works before script loads) -window.ZoriHQ.push(['track', 'purchase_completed', { - product_id: 'prod_123', - amount: 99.99 -}]); +window.ZoriHQ.push(['track', 'purchase', { amount: 99.99 }]); ``` ### Identify Users -Link visitor cookies to your app users: - ```javascript -// Direct API window.ZoriHQ.identify({ - app_id: 'user_123', // Your app's user ID - email: 'user@example.com', // User email - fullname: 'John Doe', // Full name - plan: 'premium', // Additional properties - signup_date: '2025-01-15' -}); - -// Queue method -window.ZoriHQ.push(['identify', { app_id: 'user_123', email: 'user@example.com', - fullname: 'John Doe' -}]); -``` - -### Consent Management (GDPR) - -```javascript -// Grant consent -window.ZoriHQ.setConsent({ - analytics: true, // Allow analytics - marketing: false // Deny marketing + fullname: 'John Doe', + plan: 'premium' }); - -// Check consent status -const hasConsent = window.ZoriHQ.hasConsent(); - -// Opt out completely (GDPR right to be forgotten) -window.ZoriHQ.optOut(); - -// Queue method -window.ZoriHQ.push(['setConsent', { analytics: true }]); -window.ZoriHQ.push(['optOut']); ``` ### Get IDs ```javascript -// Get visitor ID const visitorId = await window.ZoriHQ.getVisitorId(); - -// Get current session ID const sessionId = window.ZoriHQ.getSessionId(); - -// Queue method with callback -window.ZoriHQ.push(['getVisitorId', function(id) { - console.log('Visitor ID:', id); -}]); ``` -## Automatic Event Tracking - -The script automatically tracks: - -- **page_view** - On initial load with page title, path, search params, and hash -- **click** - Every click with enhanced element detection: - - Element type (button, link, input, or clickable) - - CSS selector (optimized, not 10 levels deep) - - Element text content - - Link destination (for links) - - Click coordinates (x, y) - - Screen dimensions (for heatmap normalization) - - Data attributes -- **session_start** - When a new session begins -- **session_end** - When session expires (includes duration and page count) -- **user_comeback** - When user returns after being away for significant time (>30 seconds by default) - - Includes `hidden_duration_ms` and `hidden_duration_seconds` - - Reduces event bloat by ignoring quick tab switches -- **left_while_hidden** - When user closes/navigates away while page was hidden - - Includes `hidden_duration_ms` and `hidden_duration_seconds` - - Helps identify background tab closures vs. active exits - -All events include: -- Unique visitor ID (persisted for 2 years) -- Session ID (30-minute timeout) -- UTM parameters (if present in URL) -- Referrer -- User agent -- Page URL and host -- Timestamp (UTC) - -### Session Tracking +### Consent Management -Sessions automatically restart when: -- 30 minutes of inactivity pass -- User arrives with different UTM parameters (new campaign) -- Browser session ends +```javascript +// Set consent +window.ZoriHQ.setConsent({ analytics: true, marketing: false }); -Session data includes: -- Session duration (milliseconds) -- Page count (pages viewed in session) -- Campaign attribution (preserved from session start) +// Check consent +const hasConsent = window.ZoriHQ.hasConsent(); -## Browser Fingerprinting +// Opt out completely (deletes all data) +window.ZoriHQ.optOut(); +``` -On first visit, the script generates a comprehensive fingerprint including: +--- -- Screen resolution, color depth, orientation -- Browser properties (user agent, platform, languages, timezone) -- Hardware info (CPU cores, memory, touch points) -- Canvas and WebGL fingerprints -- Audio context fingerprint -- Available media devices -- Network connection info -- Battery status (if available) +## Scroll Depth Tracking -The fingerprint is stored in localStorage and used to help identify returning visitors even if cookies are cleared. +Scroll tracking is enabled by default and provides normalized scroll data for building heatmaps. -## GDPR Compliance +### Events Tracked -### Consent Management +| Event | Trigger | Data | +|-------|---------|------| +| `scroll_depth_milestone` | When user scrolls past 25%, 50%, 75%, 90%, 100% | Milestone %, scroll metrics | +| `scroll_depth_final` | On page unload | Max depth, all milestones reached | -The script respects user privacy and includes built-in GDPR compliance: +### Scroll Metrics -```html - +```javascript +{ + scroll_depth_percent: 75, // Current depth (0-100) + document_height: 3000, // Total document height (px) + viewport_height: 800, // Viewport height (px) + scroll_top: 1650, // Scroll position from top (px) + visible_top_percent: 55, // Top of viewport as % of document + visible_bottom_percent: 81.67, // Bottom of viewport as % of document + screen_width: 1920, // Screen width (px) + screen_height: 1080 // Screen height (px) +} ``` -### Do Not Track (DNT) - -The script automatically respects the browser's Do Not Track header by default. If DNT is enabled, no tracking occurs. - -### Right to be Forgotten - -Users can completely opt out and delete all their data: +### API ```javascript -window.ZoriHQ.optOut(); -// Deletes: cookies, localStorage, blocks future tracking +// Get current scroll heatmap data +const data = window.ZoriHQ.getScrollData(); + +// Returns: +{ + max_depth_percent: 75, + milestones_reached: [25, 50, 75], + snapshots: [...], // Array of scroll snapshots + current_metrics: { ... } +} ``` -### Cookies Used - -| Cookie | Purpose | Expiry | Required | -|--------|---------|--------|----------| -| `zori_visitor_id` | Anonymous visitor tracking | 2 years | Yes (with consent) | -| `zori_session_id` | Session tracking | Browser close | Yes (with consent) | -| `zori_consent` | Consent preferences | 2 years | Always | +--- -### Data Stored +## Session Recording -- **Cookies**: Visitor ID, session ID, consent preferences -- **localStorage**: Browser fingerprint, session data, identified user info -- **Server**: Events, timestamps, page URLs, user agents +Session recording uses [RRWeb](https://www.rrweb.io/) to capture DOM mutations for session replay. -All data can be deleted via `optOut()` method. - -## Development +### Enable Recording -### Prerequisites - -- Node.js 23+ -- pnpm 10+ - -### Setup +**Option 1: Auto-start via config** +```html + +``` -```bash -pnpm install +**Option 2: Start programmatically** +```javascript +await window.ZoriHQ.startRecording(); +window.ZoriHQ.stopRecording(); ``` -### Build Process +**Option 3: With custom options** +```javascript +await window.ZoriHQ.startRecording({ + maskAllInputs: true, + blockClass: 'zori-block', + ignoreClass: 'zori-ignore', + maskTextClass: 'zori-mask' +}); +``` -```bash -# Build minified version -pnpm run build +### Recording Status -# Or run minification directly -pnpm run minify +```javascript +const status = window.ZoriHQ.getRecordingStatus(); +// { isRecording: true, eventCount: 42, rrwebLoaded: true } ``` -The build creates `dist/script.min.js` (~6.6KB minified). +### Privacy CSS Classes -## Release Process +Use these CSS classes to control what gets recorded: -This project uses semantic versioning with automated releases via GitHub Actions. +| Class | Effect | Use Case | +|-------|--------|----------| +| `zori-block` | Element is replaced with placeholder | Sensitive sections, iframes, videos | +| `zori-ignore` | Input values not captured | Password fields, credit cards | +| `zori-mask` | Text content is masked with `*` | Personal info, emails in UI | -### Commit Message Convention +**Example:** +```html + +
+ +
-We follow the [Conventional Commits](https://www.conventionalcommits.org/) specification: + + -#### Format + +

user@email.com

``` -[optional scope]: -[optional body] +### Default Privacy Settings -[optional footer(s)] -``` +Session recording has privacy-focused defaults: +- All input values are masked by default +- Password and email inputs are always masked +- Recording respects consent settings and DNT headers -#### Types -- **feat**: A new feature (triggers minor release) -- **fix**: A bug fix (triggers patch release) -- **perf**: Performance improvements (triggers patch release) -- **docs**: Documentation changes (triggers patch release) -- **refactor**: Code refactoring (triggers patch release) -- **test**: Adding or updating tests (no release) -- **build**: Build system changes (no release) -- **ci**: CI/CD changes (no release) -- **chore**: Maintenance tasks (no release) - -#### Breaking Changes -Add `BREAKING CHANGE:` in the footer or `!` after type for major releases: -``` -feat!: remove support for legacy browsers +--- -BREAKING CHANGE: This version drops support for Internet Explorer -``` +## Automatic Events -#### Examples -```bash -# Feature (minor release) -git commit -m "feat: add click heatmap tracking" +### Events Tracked -# Bug fix (patch release) -git commit -m "fix: resolve cookie domain issues" +| Event | Trigger | Data Included | +|-------|---------|---------------| +| `page_view` | Page load | Title, path, search, hash | +| `click` | Any click | Element info, position, screen size | +| `session_start` | New session | UTM params, referrer | +| `session_end` | Session timeout/close | Duration, page count | +| `user_comeback` | Return after >30s hidden | Hidden duration | +| `left_while_hidden` | Close while tab hidden | Hidden duration | +| `scroll_depth_milestone` | Scroll milestone reached | Scroll metrics | +| `scroll_depth_final` | Page unload | Max depth, milestones | +| `session_recording_started` | Recording begins | Recording options | +| `session_recording_stopped` | Recording ends | - | -# Performance improvement (patch release) -git commit -m "perf: optimize fingerprint generation" +### Click Event Data -# Breaking change (major release) -git commit -m "feat!: redesign tracking API" +```javascript +{ + click_element: { + tag: 'button', + type: 'button', + text: 'Sign Up', + selector: '#signup-btn', + data_attributes: { 'data-action': 'signup' } + }, + click_position: { + x: 450, + y: 320, + screen_width: 1920, + screen_height: 1080 + } +} +``` -# Documentation (patch release) -git commit -m "docs: update installation instructions" +### Session Management -# No release -git commit -m "test: add unit tests for cookie handling" -git commit -m "ci: update GitHub Actions workflow" -git commit -m "chore: update dependencies" -``` +Sessions automatically restart when: +- 30 minutes of inactivity +- New UTM parameters detected +- Browser session ends -### Release Workflow +--- -1. **Development**: Make changes and commit using conventional commit messages -2. **Push to main**: Push directly or merge PR to `main` branch -3. **Automatic Release**: When code is pushed to `main`, semantic-release automatically: - - Analyzes commits to determine version bump - - Generates changelog - - Updates version in package.json - - Creates git tag - - Creates GitHub release with build artifacts -4. **Automatic CDN Deployment**: When a git tag is created, the deploy-cdn workflow automatically: - - Builds the minified script - - Deploys to Cloudflare R2 at three URLs (versioned, latest, root) - - Verifies deployment success +## Browser Fingerprinting -## Build Artifacts +On first visit, generates a comprehensive fingerprint: -The build process creates: +- Screen: resolution, color depth, orientation +- Browser: user agent, platform, languages, timezone +- Hardware: CPU cores, memory, touch points +- Canvas & WebGL fingerprints +- Audio context fingerprint +- Media devices count +- Network connection info +- Battery status -- `dist/script.min.js` - Minified version (~6.6KB) -- `dist/script.obfuscated.js` - Obfuscated version (~45KB) - only for special builds +Fingerprint is stored in localStorage to identify returning visitors. -The minified version is what gets deployed to the CDN. +--- -## CDN Deployment +## GDPR Compliance -### Automated Deployment +### Do Not Track (DNT) -The script is automatically deployed to Cloudflare R2 when a git tag (e.g., `v1.0.6`) is pushed or a GitHub release is published: +Automatically respects browser DNT header. No tracking occurs if enabled. -- **Versioned URL**: `https://cdn.zorihq.com/v{version}/script.min.js` - Immutable, cache forever -- **Latest URL**: `https://cdn.zorihq.com/latest/script.min.js` - Always points to newest version -- **Root URL**: `https://cdn.zorihq.com/script.min.js` - Always points to newest version +### Cookies Used -### Manual Deployment +| Cookie | Purpose | Expiry | +|--------|---------|--------| +| `zori_visitor_id` | Anonymous visitor tracking | 2 years | +| `zori_session_id` | Session tracking | Browser close | +| `zori_consent` | Consent preferences | 2 years | -To deploy a specific version manually: +### Right to be Forgotten -```bash -gh workflow run deploy-cdn.yml --ref main -f version=v1.2.3 +```javascript +window.ZoriHQ.optOut(); +// Deletes all cookies, localStorage data, blocks future tracking ``` -Or via GitHub UI: Actions → Deploy to CDN → Run workflow → Enter version +--- -## GitHub Secrets Required +## CDN URLs -For automated releases and CDN deployment, configure these secrets in your GitHub repository: +| URL | Description | +|-----|-------------| +| `https://cdn.zorihq.com/script.min.js` | Latest stable | +| `https://cdn.zorihq.com/latest/script.min.js` | Latest stable | +| `https://cdn.zorihq.com/v1.3.0/script.min.js` | Specific version | -### Release Secrets -- `GH_PAT` - Personal Access Token with repo permissions (optional, allows release to trigger deploy) -- `GITHUB_TOKEN` - Automatically provided by GitHub (fallback if PAT not set) +--- -### CDN Deployment Secrets -- `CLOUDFLARE_API_TOKEN` - Cloudflare API token with R2 read/write permissions -- `CLOUDFLARE_ACCOUNT_ID` - Your Cloudflare account ID -- `CLOUDFLARE_R2_BUCKET_NAME` - Your R2 bucket name (e.g., `zorihq-tracking-script`) -- `CLOUDFLARE_R2_CUSTOM_DOMAIN` - Your custom domain (e.g., `cdn.zorihq.com`) +## Development -### Setting Up GitHub PAT (Recommended) +### Prerequisites -To enable automatic CDN deployment when releases are created: +- Node.js 23+ +- pnpm 10+ -1. Go to https://github.com/settings/tokens?type=beta -2. Generate new fine-grained token -3. Set repository access to your tracking script repo -4. Grant permissions: Contents (read/write), Metadata (read), Pull requests (read/write) -5. Add as `GH_PAT` secret in your repository settings +### Build -Without this, you'll need to manually trigger CDN deployment or rely on the git tag trigger. +```bash +pnpm install +pnpm run build # Creates dist/script.min.js +``` + +### Release -### Setting Up Cloudflare R2 +Uses semantic versioning with conventional commits: + +```bash +feat: new feature # Minor release +fix: bug fix # Patch release +feat!: breaking change # Major release +``` -1. Create R2 bucket in Cloudflare Dashboard -2. Generate API token with R2 read/write permissions -3. (Optional) Configure custom domain for your bucket -4. Add all secrets to GitHub repository settings → Secrets and variables → Actions +--- ## License diff --git a/nextjs/index.tsx b/nextjs/index.tsx index 3528494..03555cc 100644 --- a/nextjs/index.tsx +++ b/nextjs/index.tsx @@ -2,10 +2,10 @@ import React, { createContext, useContext, useEffect, useRef, useState, useCallback } from 'react'; import { usePathname, useSearchParams } from 'next/navigation'; -import type { ZoriConfig, ConsentPreferences, UserInfo, ZoriCoreAPI } from '@zorihq/types'; +import type { ZoriConfig, ConsentPreferences, UserInfo, ZoriCoreAPI, ScrollHeatmapData, RecordingStatus, RecordingOptions } from '@zorihq/types'; // Re-export shared types for convenience -export type { ZoriConfig, ConsentPreferences, UserInfo } from '@zorihq/types'; +export type { ZoriConfig, ConsentPreferences, UserInfo, ScrollHeatmapData, RecordingStatus, RecordingOptions } from '@zorihq/types'; // Next.js-specific context type extending core API export interface ZoriContextType extends ZoriCoreAPI { @@ -57,6 +57,26 @@ export const ZoriProvider: React.FC = ({ script.setAttribute('data-track-quick-switches', config.trackQuickSwitches.toString()); } + if (config.trackScrollDepth !== undefined) { + script.setAttribute('data-track-scroll-depth', config.trackScrollDepth.toString()); + } + + if (config.scrollThrottleMs !== undefined) { + script.setAttribute('data-scroll-throttle-ms', config.scrollThrottleMs.toString()); + } + + if (config.scrollDepthIntervals !== undefined) { + script.setAttribute('data-scroll-depth-intervals', JSON.stringify(config.scrollDepthIntervals)); + } + + if (config.enableSessionRecording !== undefined) { + script.setAttribute('data-enable-session-recording', config.enableSessionRecording.toString()); + } + + if (config.rrwebCdnUrl !== undefined) { + script.setAttribute('data-rrweb-cdn-url', config.rrwebCdnUrl); + } + script.onload = () => { setIsInitialized(true); }; @@ -165,6 +185,44 @@ export const ZoriProvider: React.FC = ({ return zori.hasConsent(); }, []); + const getScrollData = useCallback((): ScrollHeatmapData | null => { + const zori = (window as any).ZoriHQ; + if (!zori || typeof zori.getScrollData !== 'function') return null; + return zori.getScrollData(); + }, []); + + const startRecording = useCallback(async (options?: RecordingOptions): Promise => { + const zori = (window as any).ZoriHQ; + if (!zori) return false; + + if (typeof zori.startRecording === 'function') { + return await zori.startRecording(options); + } else { + zori.push(['startRecording', options]); + return true; + } + }, []); + + const stopRecording = useCallback((): boolean => { + const zori = (window as any).ZoriHQ; + if (!zori) return false; + + if (typeof zori.stopRecording === 'function') { + return zori.stopRecording(); + } else { + zori.push(['stopRecording']); + return true; + } + }, []); + + const getRecordingStatus = useCallback((): RecordingStatus => { + const zori = (window as any).ZoriHQ; + if (!zori || typeof zori.getRecordingStatus !== 'function') { + return { isRecording: false, eventCount: 0, rrwebLoaded: false }; + } + return zori.getRecordingStatus(); + }, []); + const contextValue: ZoriContextType = { isInitialized, track, @@ -174,6 +232,10 @@ export const ZoriProvider: React.FC = ({ setConsent, optOut, hasConsent, + getScrollData, + startRecording, + stopRecording, + getRecordingStatus, }; return {children}; @@ -215,6 +277,69 @@ export const useIdentify = (userInfo: UserInfo | null) => { }, [isInitialized, userInfo, identify]); }; +// Hook to get scroll heatmap data +export const useScrollData = () => { + const { getScrollData, isInitialized } = useZori(); + const [scrollData, setScrollData] = useState(null); + + useEffect(() => { + if (!isInitialized) return; + + // Get initial data + setScrollData(getScrollData()); + + // Update periodically + const interval = setInterval(() => { + setScrollData(getScrollData()); + }, 1000); + + return () => clearInterval(interval); + }, [isInitialized, getScrollData]); + + return scrollData; +}; + +// Hook for session recording +export const useSessionRecording = () => { + const { startRecording, stopRecording, getRecordingStatus, isInitialized } = useZori(); + const [status, setStatus] = useState({ + isRecording: false, + eventCount: 0, + rrwebLoaded: false, + }); + + useEffect(() => { + if (!isInitialized) return; + + // Update status periodically while recording + const interval = setInterval(() => { + setStatus(getRecordingStatus()); + }, 1000); + + return () => clearInterval(interval); + }, [isInitialized, getRecordingStatus]); + + const start = useCallback(async (options?: RecordingOptions) => { + const result = await startRecording(options); + if (result) { + setStatus(getRecordingStatus()); + } + return result; + }, [startRecording, getRecordingStatus]); + + const stop = useCallback(() => { + const result = stopRecording(); + setStatus(getRecordingStatus()); + return result; + }, [stopRecording, getRecordingStatus]); + + return { + ...status, + start, + stop, + }; +}; + // Component to track clicks export interface TrackClickProps { eventName?: string; @@ -255,5 +380,7 @@ export default { useZori, useTrackEvent, useIdentify, + useScrollData, + useSessionRecording, TrackClick, }; diff --git a/react/index.tsx b/react/index.tsx index 960cc32..7af30fb 100644 --- a/react/index.tsx +++ b/react/index.tsx @@ -1,8 +1,8 @@ import React, { createContext, useContext, useEffect, useRef, useState, useCallback } from 'react'; -import type { ZoriConfig, ConsentPreferences, UserInfo, ZoriCoreAPI } from '@zorihq/types'; +import type { ZoriConfig, ConsentPreferences, UserInfo, ZoriCoreAPI, ScrollHeatmapData, RecordingStatus, RecordingOptions } from '@zorihq/types'; // Re-export shared types for convenience -export type { ZoriConfig, ConsentPreferences, UserInfo } from '@zorihq/types'; +export type { ZoriConfig, ConsentPreferences, UserInfo, ScrollHeatmapData, RecordingStatus, RecordingOptions } from '@zorihq/types'; // React-specific context type extending core API export interface ZoriContextType extends ZoriCoreAPI { @@ -52,6 +52,26 @@ export const ZoriProvider: React.FC = ({ script.setAttribute('data-track-quick-switches', config.trackQuickSwitches.toString()); } + if (config.trackScrollDepth !== undefined) { + script.setAttribute('data-track-scroll-depth', config.trackScrollDepth.toString()); + } + + if (config.scrollThrottleMs !== undefined) { + script.setAttribute('data-scroll-throttle-ms', config.scrollThrottleMs.toString()); + } + + if (config.scrollDepthIntervals !== undefined) { + script.setAttribute('data-scroll-depth-intervals', JSON.stringify(config.scrollDepthIntervals)); + } + + if (config.enableSessionRecording !== undefined) { + script.setAttribute('data-enable-session-recording', config.enableSessionRecording.toString()); + } + + if (config.rrwebCdnUrl !== undefined) { + script.setAttribute('data-rrweb-cdn-url', config.rrwebCdnUrl); + } + script.onload = () => { setIsInitialized(true); }; @@ -139,6 +159,44 @@ export const ZoriProvider: React.FC = ({ return zori.hasConsent(); }, []); + const getScrollData = useCallback((): ScrollHeatmapData | null => { + const zori = (window as any).ZoriHQ; + if (!zori || typeof zori.getScrollData !== 'function') return null; + return zori.getScrollData(); + }, []); + + const startRecording = useCallback(async (options?: RecordingOptions): Promise => { + const zori = (window as any).ZoriHQ; + if (!zori) return false; + + if (typeof zori.startRecording === 'function') { + return await zori.startRecording(options); + } else { + zori.push(['startRecording', options]); + return true; + } + }, []); + + const stopRecording = useCallback((): boolean => { + const zori = (window as any).ZoriHQ; + if (!zori) return false; + + if (typeof zori.stopRecording === 'function') { + return zori.stopRecording(); + } else { + zori.push(['stopRecording']); + return true; + } + }, []); + + const getRecordingStatus = useCallback((): RecordingStatus => { + const zori = (window as any).ZoriHQ; + if (!zori || typeof zori.getRecordingStatus !== 'function') { + return { isRecording: false, eventCount: 0, rrwebLoaded: false }; + } + return zori.getRecordingStatus(); + }, []); + const contextValue: ZoriContextType = { isInitialized, track, @@ -148,6 +206,10 @@ export const ZoriProvider: React.FC = ({ setConsent, optOut, hasConsent, + getScrollData, + startRecording, + stopRecording, + getRecordingStatus, }; return {children}; @@ -206,6 +268,69 @@ export const useIdentify = (userInfo: UserInfo | null) => { }, [isInitialized, userInfo, identify]); }; +// Hook to get scroll heatmap data +export const useScrollData = () => { + const { getScrollData, isInitialized } = useZori(); + const [scrollData, setScrollData] = useState(null); + + useEffect(() => { + if (!isInitialized) return; + + // Get initial data + setScrollData(getScrollData()); + + // Update periodically + const interval = setInterval(() => { + setScrollData(getScrollData()); + }, 1000); + + return () => clearInterval(interval); + }, [isInitialized, getScrollData]); + + return scrollData; +}; + +// Hook for session recording +export const useSessionRecording = () => { + const { startRecording, stopRecording, getRecordingStatus, isInitialized } = useZori(); + const [status, setStatus] = useState({ + isRecording: false, + eventCount: 0, + rrwebLoaded: false, + }); + + useEffect(() => { + if (!isInitialized) return; + + // Update status periodically while recording + const interval = setInterval(() => { + setStatus(getRecordingStatus()); + }, 1000); + + return () => clearInterval(interval); + }, [isInitialized, getRecordingStatus]); + + const start = useCallback(async (options?: RecordingOptions) => { + const result = await startRecording(options); + if (result) { + setStatus(getRecordingStatus()); + } + return result; + }, [startRecording, getRecordingStatus]); + + const stop = useCallback(() => { + const result = stopRecording(); + setStatus(getRecordingStatus()); + return result; + }, [stopRecording, getRecordingStatus]); + + return { + ...status, + start, + stop, + }; +}; + // Component to track clicks export interface TrackClickProps { eventName?: string; @@ -247,5 +372,7 @@ export default { usePageView, useTrackEvent, useIdentify, + useScrollData, + useSessionRecording, TrackClick, }; diff --git a/script.js b/script.js index 6549ce3..f22c535 100644 --- a/script.js +++ b/script.js @@ -10,6 +10,11 @@ const DEFAULT_API_URL = "https://ingestion.zorihq.com/ingest"; const DEFAULT_COMEBACK_THRESHOLD_MS = 30 * 1000; // 30 seconds const DEFAULT_TRACK_QUICK_SWITCHES = false; + const DEFAULT_SCROLL_TRACKING = true; + const DEFAULT_SCROLL_THROTTLE_MS = 250; + const DEFAULT_SCROLL_DEPTH_INTERVALS = [25, 50, 75, 90, 100]; + const DEFAULT_SESSION_RECORDING = false; + const RRWEB_CDN_URL = "https://cdn.jsdelivr.net/npm/rrweb@latest/dist/rrweb.min.js"; let consentState = { analytics: null, // null = not set, true = granted, false = denied @@ -30,6 +35,30 @@ trackQuickSwitches: scriptTag?.getAttribute("data-track-quick-switches") === "true" || DEFAULT_TRACK_QUICK_SWITCHES, + // Scroll tracking configuration + trackScrollDepth: + scriptTag?.getAttribute("data-track-scroll-depth") !== "false" && + DEFAULT_SCROLL_TRACKING, + scrollThrottleMs: + parseInt(scriptTag?.getAttribute("data-scroll-throttle-ms")) || + DEFAULT_SCROLL_THROTTLE_MS, + scrollDepthIntervals: (() => { + const attr = scriptTag?.getAttribute("data-scroll-depth-intervals"); + if (attr) { + try { + return JSON.parse(attr); + } catch (e) { + return DEFAULT_SCROLL_DEPTH_INTERVALS; + } + } + return DEFAULT_SCROLL_DEPTH_INTERVALS; + })(), + // Session recording configuration + enableSessionRecording: + scriptTag?.getAttribute("data-enable-session-recording") === "true" || + DEFAULT_SESSION_RECORDING, + rrwebCdnUrl: + scriptTag?.getAttribute("data-rrweb-cdn-url") || RRWEB_CDN_URL, }; if (!config.publishableKey) { @@ -39,6 +68,22 @@ let pageHiddenAt = null; + // Scroll tracking state + let scrollState = { + maxDepthPercent: 0, + reachedIntervals: new Set(), + lastScrollTime: 0, + scrollSnapshots: [], + }; + + // Session recording state + let recordingState = { + isRecording: false, + stopFn: null, + events: [], + rrwebLoaded: false, + }; + // ==================== UTILITY FUNCTIONS ==================== function setCookie(name, value, days) { @@ -765,6 +810,293 @@ ); } + // ==================== SCROLL DEPTH TRACKING ==================== + + function getScrollMetrics() { + const scrollTop = window.pageYOffset || document.documentElement.scrollTop; + const viewportHeight = window.innerHeight; + const documentHeight = Math.max( + document.body.scrollHeight, + document.body.offsetHeight, + document.documentElement.clientHeight, + document.documentElement.scrollHeight, + document.documentElement.offsetHeight + ); + + // Calculate scroll depth as percentage of total scrollable content + const maxScroll = documentHeight - viewportHeight; + const scrollPercent = maxScroll > 0 ? (scrollTop / maxScroll) * 100 : 100; + + // Calculate what percentage of the document is currently visible + const visibleTop = (scrollTop / documentHeight) * 100; + const visibleBottom = ((scrollTop + viewportHeight) / documentHeight) * 100; + + return { + // Current scroll position normalized (0-100) + scroll_depth_percent: Math.min(Math.round(scrollPercent), 100), + // Document metrics for heatmap reconstruction + document_height: documentHeight, + viewport_height: viewportHeight, + scroll_top: scrollTop, + // Normalized positions for heatmaps (relative to document) + visible_top_percent: Math.round(visibleTop * 100) / 100, + visible_bottom_percent: Math.round(visibleBottom * 100) / 100, + // Screen dimensions + screen_width: window.innerWidth, + screen_height: window.innerHeight, + }; + } + + function throttle(func, limit) { + let lastFunc; + let lastRan; + return function (...args) { + if (!lastRan) { + func.apply(this, args); + lastRan = Date.now(); + } else { + clearTimeout(lastFunc); + lastFunc = setTimeout( + function () { + if (Date.now() - lastRan >= limit) { + func.apply(this, args); + lastRan = Date.now(); + } + }, + limit - (Date.now() - lastRan) + ); + } + }; + } + + async function handleScrollEvent() { + const metrics = getScrollMetrics(); + const currentDepth = metrics.scroll_depth_percent; + + // Update max depth + if (currentDepth > scrollState.maxDepthPercent) { + scrollState.maxDepthPercent = currentDepth; + } + + // Check for milestone intervals + for (const interval of config.scrollDepthIntervals) { + if (currentDepth >= interval && !scrollState.reachedIntervals.has(interval)) { + scrollState.reachedIntervals.add(interval); + + await trackEvent("scroll_depth_milestone", { + milestone_percent: interval, + ...metrics, + }); + } + } + + // Store snapshot for heatmap data + scrollState.scrollSnapshots.push({ + timestamp: Date.now(), + ...metrics, + }); + + // Keep only last 100 snapshots to prevent memory issues + if (scrollState.scrollSnapshots.length > 100) { + scrollState.scrollSnapshots = scrollState.scrollSnapshots.slice(-100); + } + } + + function setupScrollTracking() { + if (!config.trackScrollDepth) { + return; + } + + const throttledScrollHandler = throttle( + handleScrollEvent, + config.scrollThrottleMs + ); + + window.addEventListener("scroll", throttledScrollHandler, { passive: true }); + + // Track scroll depth on page unload + window.addEventListener("beforeunload", function () { + if (scrollState.maxDepthPercent > 0) { + const metrics = getScrollMetrics(); + + trackEvent("scroll_depth_final", { + max_depth_percent: scrollState.maxDepthPercent, + milestones_reached: Array.from(scrollState.reachedIntervals), + snapshot_count: scrollState.scrollSnapshots.length, + ...metrics, + }); + } + }); + } + + function getScrollHeatmapData() { + return { + max_depth_percent: scrollState.maxDepthPercent, + milestones_reached: Array.from(scrollState.reachedIntervals), + snapshots: scrollState.scrollSnapshots, + current_metrics: getScrollMetrics(), + }; + } + + // ==================== SESSION RECORDING (RRWEB) ==================== + + function loadRRWebScript() { + return new Promise((resolve, reject) => { + if (recordingState.rrwebLoaded || window.rrweb) { + recordingState.rrwebLoaded = true; + resolve(); + return; + } + + const script = document.createElement("script"); + script.src = config.rrwebCdnUrl; + script.async = true; + + script.onload = function () { + recordingState.rrwebLoaded = true; + console.log("[ZoriHQ] RRWeb loaded successfully"); + resolve(); + }; + + script.onerror = function () { + console.error("[ZoriHQ] Failed to load RRWeb script"); + reject(new Error("Failed to load RRWeb")); + }; + + document.head.appendChild(script); + }); + } + + async function startSessionRecording(options = {}) { + if (!hasTrackingConsent()) { + console.log("[ZoriHQ] Session recording blocked: no consent or DNT enabled"); + return false; + } + + if (recordingState.isRecording) { + console.log("[ZoriHQ] Session recording already active"); + return true; + } + + try { + await loadRRWebScript(); + + if (!window.rrweb || !window.rrweb.record) { + console.error("[ZoriHQ] RRWeb not available"); + return false; + } + + const recordingOptions = { + emit: function (event) { + recordingState.events.push(event); + + // Send events in batches (every 50 events or every 10 seconds) + if (recordingState.events.length >= 50) { + flushRecordingEvents(); + } + }, + // Default options - can be overridden + checkoutEveryNms: 10 * 60 * 1000, // Full snapshot every 10 minutes + blockClass: "zori-block", // Block elements with this class + ignoreClass: "zori-ignore", // Ignore input elements with this class + maskTextClass: "zori-mask", // Mask text with this class + maskAllInputs: true, // Mask all input values for privacy + maskInputOptions: { + password: true, + email: true, + }, + ...options, + }; + + recordingState.stopFn = window.rrweb.record(recordingOptions); + recordingState.isRecording = true; + + // Set up periodic flush (every 10 seconds) + recordingState.flushInterval = setInterval(function () { + if (recordingState.events.length > 0) { + flushRecordingEvents(); + } + }, 10000); + + console.log("[ZoriHQ] Session recording started"); + + // Track recording start event + await trackEvent("session_recording_started", { + recording_options: { + maskAllInputs: recordingOptions.maskAllInputs, + }, + }); + + return true; + } catch (error) { + console.error("[ZoriHQ] Failed to start session recording:", error); + return false; + } + } + + function stopSessionRecording() { + if (!recordingState.isRecording) { + return false; + } + + if (recordingState.stopFn) { + recordingState.stopFn(); + } + + if (recordingState.flushInterval) { + clearInterval(recordingState.flushInterval); + } + + // Flush remaining events + if (recordingState.events.length > 0) { + flushRecordingEvents(); + } + + recordingState.isRecording = false; + recordingState.stopFn = null; + + console.log("[ZoriHQ] Session recording stopped"); + + // Track recording stop event + trackEvent("session_recording_stopped"); + + return true; + } + + async function flushRecordingEvents() { + if (recordingState.events.length === 0) { + return; + } + + const eventsToSend = [...recordingState.events]; + recordingState.events = []; + + const visitorId = await getOrCreateVisitorId(); + const sessionId = getOrCreateSession(); + + const payload = { + event_name: "session_recording_events", + client_generated_event_id: generateEventId(), + visitor_id: visitorId, + session_id: sessionId, + client_timestamp_utc: new Date().toISOString(), + page_url: window.location.pathname, + host: window.location.host, + recording_events: eventsToSend, + event_count: eventsToSend.length, + }; + + await sendEvent(payload, "/recording"); + } + + function getRecordingStatus() { + return { + isRecording: recordingState.isRecording, + eventCount: recordingState.events.length, + rrwebLoaded: recordingState.rrwebLoaded, + }; + } + // ==================== PAGE VIEW TRACKING ==================== async function trackPageView() { @@ -813,6 +1145,22 @@ case "optOut": optOut(); break; + case "startRecording": + startSessionRecording(...args); + break; + case "stopRecording": + stopSessionRecording(); + break; + case "getScrollData": + if (typeof args[0] === "function") { + args[0](getScrollHeatmapData()); + } + break; + case "getRecordingStatus": + if (typeof args[0] === "function") { + args[0](getRecordingStatus()); + } + break; default: console.warn(`[ZoriHQ] Unknown method: ${method}`); } @@ -838,6 +1186,12 @@ setConsent: setConsent, optOut: optOut, hasConsent: hasTrackingConsent, + // Scroll tracking (no-op when no consent) + getScrollData: () => null, + // Session recording (no-op when no consent) + startRecording: async () => false, + stopRecording: () => false, + getRecordingStatus: () => ({ isRecording: false, eventCount: 0, rrwebLoaded: false }), push: function (command) { if (Array.isArray(command)) { processQueuedCommands([command]); @@ -858,6 +1212,13 @@ setupClickTracking(); + setupScrollTracking(); + + // Auto-start session recording if enabled + if (config.enableSessionRecording) { + startSessionRecording(); + } + document.addEventListener("visibilitychange", function () { if (document.visibilityState === "hidden") { pageHiddenAt = Date.now(); @@ -910,6 +1271,12 @@ const session = getSession(); return session?.session_id || null; }, + // Scroll tracking API + getScrollData: getScrollHeatmapData, + // Session recording API + startRecording: startSessionRecording, + stopRecording: stopSessionRecording, + getRecordingStatus: getRecordingStatus, push: function (command) { if (Array.isArray(command)) { processQueuedCommands([command]); diff --git a/shared/types.ts b/shared/types.ts index f640cd0..32bd048 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -19,6 +19,16 @@ export interface ZoriConfig { comebackThreshold?: number; /** Whether to track quick tab switches (optional) */ trackQuickSwitches?: boolean; + /** Whether to track scroll depth (default: true) */ + trackScrollDepth?: boolean; + /** Throttle interval for scroll events in ms (default: 250) */ + scrollThrottleMs?: number; + /** Scroll depth milestones to track (default: [25, 50, 75, 90, 100]) */ + scrollDepthIntervals?: number[]; + /** Whether to enable session recording (default: false) */ + enableSessionRecording?: boolean; + /** Custom CDN URL for RRWeb script */ + rrwebCdnUrl?: string; } /** @@ -62,6 +72,92 @@ export interface UserInfo { [key: string]: any; } +// ============================================================================= +// Scroll Tracking Types +// ============================================================================= + +/** + * Scroll metrics for heatmap generation + */ +export interface ScrollMetrics { + /** Current scroll depth as percentage (0-100) */ + scroll_depth_percent: number; + /** Total document height in pixels */ + document_height: number; + /** Viewport height in pixels */ + viewport_height: number; + /** Current scroll position from top in pixels */ + scroll_top: number; + /** Top of visible area as percentage of document (0-100) */ + visible_top_percent: number; + /** Bottom of visible area as percentage of document (0-100) */ + visible_bottom_percent: number; + /** Screen/viewport width in pixels */ + screen_width: number; + /** Screen/viewport height in pixels */ + screen_height: number; +} + +/** + * Scroll snapshot with timestamp + */ +export interface ScrollSnapshot extends ScrollMetrics { + /** Timestamp when this snapshot was taken */ + timestamp: number; +} + +/** + * Complete scroll heatmap data + */ +export interface ScrollHeatmapData { + /** Maximum scroll depth reached (percentage) */ + max_depth_percent: number; + /** Array of milestone percentages that were reached */ + milestones_reached: number[]; + /** Array of scroll snapshots for detailed analysis */ + snapshots: ScrollSnapshot[]; + /** Current scroll metrics */ + current_metrics: ScrollMetrics; +} + +// ============================================================================= +// Session Recording Types +// ============================================================================= + +/** + * Session recording status + */ +export interface RecordingStatus { + /** Whether recording is currently active */ + isRecording: boolean; + /** Number of events in the current buffer */ + eventCount: number; + /** Whether the RRWeb library has been loaded */ + rrwebLoaded: boolean; +} + +/** + * Options for starting session recording + */ +export interface RecordingOptions { + /** Time in ms between full DOM snapshots (default: 10 minutes) */ + checkoutEveryNms?: number; + /** CSS class to block from recording */ + blockClass?: string; + /** CSS class to ignore input values */ + ignoreClass?: string; + /** CSS class to mask text content */ + maskTextClass?: string; + /** Mask all input values for privacy (default: true) */ + maskAllInputs?: boolean; + /** Specific input types to mask */ + maskInputOptions?: { + password?: boolean; + email?: boolean; + [key: string]: boolean | undefined; + }; +} + // ============================================================================= // Core API Interface // ============================================================================= @@ -85,6 +181,14 @@ export interface ZoriCoreAPI { optOut: () => boolean; /** Check if user has given consent */ hasConsent: () => boolean; + /** Get scroll heatmap data */ + getScrollData: () => ScrollHeatmapData | null; + /** Start session recording */ + startRecording: (options?: RecordingOptions) => Promise; + /** Stop session recording */ + stopRecording: () => boolean; + /** Get current recording status */ + getRecordingStatus: () => RecordingStatus; } // ============================================================================= diff --git a/svelte/index.ts b/svelte/index.ts index fa2900b..bfe3ac8 100644 --- a/svelte/index.ts +++ b/svelte/index.ts @@ -1,9 +1,9 @@ import { writable, readonly, derived, get, type Readable, type Writable } from 'svelte/store'; import { onMount, onDestroy } from 'svelte'; -import type { ZoriConfig, ConsentPreferences, UserInfo, ZoriCoreAPI } from '@zorihq/types'; +import type { ZoriConfig, ConsentPreferences, UserInfo, ZoriCoreAPI, ScrollHeatmapData, RecordingStatus, RecordingOptions } from '@zorihq/types'; // Re-export shared types for convenience -export type { ZoriConfig, ConsentPreferences, UserInfo } from '@zorihq/types'; +export type { ZoriConfig, ConsentPreferences, UserInfo, ScrollHeatmapData, RecordingStatus, RecordingOptions } from '@zorihq/types'; // Svelte-specific store type extending core API with reactive state export interface ZoriStore extends Omit { @@ -40,6 +40,26 @@ export function createZoriStore(config: ZoriConfig): ZoriStore { script.setAttribute('data-track-quick-switches', config.trackQuickSwitches.toString()); } + if (config.trackScrollDepth !== undefined) { + script.setAttribute('data-track-scroll-depth', config.trackScrollDepth.toString()); + } + + if (config.scrollThrottleMs !== undefined) { + script.setAttribute('data-scroll-throttle-ms', config.scrollThrottleMs.toString()); + } + + if (config.scrollDepthIntervals !== undefined) { + script.setAttribute('data-scroll-depth-intervals', JSON.stringify(config.scrollDepthIntervals)); + } + + if (config.enableSessionRecording !== undefined) { + script.setAttribute('data-enable-session-recording', config.enableSessionRecording.toString()); + } + + if (config.rrwebCdnUrl !== undefined) { + script.setAttribute('data-rrweb-cdn-url', config.rrwebCdnUrl); + } + script.onload = () => { isInitialized.set(true); }; @@ -121,6 +141,44 @@ export function createZoriStore(config: ZoriConfig): ZoriStore { return zori.hasConsent(); }; + const getScrollData = (): ScrollHeatmapData | null => { + const zori = (window as any).ZoriHQ; + if (!zori || typeof zori.getScrollData !== 'function') return null; + return zori.getScrollData(); + }; + + const startRecording = async (options?: RecordingOptions): Promise => { + const zori = (window as any).ZoriHQ; + if (!zori) return false; + + if (typeof zori.startRecording === 'function') { + return await zori.startRecording(options); + } else { + zori.push(['startRecording', options]); + return true; + } + }; + + const stopRecording = (): boolean => { + const zori = (window as any).ZoriHQ; + if (!zori) return false; + + if (typeof zori.stopRecording === 'function') { + return zori.stopRecording(); + } else { + zori.push(['stopRecording']); + return true; + } + }; + + const getRecordingStatus = (): RecordingStatus => { + const zori = (window as any).ZoriHQ; + if (!zori || typeof zori.getRecordingStatus !== 'function') { + return { isRecording: false, eventCount: 0, rrwebLoaded: false }; + } + return zori.getRecordingStatus(); + }; + // Load script immediately if (typeof window !== 'undefined') { loadScript(); @@ -135,6 +193,10 @@ export function createZoriStore(config: ZoriConfig): ZoriStore { setConsent, optOut, hasConsent, + getScrollData, + startRecording, + stopRecording, + getRecordingStatus, }; } @@ -224,6 +286,95 @@ export function useIdentify(userInfo: UserInfo | null) { }); } +// Helper: useScrollData (for components) +export function useScrollData(): Readable { + const store = getZori(); + const scrollData = writable(null); + let intervalId: ReturnType | null = null; + + onMount(() => { + const unsubscribe = store.isInitialized.subscribe((initialized) => { + if (initialized) { + scrollData.set(store.getScrollData()); + } + }); + + // Update periodically + intervalId = setInterval(() => { + if (get(store.isInitialized)) { + scrollData.set(store.getScrollData()); + } + }, 1000); + + return () => { + unsubscribe(); + if (intervalId) { + clearInterval(intervalId); + } + }; + }); + + onDestroy(() => { + if (intervalId) { + clearInterval(intervalId); + } + }); + + return readonly(scrollData); +} + +// Helper: useSessionRecording (for components) +export function useSessionRecording() { + const store = getZori(); + const status = writable({ + isRecording: false, + eventCount: 0, + rrwebLoaded: false, + }); + let intervalId: ReturnType | null = null; + + onMount(() => { + // Update status periodically + intervalId = setInterval(() => { + if (get(store.isInitialized)) { + status.set(store.getRecordingStatus()); + } + }, 1000); + + return () => { + if (intervalId) { + clearInterval(intervalId); + } + }; + }); + + onDestroy(() => { + if (intervalId) { + clearInterval(intervalId); + } + }); + + const start = async (options?: RecordingOptions) => { + const result = await store.startRecording(options); + if (result) { + status.set(store.getRecordingStatus()); + } + return result; + }; + + const stop = () => { + const result = store.stopRecording(); + status.set(store.getRecordingStatus()); + return result; + }; + + return { + status: readonly(status), + start, + stop, + }; +} + // Export default export default { createZoriStore, @@ -233,4 +384,6 @@ export default { usePageView, useTrackEvent, useIdentify, + useScrollData, + useSessionRecording, }; diff --git a/vue/index.ts b/vue/index.ts index 91a0d54..84ed97d 100644 --- a/vue/index.ts +++ b/vue/index.ts @@ -15,9 +15,12 @@ import type { ConsentPreferences, UserInfo, ZoriCoreAPI, + ScrollHeatmapData, + RecordingStatus, + RecordingOptions, } from "@zorihq/types"; -export type { ZoriConfig, ConsentPreferences, UserInfo } from "@zorihq/types"; +export type { ZoriConfig, ConsentPreferences, UserInfo, ScrollHeatmapData, RecordingStatus, RecordingOptions } from "@zorihq/types"; export interface ZoriInstance extends Omit { isInitialized: Readonly>; @@ -64,6 +67,38 @@ function createZoriInstance(config: ZoriConfig): ZoriInstance { ); } + if (config.trackScrollDepth !== undefined) { + script.setAttribute( + "data-track-scroll-depth", + config.trackScrollDepth.toString(), + ); + } + + if (config.scrollThrottleMs !== undefined) { + script.setAttribute( + "data-scroll-throttle-ms", + config.scrollThrottleMs.toString(), + ); + } + + if (config.scrollDepthIntervals !== undefined) { + script.setAttribute( + "data-scroll-depth-intervals", + JSON.stringify(config.scrollDepthIntervals), + ); + } + + if (config.enableSessionRecording !== undefined) { + script.setAttribute( + "data-enable-session-recording", + config.enableSessionRecording.toString(), + ); + } + + if (config.rrwebCdnUrl !== undefined) { + script.setAttribute("data-rrweb-cdn-url", config.rrwebCdnUrl); + } + script.onload = () => { isInitialized.value = true; }; @@ -148,6 +183,44 @@ function createZoriInstance(config: ZoriConfig): ZoriInstance { return zori.hasConsent(); }; + const getScrollData = (): ScrollHeatmapData | null => { + const zori = (window as any).ZoriHQ; + if (!zori || typeof zori.getScrollData !== "function") return null; + return zori.getScrollData(); + }; + + const startRecording = async (options?: RecordingOptions): Promise => { + const zori = (window as any).ZoriHQ; + if (!zori) return false; + + if (typeof zori.startRecording === "function") { + return await zori.startRecording(options); + } else { + zori.push(["startRecording", options]); + return true; + } + }; + + const stopRecording = (): boolean => { + const zori = (window as any).ZoriHQ; + if (!zori) return false; + + if (typeof zori.stopRecording === "function") { + return zori.stopRecording(); + } else { + zori.push(["stopRecording"]); + return true; + } + }; + + const getRecordingStatus = (): RecordingStatus => { + const zori = (window as any).ZoriHQ; + if (!zori || typeof zori.getRecordingStatus !== "function") { + return { isRecording: false, eventCount: 0, rrwebLoaded: false }; + } + return zori.getRecordingStatus(); + }; + if (typeof window !== "undefined") { loadScript(); } @@ -161,6 +234,10 @@ function createZoriInstance(config: ZoriConfig): ZoriInstance { setConsent, optOut, hasConsent, + getScrollData, + startRecording, + stopRecording, + getRecordingStatus, }; } @@ -293,10 +370,90 @@ export function useIdentify(userInfo: Ref | UserInfo) { } } +export function useScrollData() { + const { getScrollData, isInitialized } = useZori(); + const scrollData = ref(null); + let intervalId: ReturnType | null = null; + + onMounted(() => { + if (isInitialized.value) { + scrollData.value = getScrollData(); + } + + // Update periodically + intervalId = setInterval(() => { + if (isInitialized.value) { + scrollData.value = getScrollData(); + } + }, 1000); + }); + + onUnmounted(() => { + if (intervalId) { + clearInterval(intervalId); + } + }); + + watch(isInitialized, (initialized) => { + if (initialized) { + scrollData.value = getScrollData(); + } + }); + + return readonly(scrollData); +} + +export function useSessionRecording() { + const { startRecording, stopRecording, getRecordingStatus, isInitialized } = useZori(); + const status = ref({ + isRecording: false, + eventCount: 0, + rrwebLoaded: false, + }); + let intervalId: ReturnType | null = null; + + onMounted(() => { + // Update status periodically + intervalId = setInterval(() => { + if (isInitialized.value) { + status.value = getRecordingStatus(); + } + }, 1000); + }); + + onUnmounted(() => { + if (intervalId) { + clearInterval(intervalId); + } + }); + + const start = async (options?: RecordingOptions) => { + const result = await startRecording(options); + if (result) { + status.value = getRecordingStatus(); + } + return result; + }; + + const stop = () => { + const result = stopRecording(); + status.value = getRecordingStatus(); + return result; + }; + + return { + status: readonly(status), + start, + stop, + }; +} + export default { ZoriPlugin, useZori, usePageView, useTrackEvent, useIdentify, + useScrollData, + useSessionRecording, };