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,
};
]