diff --git a/.changeset/display-condition-plugins.md b/.changeset/display-condition-plugins.md new file mode 100644 index 0000000..fa9fd63 --- /dev/null +++ b/.changeset/display-condition-plugins.md @@ -0,0 +1,28 @@ +--- +"@prosdevlab/experience-sdk": minor +"@prosdevlab/experience-sdk-plugins": minor +--- + +feat: add display condition plugins with event-driven architecture + +Add 4 new display condition plugins with comprehensive testing and documentation: + +**New Plugins:** +- Exit Intent: Velocity-based mouse tracking with session awareness +- Scroll Depth: Multiple thresholds with advanced engagement metrics +- Page Visits: Session and lifetime counters with first-visit detection +- Time Delay: Millisecond-precision delays with visibility API integration + +**Core Enhancements:** +- Event-driven trigger architecture (`trigger:*` events) +- Composable display conditions (AND/OR/NOT logic) +- TriggerState interface for type-safe context updates + +**Developer Experience:** +- 101 new tests across 4 plugins (314 total) +- 12 integration tests for plugin composition +- Complete API documentation with examples +- Pure functions for easier testing + +All plugins are backward compatible and work independently or together. + diff --git a/biome.json b/biome.json index 90a9d45..9cb3c0e 100644 --- a/biome.json +++ b/biome.json @@ -32,6 +32,10 @@ "packages/core/src/types.ts", "packages/core/src/runtime.ts", "packages/plugins/src/types.ts", + "packages/plugins/src/exit-intent/exit-intent.ts", + "packages/plugins/src/scroll-depth/scroll-depth.ts", + "packages/plugins/src/page-visits/page-visits.ts", + "packages/plugins/src/utils/sanitize.ts", "specs/**/contracts/types.ts", "docs/**/*.tsx" ], diff --git a/docs/pages/reference/_meta.json b/docs/pages/reference/_meta.json index 70a5262..b45fefa 100644 --- a/docs/pages/reference/_meta.json +++ b/docs/pages/reference/_meta.json @@ -3,4 +3,3 @@ "events": "Events", "plugins": "Plugins" } - diff --git a/docs/pages/reference/plugins/_meta.json b/docs/pages/reference/plugins/_meta.json new file mode 100644 index 0000000..394ecff --- /dev/null +++ b/docs/pages/reference/plugins/_meta.json @@ -0,0 +1,11 @@ +{ + "index": "Overview", + "banner": "Banner", + "frequency": "Frequency", + "debug": "Debug", + "exit-intent": "Exit Intent", + "scroll-depth": "Scroll Depth", + "page-visits": "Page Visits", + "time-delay": "Time Delay" +} + diff --git a/docs/pages/reference/plugins.mdx b/docs/pages/reference/plugins/banner.mdx similarity index 51% rename from docs/pages/reference/plugins.mdx rename to docs/pages/reference/plugins/banner.mdx index ae68969..c4a4a2e 100644 --- a/docs/pages/reference/plugins.mdx +++ b/docs/pages/reference/plugins/banner.mdx @@ -1,10 +1,4 @@ -# Plugins - -The Experience SDK uses a plugin architecture powered by [@lytics/sdk-kit](https://github.com/Lytics/sdk-kit). Plugins extend the runtime with additional capabilities. - -## Official Plugins - -### Banner Plugin +# Banner Plugin Renders banner experiences in the DOM with automatic positioning, theming, and responsive layout. @@ -236,219 +230,3 @@ content: { } } ``` - ---- - -### Frequency Plugin - -Manages impression tracking and frequency capping using persistent storage. - -#### Configuration - -```typescript -const experiences = createInstance({ - frequency: { - enabled: true, // Enable frequency tracking (default: true) - storage: 'local' // 'local' | 'session' | 'memory' (default: 'local') - } -}); -``` - -#### Experience-Level Frequency - -Define frequency caps per experience: - -```typescript -experiences.register('welcome', { - type: 'banner', - content: { message: 'Welcome!' }, - frequency: { - max: 3, // Maximum impressions - per: 'session' // 'session' | 'day' | 'week' - } -}); -``` - -#### API Methods - -##### `frequency.getImpressionCount(experienceId, period?)` - -Get impression count for an experience. - -```typescript -const count = experiences.frequency.getImpressionCount('welcome', 'session'); -console.log(`Shown ${count} times this session`); -``` - -**Parameters:** -- `experienceId: string` - Experience to check -- `period?: 'session' | 'day' | 'week'` - Time period (optional) - -**Returns:** `number` - -##### `frequency.recordImpression(experienceId)` - -Manually record an impression. - -```typescript -experiences.frequency.recordImpression('welcome'); -``` - -##### `frequency.reset(experienceId?)` - -Reset impression counts. - -```typescript -// Reset specific experience -experiences.frequency.reset('welcome'); - -// Reset all experiences -experiences.frequency.reset(); -``` - -#### How It Works - -1. **Impressions** are recorded when `experiences:shown` event is emitted -2. **Storage** persists counts in localStorage (or sessionStorage/memory) -3. **Evaluation** checks counts before showing experiences -4. **Dismissals** do NOT count as impressions - -#### Storage Keys - -Frequency data is stored with namespaced keys: - -``` -experiences.frequency.session:welcome = 3 -experiences.frequency.day:2024-12-25:welcome = 1 -experiences.frequency.week:2024-W52:welcome = 5 -``` - ---- - -### Debug Plugin - -Provides console logging and window event emission for debugging and Chrome extension integration. - -#### Configuration - -```typescript -const experiences = createInstance({ - debug: { - enabled: true, // Enable debug output (default: false) - windowEvents: true, // Emit to window (default: true when enabled) - prefix: 'experiences' // Log prefix (default: 'experiences') - } -}); -``` - -Or use the shorthand: - -```typescript -const experiences = createInstance({ debug: true }); -``` - -#### API Methods - -##### `debug.log(message, data?)` - -Log a message with optional data. - -```typescript -experiences.debug.log('User clicked button', { action: 'cta' }); -``` - -**Console output:** -``` -[experiences] User clicked button { action: 'cta' } -``` - -**Window event:** -```javascript -window.addEventListener('experiences:debug', (event) => { - console.log(event.detail); - // { message: 'User clicked button', data: { action: 'cta' }, timestamp: ... } -}); -``` - -#### Automatic Logging - -When debug mode is enabled, the plugin automatically logs: - -- SDK initialization -- Experience registration -- Evaluation results -- Decision reasons -- Event emissions - -```typescript -const experiences = createInstance({ debug: true }); - -experiences.register('welcome', { ... }); -// [experiences] Experience registered: welcome - -const decision = experiences.evaluate(); -// [experiences] Evaluating 1 experience(s) -// [experiences] Decision: show=true, experience=welcome -// [experiences] Reasons: ["✅ URL matches", "✅ Frequency: 0/3 this session"] -``` - -#### Chrome Extension Integration - -The debug plugin emits events to `window` that can be captured by Chrome extensions: - -```javascript -// In Chrome extension content script -window.addEventListener('experiences:debug', (event) => { - chrome.runtime.sendMessage({ - type: 'EXPERIENCE_DEBUG', - payload: event.detail - }); -}); -``` - -This enables building DevTools panels for inspecting experience decisions in real-time. - ---- - -## Plugin Development - -The SDK uses [@lytics/sdk-kit](https://github.com/Lytics/sdk-kit)'s plugin system. Custom plugins can be created using the `PluginFunction` interface: - -```typescript -import type { PluginFunction } from '@lytics/sdk-kit'; - -const myPlugin: PluginFunction = (plugin, instance, config) => { - // Namespace - plugin.ns('my.plugin'); - - // Default config - plugin.defaults({ myPlugin: { enabled: true } }); - - // Expose API - plugin.expose({ - myMethod() { - console.log('Hello from plugin!'); - } - }); - - // Listen to events - instance.on('experiences:evaluated', (decision) => { - // React to evaluations - }); -}; - -// Use the plugin -const experiences = createInstance(); -experiences.use(myPlugin); -``` - -See the [sdk-kit documentation](https://github.com/Lytics/sdk-kit) for complete plugin development guide. - ---- - -## Next Steps - -- [Banner Examples](/demo/banner) - Complete banner usage examples -- [Events Reference](/reference/events) - All SDK events -- [API Reference](/reference) - Core API documentation - diff --git a/docs/pages/reference/plugins/debug.mdx b/docs/pages/reference/plugins/debug.mdx new file mode 100644 index 0000000..329e091 --- /dev/null +++ b/docs/pages/reference/plugins/debug.mdx @@ -0,0 +1,88 @@ +# Debug Plugin + +Provides console logging and window event emission for debugging and Chrome extension integration. + +#### Configuration + +```typescript +const experiences = createInstance({ + debug: { + enabled: true, // Enable debug output (default: false) + windowEvents: true, // Emit to window (default: true when enabled) + prefix: 'experiences' // Log prefix (default: 'experiences') + } +}); +``` + +Or use the shorthand: + +```typescript +const experiences = createInstance({ debug: true }); +``` + +#### API Methods + +##### `debug.log(message, data?)` + +Log a message with optional data. + +```typescript +experiences.debug.log('User clicked button', { action: 'cta' }); +``` + +**Console output:** +``` +[experiences] User clicked button { action: 'cta' } +``` + +**Window event:** +```javascript +window.addEventListener('experiences:debug', (event) => { + console.log(event.detail); + // { message: 'User clicked button', data: { action: 'cta' }, timestamp: ... } +}); +``` + +#### Automatic Logging + +When debug mode is enabled, the plugin automatically logs: + +- SDK initialization +- Experience registration +- Evaluation results +- Decision reasons +- Event emissions + +```typescript +const experiences = createInstance({ debug: true }); + +experiences.register('welcome', { ... }); +// [experiences] Experience registered: welcome + +const decision = experiences.evaluate(); +// [experiences] Evaluating 1 experience(s) +// [experiences] Decision: show=true, experience=welcome +// [experiences] Reasons: ["✅ URL matches", "✅ Frequency: 0/3 this session"] +``` + +#### Chrome Extension Integration + +The debug plugin emits events to `window` that can be captured by Chrome extensions: + +```javascript +// In Chrome extension content script +window.addEventListener('experiences:debug', (event) => { + chrome.runtime.sendMessage({ + type: 'EXPERIENCE_DEBUG', + payload: event.detail + }); +}); +``` + +This enables building DevTools panels for inspecting experience decisions in real-time. + +--- + +## Display Condition Plugins + +Display condition plugins enable advanced triggering logic beyond simple URL and user targeting. These plugins detect user behavior and emit `trigger:*` events that update the evaluation context. diff --git a/docs/pages/reference/plugins/exit-intent.mdx b/docs/pages/reference/plugins/exit-intent.mdx new file mode 100644 index 0000000..38bd7f4 --- /dev/null +++ b/docs/pages/reference/plugins/exit-intent.mdx @@ -0,0 +1,133 @@ +# Exit Intent Plugin + +Detects when users are about to leave the page based on mouse movement patterns. + +#### Configuration + +```typescript +const experiences = createInstance({ + exitIntent: { + sensitivity: 50, // Distance from top in pixels (default: 50) + minTimeOnPage: 5000, // Minimum time before trigger (ms, default: 5000) + disableOnMobile: true // Disable on mobile devices (default: true) + } +}); +``` + +#### How It Works + +The plugin tracks mouse movement and triggers when: +1. Mouse moves toward the top of the page with upward velocity +2. User has been on the page for at least `minTimeOnPage` milliseconds +3. Trigger has not already fired this session + +**Detection Algorithm:** +- Monitors `mousemove` events +- Calculates velocity: `Math.abs(currentY - previousY)` +- Triggers when: `y - velocity <= sensitivity` +- Persists trigger state in `sessionStorage` (one trigger per session) + +#### Using in Targeting + +```typescript +experiences.register('exit-offer', { + type: 'banner', + content: { + title: 'Wait! Don\'t miss out', + message: 'Get 20% off your first order', + buttons: [{ text: 'Claim Offer', url: '/shop?discount=FIRST20' }] + }, + targeting: { + custom: (context) => { + return context.triggers?.exitIntent?.triggered === true; + } + } +}); +``` + +#### API Methods + +##### `exitIntent.isTriggered()` + +Check if exit intent has been triggered this session. + +```typescript +if (experiences.exitIntent.isTriggered()) { + console.log('User tried to exit'); +} +``` + +##### `exitIntent.reset()` + +Reset the trigger state (allows re-triggering). + +```typescript +experiences.exitIntent.reset(); +``` + +##### `exitIntent.getPositions()` + +Get the last 5 mouse positions (for debugging). + +```typescript +const positions = experiences.exitIntent.getPositions(); +console.log('Mouse trail:', positions); +``` + +#### Events + +**`trigger:exitIntent`** + +```typescript +experiences.on('trigger:exitIntent', (event) => { + console.log('Exit intent triggered!'); + console.log('Time on page:', event.timeOnPage, 'ms'); + console.log('Mouse velocity:', event.velocity, 'px/event'); + console.log('Position:', event.lastY, 'px from top'); +}); +``` + +**Event payload:** +```typescript +{ + triggered: boolean; + timestamp: number; + lastY: number; + previousY: number; + velocity: number; + timeOnPage: number; +} +``` + +#### Migrating from Pathfora + +```diff +- // Pathfora +- var widget = new pathfora.Message({ +- msg: 'Wait! Don\'t leave yet', +- layout: 'modal', +- displayConditions: { +- showOnExitIntent: true +- } +- }); + ++ // Experience SDK ++ experiences.register('exit-modal', { ++ type: 'banner', ++ content: { ++ message: 'Wait! Don\'t leave yet', ++ }, ++ targeting: { ++ custom: (ctx) => ctx.triggers?.exitIntent?.triggered ++ } ++ }); +``` + +**Key Differences:** +- ✅ **More accurate:** Velocity-based detection (industry standard) +- ✅ **Configurable:** Adjust sensitivity and minimum time +- ✅ **Better mobile:** Optional mobile detection +- ✅ **Session-aware:** Only triggers once per session by default +- ✅ **Composable:** Works with other display conditions + +--- diff --git a/docs/pages/reference/plugins/frequency.mdx b/docs/pages/reference/plugins/frequency.mdx new file mode 100644 index 0000000..8132929 --- /dev/null +++ b/docs/pages/reference/plugins/frequency.mdx @@ -0,0 +1,85 @@ +# Frequency Plugin + +Manages impression tracking and frequency capping using persistent storage. + +#### Configuration + +```typescript +const experiences = createInstance({ + frequency: { + enabled: true, // Enable frequency tracking (default: true) + storage: 'local' // 'local' | 'session' | 'memory' (default: 'local') + } +}); +``` + +#### Experience-Level Frequency + +Define frequency caps per experience: + +```typescript +experiences.register('welcome', { + type: 'banner', + content: { message: 'Welcome!' }, + frequency: { + max: 3, // Maximum impressions + per: 'session' // 'session' | 'day' | 'week' + } +}); +``` + +#### API Methods + +##### `frequency.getImpressionCount(experienceId, period?)` + +Get impression count for an experience. + +```typescript +const count = experiences.frequency.getImpressionCount('welcome', 'session'); +console.log(`Shown ${count} times this session`); +``` + +**Parameters:** +- `experienceId: string` - Experience to check +- `period?: 'session' | 'day' | 'week'` - Time period (optional) + +**Returns:** `number` + +##### `frequency.recordImpression(experienceId)` + +Manually record an impression. + +```typescript +experiences.frequency.recordImpression('welcome'); +``` + +##### `frequency.reset(experienceId?)` + +Reset impression counts. + +```typescript +// Reset specific experience +experiences.frequency.reset('welcome'); + +// Reset all experiences +experiences.frequency.reset(); +``` + +#### How It Works + +1. **Impressions** are recorded when `experiences:shown` event is emitted +2. **Storage** persists counts in localStorage (or sessionStorage/memory) +3. **Evaluation** checks counts before showing experiences +4. **Dismissals** do NOT count as impressions + +#### Storage Keys + +Frequency data is stored with namespaced keys: + +``` +experiences.frequency.session:welcome = 3 +experiences.frequency.day:2024-12-25:welcome = 1 +experiences.frequency.week:2024-W52:welcome = 5 +``` + +--- diff --git a/docs/pages/reference/plugins/index.mdx b/docs/pages/reference/plugins/index.mdx new file mode 100644 index 0000000..31dfdbc --- /dev/null +++ b/docs/pages/reference/plugins/index.mdx @@ -0,0 +1,127 @@ +# Plugins Overview + +The Experience SDK uses a plugin architecture powered by [@lytics/sdk-kit](https://github.com/Lytics/sdk-kit). Plugins extend the runtime with additional capabilities. + +## Official Plugins + +### Rendering Plugins + +- **[Banner](/reference/plugins/banner)** - Renders banner experiences in the DOM with automatic positioning, theming, and responsive layout + +### Utility Plugins + +- **[Frequency](/reference/plugins/frequency)** - Manages impression tracking and frequency capping using persistent storage +- **[Debug](/reference/plugins/debug)** - Provides console logging and window event emission for debugging + +### Display Condition Plugins + +Display condition plugins enable advanced triggering logic beyond simple URL and user targeting. These plugins detect user behavior and emit `trigger:*` events that update the evaluation context. + +- **[Exit Intent](/reference/plugins/exit-intent)** - Detects when users are about to leave the page based on mouse movement patterns +- **[Scroll Depth](/reference/plugins/scroll-depth)** - Tracks scroll depth with configurable thresholds and advanced engagement metrics +- **[Page Visits](/reference/plugins/page-visits)** - Tracks visit counts across sessions and lifetime with first-visit detection +- **[Time Delay](/reference/plugins/time-delay)** - Triggers experiences after configurable delays with pause/resume support + +## Combining Display Conditions + +Display condition plugins can be combined using custom targeting logic: + +### AND Logic (All conditions must be met) + +```typescript +experiences.register('super-engaged', { + type: 'banner', + content: { + message: 'You\'re amazing! Exclusive offer for engaged readers.' + }, + targeting: { + custom: (context) => { + const scrolled = (context.triggers?.scrollDepth?.maxPercent || 0) >= 75; + const timeSpent = context.triggers?.timeDelay?.triggered === true; + const returning = (context.triggers?.pageVisits?.totalVisits || 0) >= 3; + + return scrolled && timeSpent && returning; // All three + } + } +}); +``` + +### OR Logic (Any condition can trigger) + +```typescript +experiences.register('flexible-promo', { + type: 'banner', + content: { + message: 'Special offer just for you!' + }, + targeting: { + custom: (context) => { + const exitIntent = context.triggers?.exitIntent?.triggered; + const scrolled75 = (context.triggers?.scrollDepth?.maxPercent || 0) >= 75; + const timeSpent = context.triggers?.timeDelay?.triggered; + + return exitIntent || scrolled75 || timeSpent; // Any one + } + } +}); +``` + +### NOT Logic (Inverse conditions) + +```typescript +experiences.register('first-timer-only', { + type: 'banner', + content: { + message: 'Welcome! Here\'s 20% off your first order.' + }, + targeting: { + custom: (context) => { + const isFirstVisit = context.triggers?.pageVisits?.isFirstVisit; + const hasNotExited = !context.triggers?.exitIntent?.triggered; + + return isFirstVisit && hasNotExited; // First visit, not exiting + } + } +}); +``` + +## Plugin Development + +The SDK uses [@lytics/sdk-kit](https://github.com/Lytics/sdk-kit)'s plugin system. Custom plugins can be created using the `PluginFunction` interface: + +```typescript +import type { PluginFunction } from '@lytics/sdk-kit'; + +const myPlugin: PluginFunction = (plugin, instance, config) => { + // Namespace + plugin.ns('my.plugin'); + + // Default config + plugin.defaults({ myPlugin: { enabled: true } }); + + // Expose API + plugin.expose({ + myMethod() { + console.log('Hello from plugin!'); + } + }); + + // Listen to events + instance.on('experiences:evaluated', (decision) => { + // React to evaluations + }); +}; + +// Use the plugin +const experiences = createInstance(); +experiences.use(myPlugin); +``` + +See the [sdk-kit documentation](https://github.com/Lytics/sdk-kit) for complete plugin development guide. + +## Next Steps + +- [Banner Examples](/demo/banner) - Complete banner usage examples +- [Events Reference](/reference/events) - All SDK events +- [API Reference](/reference) - Core API documentation + diff --git a/docs/pages/reference/plugins/page-visits.mdx b/docs/pages/reference/plugins/page-visits.mdx new file mode 100644 index 0000000..8370fa4 --- /dev/null +++ b/docs/pages/reference/plugins/page-visits.mdx @@ -0,0 +1,231 @@ +# Page Visits Plugin + +Tracks visit counts across sessions and lifetime with first-visit detection. + +#### Configuration + +```typescript +const experiences = createInstance({ + pageVisits: { + enabled: true, // Enable tracking (default: true) + autoIncrement: true, // Auto-increment on init (default: true) + storage: 'local', // 'local' | 'session' (default: 'local') + respectDNT: true // Honor Do Not Track (default: true) + } +}); +``` + +#### How It Works + +The plugin tracks visit counts in two scopes: +1. **Session visits:** Resets when browser tab closes (`sessionStorage`) +2. **Total visits:** Persists across sessions (`localStorage`) + +**Storage Schema:** +```javascript +{ + count: 5, + first: 1703505600000, // Unix timestamp of first visit + last: 1703678400000 // Unix timestamp of last visit +} +``` + +#### Using in Targeting + +```typescript +// First-time visitors +experiences.register('welcome-new', { + type: 'banner', + content: { + title: 'Welcome!', + message: 'First time here? Get 20% off your first order.', + buttons: [{ text: 'Shop Now', url: '/shop' }] + }, + targeting: { + custom: (context) => { + return context.triggers?.pageVisits?.isFirstVisit === true; + } + } +}); + +// Returning visitors +experiences.register('welcome-back', { + type: 'banner', + content: { + title: 'Welcome back!', + message: 'We missed you! Check out what\'s new.', + buttons: [{ text: 'See New Arrivals', url: '/new' }] + }, + targeting: { + custom: (context) => { + const visits = context.triggers?.pageVisits; + return visits?.totalVisits >= 3 && !visits?.isFirstVisit; + } + } +}); + +// Frequent visitors +experiences.register('vip-offer', { + type: 'banner', + content: { + title: 'You\'re a VIP!', + message: 'Thanks for being a loyal customer. Exclusive offer inside.', + buttons: [{ text: 'Claim Reward', url: '/rewards' }] + }, + targeting: { + custom: (context) => { + return (context.triggers?.pageVisits?.totalVisits || 0) >= 10; + } + } +}); +``` + +#### API Methods + +##### `pageVisits.isFirstVisit()` + +Check if this is the first visit (before any increment). + +```typescript +if (experiences.pageVisits.isFirstVisit()) { + console.log('First-time visitor!'); +} +``` + +##### `pageVisits.getTotalCount()` + +Get total visit count (lifetime). + +```typescript +const total = experiences.pageVisits.getTotalCount(); +console.log(`${total} visits total`); +``` + +##### `pageVisits.getSessionCount()` + +Get session visit count. + +```typescript +const session = experiences.pageVisits.getSessionCount(); +console.log(`${session} visits this session`); +``` + +##### `pageVisits.increment()` + +Manually increment visit counts. + +```typescript +experiences.pageVisits.increment(); +``` + +##### `pageVisits.reset()` + +Reset all visit counts. + +```typescript +// Reset everything +experiences.pageVisits.reset(); + +// Or reset specific scope +experiences.pageVisits.reset('session'); +experiences.pageVisits.reset('total'); +``` + +##### `pageVisits.getFirstVisitTime()` + +Get timestamp of first visit. + +```typescript +const firstVisit = experiences.pageVisits.getFirstVisitTime(); +if (firstVisit) { + const daysAgo = (Date.now() - firstVisit) / (1000 * 60 * 60 * 24); + console.log(`First visited ${daysAgo} days ago`); +} +``` + +##### `pageVisits.getLastVisitTime()` + +Get timestamp of last visit. + +```typescript +const lastVisit = experiences.pageVisits.getLastVisitTime(); +if (lastVisit) { + const hoursAgo = (Date.now() - lastVisit) / (1000 * 60 * 60); + console.log(`Last visited ${hoursAgo} hours ago`); +} +``` + +##### `pageVisits.disable()` / `pageVisits.enable()` + +Temporarily disable/enable tracking. + +```typescript +// Disable tracking (e.g., for privacy compliance) +experiences.pageVisits.disable(); + +// Re-enable later +experiences.pageVisits.enable(); +``` + +#### Events + +**`pageVisits:incremented`** + +```typescript +experiences.on('pageVisits:incremented', (event) => { + console.log('Visit tracked!'); + console.log('Total visits:', event.totalVisits); + console.log('Session visits:', event.sessionVisits); + console.log('Is first visit:', event.isFirstVisit); +}); +``` + +**`pageVisits:reset`** + +```typescript +experiences.on('pageVisits:reset', ({ scope }) => { + console.log(`${scope} visits reset`); +}); +``` + +**`pageVisits:disabled`** + +```typescript +experiences.on('pageVisits:disabled', () => { + console.log('Page visit tracking disabled'); +}); +``` + +#### Migrating from Pathfora + +```diff +- // Pathfora +- var widget = new pathfora.Message({ +- msg: 'Welcome back!', +- displayConditions: { +- pageVisits: 3 +- } +- }); + ++ // Experience SDK ++ experiences.register('returning-visitor', { ++ type: 'banner', ++ content: { ++ message: 'Welcome back!' ++ }, ++ targeting: { ++ custom: (ctx) => (ctx.triggers?.pageVisits?.totalVisits || 0) >= 3 ++ } ++ }); +``` + +**Key Differences:** +- ✅ **Session vs lifetime:** Track both scopes independently +- ✅ **First-visit detection:** Built-in `isFirstVisit()` method +- ✅ **Timestamps:** Know when first/last visits occurred +- ✅ **Full operators:** `>=`, `<`, `===`, not just `>=` +- ✅ **Privacy-aware:** Honors DNT, enable/disable API +- ✅ **Reset capability:** Clear counts programmatically +- ✅ **Cross-tab safe:** Uses sdk-kit storage with locking + +--- diff --git a/docs/pages/reference/plugins/scroll-depth.mdx b/docs/pages/reference/plugins/scroll-depth.mdx new file mode 100644 index 0000000..8668bf0 --- /dev/null +++ b/docs/pages/reference/plugins/scroll-depth.mdx @@ -0,0 +1,190 @@ +# Scroll Depth Plugin + +Tracks scroll depth with configurable thresholds and advanced engagement metrics. + +#### Configuration + +```typescript +const experiences = createInstance({ + scrollDepth: { + thresholds: [25, 50, 75, 90], // Percentage thresholds (default: [25, 50, 75]) + throttle: 100, // Throttle interval in ms (default: 100) + includeViewport: true, // Include viewport in calc (default: true) + trackMax: true // Track max scroll reached (default: true) + } +}); +``` + +#### How It Works + +The plugin monitors scroll position and triggers when thresholds are crossed: +1. Calculates scroll percentage: `(scrollTop / (scrollHeight - clientHeight)) * 100` +2. Emits `trigger:scrollDepth` when crossing thresholds +3. Tracks direction changes, velocity, and engagement score + +**Advanced Metrics:** +- **Velocity:** Scroll speed in pixels per throttle interval +- **Direction:** Up/down tracking with change counting +- **Time to threshold:** How long it took to reach each threshold +- **Engagement score:** Weighted metric based on depth, time, and interaction + +#### Using in Targeting + +```typescript +// Simple threshold +experiences.register('engaged-reader', { + type: 'banner', + content: { + message: 'Enjoying this article? Subscribe for more!', + buttons: [{ text: 'Subscribe', url: '/subscribe' }] + }, + targeting: { + custom: (context) => { + return (context.triggers?.scrollDepth?.maxPercent || 0) >= 75; + } + } +}); + +// Advanced engagement +experiences.register('highly-engaged', { + type: 'banner', + content: { + message: 'You're a power reader! Join our community.', + buttons: [{ text: 'Join Now', url: '/community' }] + }, + targeting: { + custom: (context) => { + const scroll = context.triggers?.scrollDepth; + return ( + scroll?.maxPercent >= 90 && + scroll?.engagementScore >= 50 && + scroll?.directionChanges >= 2 + ); + } + } +}); +``` + +#### API Methods + +##### `scrollDepth.getCurrentPercent()` + +Get current scroll percentage. + +```typescript +const percent = experiences.scrollDepth.getCurrentPercent(); +console.log(`Scrolled ${percent}%`); +``` + +##### `scrollDepth.getMaxPercent()` + +Get maximum scroll percentage reached. + +```typescript +const max = experiences.scrollDepth.getMaxPercent(); +console.log(`Deepest scroll: ${max}%`); +``` + +##### `scrollDepth.getThresholdsCrossed()` + +Get all thresholds that have been crossed. + +```typescript +const crossed = experiences.scrollDepth.getThresholdsCrossed(); +console.log('Crossed:', crossed); // [25, 50, 75] +``` + +##### `scrollDepth.getDevice()` + +Get detected device type (for responsive thresholds). + +```typescript +const device = experiences.scrollDepth.getDevice(); +// Returns: 'mobile' | 'tablet' | 'desktop' +``` + +##### `scrollDepth.getAdvancedMetrics()` + +Get detailed engagement metrics. + +```typescript +const metrics = experiences.scrollDepth.getAdvancedMetrics(); +console.log(metrics); +// { +// timeOnPage: 45000, +// directionChanges: 3, +// timeScrollingUp: 5000, +// thresholdTimes: { '25': 2500, '50': 12000, '75': 30000 } +// } +``` + +##### `scrollDepth.reset()` + +Reset all tracking state. + +```typescript +experiences.scrollDepth.reset(); +``` + +#### Events + +**`trigger:scrollDepth`** + +```typescript +experiences.on('trigger:scrollDepth', (event) => { + console.log('Scroll threshold crossed:', event.threshold); + console.log('Current:', event.percent); + console.log('Max reached:', event.maxPercent); + console.log('Engagement score:', event.engagementScore); +}); +``` + +**Event payload:** +```typescript +{ + triggered: boolean; + timestamp: number; + percent: number; + maxPercent: number; + threshold: number; + thresholdsCrossed: number[]; + velocity: number; + direction: 'up' | 'down' | null; + directionChanges: number; + timeToThreshold: number; + engagementScore: number; +} +``` + +#### Migrating from Pathfora + +```diff +- // Pathfora +- var widget = new pathfora.Message({ +- msg: 'You\'re almost done!', +- displayConditions: { +- scrollPercentageToDisplay: 75 +- } +- }); + ++ // Experience SDK ++ experiences.register('scroll-promo', { ++ type: 'banner', ++ content: { ++ message: 'You\'re almost done!' ++ }, ++ targeting: { ++ custom: (ctx) => (ctx.triggers?.scrollDepth?.maxPercent || 0) >= 75 ++ } ++ }); +``` + +**Key Differences:** +- ✅ **Multiple thresholds:** Track 25%, 50%, 75%, 90% simultaneously +- ✅ **Throttled:** Optimized performance (configurable) +- ✅ **Advanced metrics:** Velocity, direction, engagement scoring +- ✅ **Device-aware:** Responsive threshold logic +- ✅ **Time tracking:** Know how long it took to scroll +- ✅ **Reset capability:** Re-trigger after reset + +--- diff --git a/docs/pages/reference/plugins/time-delay.mdx b/docs/pages/reference/plugins/time-delay.mdx new file mode 100644 index 0000000..96f7f70 --- /dev/null +++ b/docs/pages/reference/plugins/time-delay.mdx @@ -0,0 +1,190 @@ +# Time Delay Plugin + +Triggers experiences after configurable delays with pause/resume support. + +#### Configuration + +```typescript +const experiences = createInstance({ + timeDelay: { + delay: 5000, // Show delay in ms (default: 0) + hideAfter: 0, // Auto-hide after ms (default: 0 = disabled) + pauseWhenHidden: true // Pause timers when tab hidden (default: true) + } +}); +``` + +#### How It Works + +The plugin manages `setTimeout` timers with: +1. **Show delay:** Emits `trigger:timeDelay` after `delay` milliseconds +2. **Hide after:** Optionally emits hide trigger after additional time +3. **Page Visibility API:** Pauses/resumes timers when tab hidden +4. **Active elapsed tracking:** Tracks time excluding pauses + +**Timer Lifecycle:** +``` +Init → [delay ms] → Show Trigger → [hideAfter ms] → Hide Trigger + ↓ (tab hidden) + Pause → Resume (tab visible) → +``` + +#### Using in Targeting + +```typescript +// Show after 5 seconds +experiences.register('delayed-welcome', { + type: 'banner', + content: { + title: 'Still reading?', + message: 'Subscribe to our newsletter for weekly updates.', + buttons: [{ text: 'Subscribe', url: '/subscribe' }] + }, + targeting: { + custom: (context) => { + return context.triggers?.timeDelay?.triggered === true; + } + } +}); + +// Show after delay, hide after 10 seconds +experiences.register('flash-message', { + type: 'banner', + content: { + message: 'Limited time: Free shipping on orders over $50!' + }, + targeting: { + custom: (context) => { + const delay = context.triggers?.timeDelay; + // Show if triggered and not yet hidden + return delay?.triggered && delay?.type === 'show'; + } + } +}); +``` + +#### API Methods + +##### `timeDelay.isTriggered()` + +Check if the show trigger has fired. + +```typescript +if (experiences.timeDelay.isTriggered()) { + console.log('Delay elapsed'); +} +``` + +##### `timeDelay.getElapsed()` + +Get total elapsed time since initialization (includes pauses). + +```typescript +const total = experiences.timeDelay.getElapsed(); +console.log(`${total}ms since page load`); +``` + +##### `timeDelay.getActiveElapsed()` + +Get active elapsed time (excludes pauses). + +```typescript +const active = experiences.timeDelay.getActiveElapsed(); +console.log(`${active}ms of active time`); +``` + +##### `timeDelay.getRemaining()` + +Get remaining time until next trigger. + +```typescript +const remaining = experiences.timeDelay.getRemaining(); +console.log(`${remaining}ms until trigger`); +``` + +##### `timeDelay.isPaused()` + +Check if timers are currently paused. + +```typescript +if (experiences.timeDelay.isPaused()) { + console.log('Timers paused (tab hidden)'); +} +``` + +##### `timeDelay.reset()` + +Reset all timers and state. + +```typescript +experiences.timeDelay.reset(); +``` + +#### Events + +**`trigger:timeDelay`** + +```typescript +experiences.on('trigger:timeDelay', (event) => { + console.log('Time delay triggered!'); + console.log('Type:', event.type); // 'show' or 'hide' + console.log('Elapsed:', event.elapsed, 'ms'); + console.log('Active elapsed:', event.activeElapsed, 'ms'); + console.log('Was paused:', event.wasPaused); +}); +``` + +**Event payload:** +```typescript +{ + triggered: boolean; + timestamp: number; + elapsed: number; + activeElapsed: number; + remaining: number; + wasPaused: boolean; + type: 'show' | 'hide'; +} +``` + +#### Migrating from Pathfora + +```diff +- // Pathfora +- var widget = new pathfora.Message({ +- msg: 'Still here?', +- displayConditions: { +- showDelay: 5 +- } +- }); + ++ // Experience SDK ++ experiences.register('delayed-message', { ++ type: 'banner', ++ content: { ++ message: 'Still here?' ++ }, ++ targeting: { ++ custom: (ctx) => ctx.triggers?.timeDelay?.triggered ++ } ++ }); ++ ++ // Configure globally ++ experiences.config({ ++ timeDelay: { delay: 5000 } ++ }); +``` + +**Key Differences:** +- ✅ **Millisecond precision:** Pathfora used seconds +- ✅ **Page Visibility API:** Pauses when tab hidden +- ✅ **Active vs total time:** Track both separately +- ✅ **Hide after support:** Auto-hide experiences +- ✅ **Remaining time API:** Know how long until trigger +- ✅ **Reset capability:** Re-trigger after reset + +--- + +## Combining Display Conditions + +Display condition plugins can be combined using custom targeting logic: diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index 498d0bf..998057d 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -1,6 +1,14 @@ import { SDK } from '@lytics/sdk-kit'; import { storagePlugin } from '@lytics/sdk-kit-plugins'; -import { bannerPlugin, debugPlugin, frequencyPlugin } from '@prosdevlab/experience-sdk-plugins'; +import { + bannerPlugin, + debugPlugin, + exitIntentPlugin, + frequencyPlugin, + pageVisitsPlugin, + scrollDepthPlugin, + timeDelayPlugin, +} from '@prosdevlab/experience-sdk-plugins'; import type { Context, Decision, @@ -28,6 +36,7 @@ export class ExperienceRuntime { private decisions: Decision[] = []; private initialized = false; private destroyed = false; + private triggerContext: Context = { triggers: {} }; constructor(config: ExperienceConfig = {}) { // Create SDK instance @@ -40,7 +49,37 @@ export class ExperienceRuntime { this.sdk.use(storagePlugin); this.sdk.use(debugPlugin); this.sdk.use(frequencyPlugin); + this.sdk.use(exitIntentPlugin); + this.sdk.use(scrollDepthPlugin); + this.sdk.use(pageVisitsPlugin); + this.sdk.use(timeDelayPlugin); this.sdk.use(bannerPlugin); + + // Listen for trigger events from display condition plugins + this.setupTriggerListeners(); + } + + /** + * Setup listeners for trigger:* events + * This enables event-driven display conditions + */ + private setupTriggerListeners(): void { + // Listen for all trigger:* events using wildcard + // When a trigger fires, update context and re-evaluate + this.sdk.on('trigger:*', (eventName: string, data: any) => { + const triggerName = eventName.replace('trigger:', ''); + + // Update trigger context + this.triggerContext.triggers = this.triggerContext.triggers || {}; + this.triggerContext.triggers[triggerName] = { + triggered: true, + timestamp: Date.now(), + ...data, // Merge trigger-specific data + }; + + // Re-evaluate all experiences with updated context + this.evaluate(this.triggerContext); + }); } /** @@ -63,6 +102,10 @@ export class ExperienceRuntime { this.sdk.use(storagePlugin); this.sdk.use(debugPlugin); this.sdk.use(frequencyPlugin); + this.sdk.use(exitIntentPlugin); + this.sdk.use(scrollDepthPlugin); + this.sdk.use(pageVisitsPlugin); + this.sdk.use(timeDelayPlugin); this.sdk.use(bannerPlugin); this.destroyed = false; @@ -359,6 +402,7 @@ export function buildContext(partial?: Partial): Context { timestamp: Date.now(), user: partial?.user, custom: partial?.custom, + triggers: partial?.triggers ?? {}, // Include triggers }; } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 06df9c5..44fb292 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -124,6 +124,60 @@ export interface Context { timestamp?: number; /** Custom context properties */ custom?: Record; + /** Trigger state (for display condition plugins) */ + triggers?: TriggerState; +} + +/** + * Trigger State + * + * Tracks which trigger-based display conditions have fired. + * Used by plugins like exitIntent, scrollDepth, pageVisits, timeDelay. + */ +export interface TriggerState { + /** Exit intent trigger state */ + exitIntent?: { + /** Whether the trigger has fired */ + triggered: boolean; + /** When the trigger fired (unix timestamp) */ + timestamp?: number; + /** Additional trigger-specific data */ + lastY?: number; + previousY?: number; + velocity?: number; + timeOnPage?: number; + }; + /** Scroll depth trigger state */ + scrollDepth?: { + triggered: boolean; + timestamp?: number; + /** Current scroll percentage (0-100) */ + percent?: number; + }; + /** Page visits trigger state */ + pageVisits?: { + triggered: boolean; + timestamp?: number; + /** Total visit count */ + count?: number; + /** Whether this is the first visit */ + firstVisit?: boolean; + }; + /** Time delay trigger state */ + timeDelay?: { + triggered: boolean; + timestamp?: number; + /** Total elapsed time (ms, includes paused time) */ + elapsed?: number; + /** Active elapsed time (ms, excludes paused time) */ + activeElapsed?: number; + /** Whether timer was paused */ + wasPaused?: boolean; + /** Number of visibility changes */ + visibilityChanges?: number; + }; + /** Extensible for future triggers */ + [key: string]: any; } /** diff --git a/packages/plugins/src/exit-intent/exit-intent.test.ts b/packages/plugins/src/exit-intent/exit-intent.test.ts new file mode 100644 index 0000000..5109ba4 --- /dev/null +++ b/packages/plugins/src/exit-intent/exit-intent.test.ts @@ -0,0 +1,423 @@ +// packages/plugins/src/exit-intent/exit-intent.test.ts + +import { SDK } from '@lytics/sdk-kit'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { exitIntentPlugin } from './index'; + +describe('Exit Intent Plugin', () => { + let sdk: SDK; + let mouseEventListeners: Record = {}; + + beforeEach(() => { + // Clear sessionStorage + sessionStorage.clear(); + + // Create fresh SDK instance + sdk = new SDK({ name: 'test-sdk' }); + + // Mock document event listeners + mouseEventListeners = {}; + vi.spyOn(document, 'addEventListener').mockImplementation((event: string, handler: any) => { + mouseEventListeners[event] = handler; + }); + + vi.spyOn(document, 'removeEventListener').mockImplementation((event: string) => { + delete mouseEventListeners[event]; + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // Helper to initialize plugin with config + async function initPlugin(config: any = { sensitivity: 50, minTimeOnPage: 0 }) { + sdk.set('exitIntent', config); + sdk.use(exitIntentPlugin); + await sdk.init(); + } + + describe('Plugin Initialization', () => { + it('should register exitIntent namespace', async () => { + await initPlugin(); + expect((sdk as any).exitIntent).toBeDefined(); + }); + + it('should set up mouse event listeners', async () => { + await initPlugin({ sensitivity: 20 }); + expect(mouseEventListeners.mousemove).toBeDefined(); + expect(mouseEventListeners.mouseout).toBeDefined(); + }); + + it('should not initialize on mobile devices', async () => { + const originalUserAgent = navigator.userAgent; + Object.defineProperty(navigator, 'userAgent', { + value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)', + configurable: true, + }); + + await initPlugin({ disableOnMobile: true }); + + expect(mouseEventListeners.mousemove).toBeUndefined(); + expect(mouseEventListeners.mouseout).toBeUndefined(); + + Object.defineProperty(navigator, 'userAgent', { + value: originalUserAgent, + configurable: true, + }); + }); + + it('should initialize on mobile if disableOnMobile is false', async () => { + const originalUserAgent = navigator.userAgent; + Object.defineProperty(navigator, 'userAgent', { + value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)', + configurable: true, + }); + + await initPlugin({ disableOnMobile: false }); + + expect(mouseEventListeners.mousemove).toBeDefined(); + expect(mouseEventListeners.mouseout).toBeDefined(); + + Object.defineProperty(navigator, 'userAgent', { + value: originalUserAgent, + configurable: true, + }); + }); + + it('should not initialize if already triggered this session', async () => { + sessionStorage.setItem('xp:exitIntent:triggered', Date.now().toString()); + + await initPlugin(); + + expect(mouseEventListeners.mousemove).toBeUndefined(); + expect(mouseEventListeners.mouseout).toBeUndefined(); + }); + }); + + describe('Mouse Position Tracking', () => { + it('should track mouse positions', async () => { + await initPlugin(); + + const mouseMoveHandler = mouseEventListeners.mousemove; + mouseMoveHandler(new MouseEvent('mousemove', { clientX: 100, clientY: 200 })); + mouseMoveHandler(new MouseEvent('mousemove', { clientX: 150, clientY: 250 })); + + const positions = (sdk as any).exitIntent.getPositions(); + expect(positions).toHaveLength(2); + expect(positions[0]).toEqual({ x: 100, y: 200 }); + expect(positions[1]).toEqual({ x: 150, y: 250 }); + }); + + it('should limit position history to configured size', async () => { + await initPlugin({ positionHistorySize: 3 }); + + const mouseMoveHandler = mouseEventListeners.mousemove; + for (let i = 0; i < 5; i++) { + mouseMoveHandler(new MouseEvent('mousemove', { clientX: i * 10, clientY: i * 10 })); + } + + const positions = (sdk as any).exitIntent.getPositions(); + expect(positions).toHaveLength(3); + expect(positions[0]).toEqual({ x: 20, y: 20 }); + expect(positions[1]).toEqual({ x: 30, y: 30 }); + expect(positions[2]).toEqual({ x: 40, y: 40 }); + }); + }); + + describe('Exit Intent Detection (Pathfora Test Cases)', () => { + it('should NOT trigger immediately on page load', async () => { + const triggerSpy = vi.fn(); + sdk.on('trigger:exitIntent', triggerSpy); + + await initPlugin(); + + expect(triggerSpy).not.toHaveBeenCalled(); + }); + + it('should NOT trigger when exiting from left edge', async () => { + const triggerSpy = vi.fn(); + sdk.on('trigger:exitIntent', triggerSpy); + + await initPlugin(); + + const mouseMoveHandler = mouseEventListeners.mousemove; + const mouseOutHandler = mouseEventListeners.mouseout; + + mouseMoveHandler(new MouseEvent('mousemove', { clientX: 100, clientY: 300 })); + mouseMoveHandler(new MouseEvent('mousemove', { clientX: 50, clientY: 300 })); + mouseMoveHandler(new MouseEvent('mousemove', { clientX: 10, clientY: 300 })); + + mouseOutHandler( + new MouseEvent('mouseout', { clientX: 0, clientY: 300, relatedTarget: null }) + ); + + expect(triggerSpy).not.toHaveBeenCalled(); + }); + + it('should NOT trigger when exiting from bottom', async () => { + const triggerSpy = vi.fn(); + sdk.on('trigger:exitIntent', triggerSpy); + + await initPlugin(); + + const mouseMoveHandler = mouseEventListeners.mousemove; + const mouseOutHandler = mouseEventListeners.mouseout; + + mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 300 })); + mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 500 })); + mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 700 })); + + mouseOutHandler( + new MouseEvent('mouseout', { clientX: 500, clientY: 800, relatedTarget: null }) + ); + + expect(triggerSpy).not.toHaveBeenCalled(); + }); + + it('should NOT trigger on downward movement', async () => { + const triggerSpy = vi.fn(); + sdk.on('trigger:exitIntent', triggerSpy); + + await initPlugin(); + + const mouseMoveHandler = mouseEventListeners.mousemove; + const mouseOutHandler = mouseEventListeners.mouseout; + + mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 10 })); + mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 30 })); + + mouseOutHandler( + new MouseEvent('mouseout', { clientX: 500, clientY: 50, relatedTarget: null }) + ); + + expect(triggerSpy).not.toHaveBeenCalled(); + }); + + it('SHOULD trigger on upward movement + top exit (Pathfora algorithm)', async () => { + const triggerSpy = vi.fn(); + sdk.on('trigger:exitIntent', triggerSpy); + + await initPlugin(); + + const mouseMoveHandler = mouseEventListeners.mousemove; + const mouseOutHandler = mouseEventListeners.mouseout; + + mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 200 })); + mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 40 })); + + mouseOutHandler( + new MouseEvent('mouseout', { clientX: 500, clientY: 5, relatedTarget: null }) + ); + + expect(triggerSpy).toHaveBeenCalledTimes(1); + }); + + it('should respect min time on page setting', async () => { + const triggerSpy = vi.fn(); + sdk.on('trigger:exitIntent', triggerSpy); + + await initPlugin({ sensitivity: 50, minTimeOnPage: 2000 }); + + const mouseMoveHandler = mouseEventListeners.mousemove; + const mouseOutHandler = mouseEventListeners.mouseout; + + // Try immediately (should fail) + mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 100 })); + mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 40 })); + mouseOutHandler( + new MouseEvent('mouseout', { clientX: 500, clientY: 5, relatedTarget: null }) + ); + + expect(triggerSpy).not.toHaveBeenCalled(); + + // Wait and try again + await new Promise((resolve) => setTimeout(resolve, 2100)); + + mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 100 })); + mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 40 })); + mouseOutHandler( + new MouseEvent('mouseout', { clientX: 500, clientY: 5, relatedTarget: null }) + ); + + expect(triggerSpy).toHaveBeenCalledTimes(1); + }); + + it('should trigger only once per session', async () => { + const triggerSpy = vi.fn(); + sdk.on('trigger:exitIntent', triggerSpy); + + await initPlugin(); + + const mouseMoveHandler = mouseEventListeners.mousemove; + const mouseOutHandler = mouseEventListeners.mouseout; + + // First trigger + mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 100 })); + mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 40 })); + mouseOutHandler( + new MouseEvent('mouseout', { clientX: 500, clientY: 5, relatedTarget: null }) + ); + + expect(triggerSpy).toHaveBeenCalledTimes(1); + + // Try again + mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 100 })); + mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 40 })); + mouseOutHandler( + new MouseEvent('mouseout', { clientX: 500, clientY: 5, relatedTarget: null }) + ); + + expect(triggerSpy).toHaveBeenCalledTimes(1); // Still 1 + }); + + it('should respect configurable sensitivity threshold', async () => { + const triggerSpy = vi.fn(); + sdk.on('trigger:exitIntent', triggerSpy); + + await initPlugin({ sensitivity: 20, minTimeOnPage: 0 }); + + const mouseMoveHandler = mouseEventListeners.mousemove; + const mouseOutHandler = mouseEventListeners.mouseout; + + // Far from top with slow movement - should NOT trigger + // y=100, py=110, velocity=10 + // 100 - 10 = 90, which is > 20, so won't trigger + mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 110 })); + mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 100 })); + mouseOutHandler( + new MouseEvent('mouseout', { clientX: 500, clientY: 95, relatedTarget: null }) + ); + + expect(triggerSpy).not.toHaveBeenCalled(); + + // Near top with upward movement - SHOULD trigger + // y=10, py=30, velocity=20 + // 10 - 20 = -10, which is <= 20, so will trigger + mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 30 })); + mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 10 })); + mouseOutHandler( + new MouseEvent('mouseout', { clientX: 500, clientY: 5, relatedTarget: null }) + ); + + expect(triggerSpy).toHaveBeenCalledTimes(1); + }); + + it('should clean up event listeners after trigger', async () => { + await initPlugin(); + + const mouseMoveHandler = mouseEventListeners.mousemove; + const mouseOutHandler = mouseEventListeners.mouseout; + + expect(mouseMoveHandler).toBeDefined(); + expect(mouseOutHandler).toBeDefined(); + + // Trigger exit intent + mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 100 })); + mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 40 })); + mouseOutHandler( + new MouseEvent('mouseout', { clientX: 500, clientY: 5, relatedTarget: null }) + ); + + // Listeners should be removed + expect(mouseEventListeners.mousemove).toBeUndefined(); + expect(mouseEventListeners.mouseout).toBeUndefined(); + }); + + it('should apply delay before emitting trigger event', async () => { + const triggerSpy = vi.fn(); + sdk.on('trigger:exitIntent', triggerSpy); + + await initPlugin({ sensitivity: 50, minTimeOnPage: 0, delay: 1000 }); + + const mouseMoveHandler = mouseEventListeners.mousemove; + const mouseOutHandler = mouseEventListeners.mouseout; + + // Trigger exit intent + mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 100 })); + mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 40 })); + mouseOutHandler( + new MouseEvent('mouseout', { clientX: 500, clientY: 5, relatedTarget: null }) + ); + + // Should not trigger immediately + expect(triggerSpy).not.toHaveBeenCalled(); + + // Wait for delay + await new Promise((resolve) => setTimeout(resolve, 1100)); + + expect(triggerSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('API Methods', () => { + it('should expose isTriggered() method', async () => { + await initPlugin(); + + expect((sdk as any).exitIntent.isTriggered()).toBe(false); + + const mouseMoveHandler = mouseEventListeners.mousemove; + const mouseOutHandler = mouseEventListeners.mouseout; + + mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 100 })); + mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 40 })); + mouseOutHandler( + new MouseEvent('mouseout', { clientX: 500, clientY: 5, relatedTarget: null }) + ); + + expect((sdk as any).exitIntent.isTriggered()).toBe(true); + }); + + it('should expose reset() method for testing', async () => { + await initPlugin(); + + const mouseMoveHandler = mouseEventListeners.mousemove; + const mouseOutHandler = mouseEventListeners.mouseout; + + mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 100 })); + mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 40 })); + mouseOutHandler( + new MouseEvent('mouseout', { clientX: 500, clientY: 5, relatedTarget: null }) + ); + + expect((sdk as any).exitIntent.isTriggered()).toBe(true); + + // Reset + (sdk as any).exitIntent.reset(); + + expect((sdk as any).exitIntent.isTriggered()).toBe(false); + expect((sdk as any).exitIntent.getPositions()).toHaveLength(0); + }); + + it('should expose getPositions() method', async () => { + await initPlugin(); + + const mouseMoveHandler = mouseEventListeners.mousemove; + mouseMoveHandler(new MouseEvent('mousemove', { clientX: 100, clientY: 200 })); + mouseMoveHandler(new MouseEvent('mousemove', { clientX: 150, clientY: 250 })); + + const positions = (sdk as any).exitIntent.getPositions(); + expect(positions).toEqual([ + { x: 100, y: 200 }, + { x: 150, y: 250 }, + ]); + }); + }); + + describe('SessionStorage Persistence', () => { + it('should store trigger state in sessionStorage', async () => { + await initPlugin(); + + const mouseMoveHandler = mouseEventListeners.mousemove; + const mouseOutHandler = mouseEventListeners.mouseout; + + mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 100 })); + mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 40 })); + mouseOutHandler( + new MouseEvent('mouseout', { clientX: 500, clientY: 5, relatedTarget: null }) + ); + + expect(sessionStorage.getItem('xp:exitIntent:triggered')).toBeTruthy(); + }); + }); +}); diff --git a/packages/plugins/src/exit-intent/exit-intent.ts b/packages/plugins/src/exit-intent/exit-intent.ts new file mode 100644 index 0000000..3b38b45 --- /dev/null +++ b/packages/plugins/src/exit-intent/exit-intent.ts @@ -0,0 +1,372 @@ +// packages/plugins/src/exit-intent/exit-intent.ts + +import type { PluginFunction } from '@lytics/sdk-kit'; +import type { ExitIntentEvent, ExitIntentPlugin, ExitIntentPluginConfig } from './types'; + +/** + * Position in history + */ +interface Position { + x: number; + y: number; +} + +/** + * Pure function: Check if device is mobile + */ +export function isMobileDevice(userAgent: string): boolean { + return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(userAgent); +} + +/** + * Pure function: Check if minimum time has elapsed + */ +export function hasMinTimeElapsed( + pageLoadTime: number, + minTime: number, + currentTime: number +): boolean { + return currentTime - pageLoadTime >= minTime; +} + +/** + * Pure function: Add position to history (immutable) + */ +export function addPositionToHistory( + positions: Position[], + newPosition: Position, + maxSize: number +): Position[] { + const updated = [...positions, newPosition]; + if (updated.length > maxSize) { + return updated.slice(1); // Remove oldest + } + return updated; +} + +/** + * Pure function: Calculate velocity from two Y positions + */ +export function calculateVelocity(lastY: number, previousY: number): number { + return Math.abs(lastY - previousY); +} + +/** + * Pure function: Check if exit intent should trigger based on Pathfora algorithm + */ +export function shouldTriggerExitIntent( + positions: Position[], + sensitivity: number, + relatedTarget: EventTarget | null +): { shouldTrigger: boolean; lastY: number; previousY: number; velocity: number } { + // Must have movement history + if (positions.length < 2) { + return { shouldTrigger: false, lastY: 0, previousY: 0, velocity: 0 }; + } + + // Check if leaving the document (mouse entering browser chrome) + // relatedTarget is null when leaving to browser UI + if (relatedTarget && (relatedTarget as any).nodeName !== 'HTML') { + return { shouldTrigger: false, lastY: 0, previousY: 0, velocity: 0 }; + } + + // Get last two positions + const lastY = positions[positions.length - 1].y; + const previousY = positions[positions.length - 2].y; + + // Calculate velocity (speed of upward movement) + const velocity = calculateVelocity(lastY, previousY); + + // Check if moving up and near top (Pathfora algorithm) + const isMovingUp = lastY < previousY; + const isNearTop = lastY - velocity <= sensitivity; + + return { + shouldTrigger: isMovingUp && isNearTop, + lastY, + previousY, + velocity, + }; +} + +/** + * Pure function: Create exit intent event payload + */ +export function createExitIntentEvent( + lastY: number, + previousY: number, + velocity: number, + pageLoadTime: number, + timestamp: number +): ExitIntentEvent { + return { + timestamp, + lastY, + previousY, + velocity, + timeOnPage: timestamp - pageLoadTime, + }; +} + +/** + * Exit Intent Plugin + * + * Detects when users are about to leave the page by tracking upward mouse movement + * near the top of the viewport. Inspired by Pathfora's showOnExitIntent. + * + * **Event-Driven Architecture:** + * This plugin emits `trigger:exitIntent` events when exit intent is detected. + * The core runtime listens for these events and automatically re-evaluates experiences. + * + * **Usage Pattern:** + * Use `targeting.custom` to check if exit intent has triggered: + * + * @example Basic usage + * ```typescript + * import { init, register } from '@prosdevlab/experience-sdk'; + * import { exitIntentPlugin } from '@prosdevlab/experience-sdk-plugins'; + * + * init({ + * plugins: [exitIntentPlugin], + * exitIntent: { + * sensitivity: 20, // Trigger within 20px of top (default: 50) + * minTimeOnPage: 2000, // Wait 2s before enabling (default: 2000) + * delay: 0, // Delay after trigger (default: 0) + * disableOnMobile: true // Disable on mobile (default: true) + * } + * }); + * + * // Show banner only when exit intent is detected + * register('exit-offer', { + * type: 'banner', + * content: { + * title: 'Wait! Don't leave yet!', + * message: 'Get 15% off your first order', + * buttons: [{ text: 'Claim Offer', variant: 'primary' }] + * }, + * targeting: { + * custom: (context) => context.triggers?.exitIntent?.triggered === true + * }, + * frequency: { max: 1, per: 'session' } // Only show once per session + * }); + * ``` + * + * @example Combining with other conditions + * ```typescript + * // Show exit offer only on shop pages with items in cart + * register('cart-recovery', { + * type: 'banner', + * content: { message: 'Complete your purchase and save!' }, + * targeting: { + * url: { contains: '/shop' }, + * custom: (context) => { + * return ( + * context.triggers?.exitIntent?.triggered === true && + * getCart().items.length > 0 + * ); + * } + * } + * }); + * ``` + * + * @example Combining multiple triggers (exit intent + scroll depth) + * ```typescript + * // Show offer on exit intent OR after 70% scroll + * register('engaged-exit', { + * type: 'banner', + * content: { message: 'You're almost there!' }, + * targeting: { + * custom: (context) => { + * const exitIntent = context.triggers?.exitIntent?.triggered; + * const scrolled = (context.triggers?.scrollDepth?.percent || 0) >= 70; + * return exitIntent || scrolled; + * } + * } + * }); + * ``` + */ +export const exitIntentPlugin: PluginFunction = (plugin, instance, config) => { + plugin.ns('experiences.exitIntent'); + + // Default configuration + plugin.defaults({ + exitIntent: { + sensitivity: 50, + minTimeOnPage: 2000, + delay: 0, + positionHistorySize: 30, + disableOnMobile: true, + }, + }); + + const exitIntentConfig = config.get('exitIntent'); + + if (!exitIntentConfig) { + return; + } + + // State + let positions: Position[] = []; + let triggered = false; + const pageLoadTime = Date.now(); + let mouseMoveListener: ((e: MouseEvent) => void) | null = null; + let mouseOutListener: ((e: MouseEvent) => void) | null = null; + + /** + * Check if exit intent should be disabled (uses pure function) + */ + function shouldDisable(): boolean { + if (!exitIntentConfig?.disableOnMobile) { + return false; + } + return isMobileDevice(navigator.userAgent); + } + + /** + * Track mouse position (updates state immutably using pure function) + */ + function trackPosition(e: MouseEvent): void { + const newPosition = { x: e.clientX, y: e.clientY }; + const maxSize = exitIntentConfig?.positionHistorySize ?? 30; + positions = addPositionToHistory(positions, newPosition, maxSize); + } + + /** + * Handle exit intent trigger + */ + function handleExitIntent(e: MouseEvent): void { + // Check if already triggered + if (triggered) { + return; + } + + // Check minimum time on page using pure function + const minTime = exitIntentConfig?.minTimeOnPage ?? 2000; + if (!hasMinTimeElapsed(pageLoadTime, minTime, Date.now())) { + return; + } + + // Check if should trigger using pure function + const sensitivity = exitIntentConfig?.sensitivity ?? 50; + const relatedTarget = (e as any).relatedTarget || (e as any).toElement; + const result = shouldTriggerExitIntent(positions, sensitivity, relatedTarget); + + if (result.shouldTrigger) { + triggered = true; + + // Create event payload using pure function + const eventPayload = createExitIntentEvent( + result.lastY, + result.previousY, + result.velocity, + pageLoadTime, + Date.now() + ); + + // Apply delay if configured + const delay = exitIntentConfig?.delay ?? 0; + + if (delay > 0) { + setTimeout(() => { + // Emit trigger event (Core will handle evaluation) + instance.emit('trigger:exitIntent', eventPayload); + }, delay); + } else { + // Emit trigger event (Core will handle evaluation) + instance.emit('trigger:exitIntent', eventPayload); + } + + // Store in sessionStorage to prevent re-triggering + try { + sessionStorage.setItem('xp:exitIntent:triggered', Date.now().toString()); + } catch (_e) { + // Ignore sessionStorage errors (e.g., in incognito mode) + } + + // Cleanup listeners after trigger (one-time event) + cleanup(); + } + } + + /** + * Cleanup event listeners + */ + function cleanup(): void { + if (mouseMoveListener) { + document.removeEventListener('mousemove', mouseMoveListener); + mouseMoveListener = null; + } + if (mouseOutListener) { + document.removeEventListener('mouseout', mouseOutListener); + mouseOutListener = null; + } + } + + /** + * Initialize exit intent detection + */ + function initialize(): void { + if (shouldDisable()) { + return; + } + + // Check if exit intent was already triggered this session + try { + const storedTrigger = sessionStorage.getItem('xp:exitIntent:triggered'); + if (storedTrigger) { + triggered = true; + return; // Don't set up listeners if already triggered + } + } catch (_e) { + // Ignore sessionStorage errors (e.g., in incognito mode) + } + + mouseMoveListener = trackPosition; + mouseOutListener = handleExitIntent; + + document.addEventListener('mousemove', mouseMoveListener); + document.addEventListener('mouseout', mouseOutListener); + } + + // Expose API + plugin.expose({ + exitIntent: { + /** + * Check if exit intent has been triggered + */ + isTriggered: () => triggered, + + /** + * Reset exit intent state (useful for testing) + */ + reset: () => { + triggered = false; + positions = []; + + // Clear sessionStorage + try { + sessionStorage.removeItem('xp:exitIntent:triggered'); + } catch (_e) { + // Ignore sessionStorage errors + } + + cleanup(); + initialize(); + }, + + /** + * Get current position history + */ + getPositions: () => [...positions], + } satisfies ExitIntentPlugin, + }); + + // Initialize on plugin load + initialize(); + + // Cleanup on instance destroy + const destroyHandler = () => { + cleanup(); + }; + instance.on('destroy', destroyHandler); +}; diff --git a/packages/plugins/src/exit-intent/index.ts b/packages/plugins/src/exit-intent/index.ts new file mode 100644 index 0000000..a3e188b --- /dev/null +++ b/packages/plugins/src/exit-intent/index.ts @@ -0,0 +1,6 @@ +/** + * Exit Intent Plugin - Barrel Export + */ + +export { exitIntentPlugin } from './exit-intent'; +export type { ExitIntentEvent, ExitIntentPlugin, ExitIntentPluginConfig } from './types'; diff --git a/packages/plugins/src/exit-intent/types.ts b/packages/plugins/src/exit-intent/types.ts new file mode 100644 index 0000000..8d55e87 --- /dev/null +++ b/packages/plugins/src/exit-intent/types.ts @@ -0,0 +1,59 @@ +// packages/plugins/src/exit-intent/types.ts + +/** + * Exit Intent Plugin Configuration + */ +export interface ExitIntentPluginConfig { + exitIntent?: { + /** + * Maximum Y position (px) where exit intent can trigger + * @default 50 + */ + sensitivity?: number; + + /** + * Minimum time on page (ms) before exit intent is active + * Prevents immediate triggers on page load + * @default 2000 + */ + minTimeOnPage?: number; + + /** + * Delay (ms) between detection and trigger + * @default 0 + */ + delay?: number; + + /** + * Number of mouse positions to track for velocity calculation + * @default 30 + */ + positionHistorySize?: number; + + /** + * Disable exit intent on mobile devices + * @default true + */ + disableOnMobile?: boolean; + }; +} + +/** + * Exit Intent Event Payload + */ +export interface ExitIntentEvent { + timestamp: number; + lastY: number; // Last Y position before exit + previousY: number; // Previous Y position + velocity: number; // Calculated Y velocity + timeOnPage: number; // Ms user was on page +} + +/** + * Exit Intent Plugin API + */ +export interface ExitIntentPlugin { + isTriggered(): boolean; + reset(): void; + getPositions(): Array<{ x: number; y: number }>; +} diff --git a/packages/plugins/src/index.ts b/packages/plugins/src/index.ts index c54eb1d..bbfe79c 100644 --- a/packages/plugins/src/index.ts +++ b/packages/plugins/src/index.ts @@ -8,7 +8,12 @@ export * from './banner'; // Export plugins export * from './debug'; +export * from './exit-intent'; export * from './frequency'; +export * from './page-visits'; +export * from './scroll-depth'; +export * from './time-delay'; + // Export shared types export type { BannerContent, diff --git a/packages/plugins/src/integration.test.ts b/packages/plugins/src/integration.test.ts new file mode 100644 index 0000000..4a721fe --- /dev/null +++ b/packages/plugins/src/integration.test.ts @@ -0,0 +1,362 @@ +/** + * Integration Tests - Display Condition Plugins + * + * Tests all 4 display condition plugins working together: + * - Exit Intent + * - Scroll Depth + * - Page Visits + * - Time Delay + */ + +import { SDK } from '@lytics/sdk-kit'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { exitIntentPlugin, pageVisitsPlugin, scrollDepthPlugin, timeDelayPlugin } from './index'; + +// Type augmentation for plugin APIs +type SDKWithPlugins = SDK & { + exitIntent?: any; + scrollDepth?: any; + pageVisits?: any; + timeDelay?: any; +}; + +describe('Display Condition Plugins - Integration', () => { + beforeEach(() => { + vi.useFakeTimers(); + // Reset document state + Object.defineProperty(document, 'hidden', { + writable: true, + configurable: true, + value: false, + }); + // Clear storage + sessionStorage.clear(); + localStorage.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + describe('Plugin Composition', () => { + it('should load all 4 plugins without conflicts', async () => { + const sdk = new SDK({ + exitIntent: { sensitivity: 50, minTimeOnPage: 0, disableOnMobile: false }, + scrollDepth: { thresholds: [25, 50, 75], throttle: 100 }, + pageVisits: { enabled: true }, + timeDelay: { delay: 5000, pauseWhenHidden: false }, + }) as SDKWithPlugins; + + sdk.use(exitIntentPlugin); + sdk.use(scrollDepthPlugin); + sdk.use(pageVisitsPlugin); + sdk.use(timeDelayPlugin); + + await sdk.init(); + + // All plugins should expose their APIs + expect(sdk.exitIntent).toBeDefined(); + expect(sdk.scrollDepth).toBeDefined(); + expect(sdk.pageVisits).toBeDefined(); + expect(sdk.timeDelay).toBeDefined(); + }); + + it('should handle multiple triggers firing independently', async () => { + const events: Array<{ type: string; data: any }> = []; + + const sdk = new SDK({ + exitIntent: { sensitivity: 50, minTimeOnPage: 0, disableOnMobile: false }, + scrollDepth: { thresholds: [50], throttle: 100 }, + pageVisits: { enabled: true, autoIncrement: true }, + timeDelay: { delay: 2000, pauseWhenHidden: false }, + }) as SDKWithPlugins; + + sdk.use(exitIntentPlugin); + sdk.use(scrollDepthPlugin); + sdk.use(pageVisitsPlugin); + sdk.use(timeDelayPlugin); + + // Listen to all trigger events + sdk.on('trigger:exitIntent', (data) => events.push({ type: 'exitIntent', data })); + sdk.on('trigger:scrollDepth', (data) => events.push({ type: 'scrollDepth', data })); + sdk.on('trigger:timeDelay', (data) => events.push({ type: 'timeDelay', data })); + sdk.on('pageVisits:incremented', (data) => events.push({ type: 'pageVisits', data })); + + await sdk.init(); + + // Page visits should fire on init + expect(events.some((e) => e.type === 'pageVisits')).toBe(true); + + // Time delay should fire after 2s + vi.advanceTimersByTime(2000); + expect(events.some((e) => e.type === 'timeDelay')).toBe(true); + + // All events should be distinct + const types = new Set(events.map((e) => e.type)); + expect(types.size).toBeGreaterThan(1); + }); + + it('should update context correctly for all triggers', async () => { + const contextUpdates: any[] = []; + + const sdk = new SDK({ + exitIntent: { sensitivity: 50, minTimeOnPage: 0, disableOnMobile: false }, + scrollDepth: { thresholds: [50], throttle: 100 }, + pageVisits: { enabled: true }, + timeDelay: { delay: 1000, pauseWhenHidden: false }, + }) as SDKWithPlugins; + + sdk.use(exitIntentPlugin); + sdk.use(scrollDepthPlugin); + sdk.use(pageVisitsPlugin); + sdk.use(timeDelayPlugin); + + // Capture context after each trigger + sdk.on('trigger:exitIntent', () => { + contextUpdates.push({ trigger: 'exitIntent', timestamp: Date.now() }); + }); + sdk.on('trigger:timeDelay', () => { + contextUpdates.push({ trigger: 'timeDelay', timestamp: Date.now() }); + }); + + await sdk.init(); + + vi.advanceTimersByTime(1000); + + // Should have multiple context updates + expect(contextUpdates.length).toBeGreaterThan(0); + }); + }); + + describe('Complex Targeting Logic', () => { + it('should support AND logic (multiple conditions)', async () => { + const sdk = new SDK({ + scrollDepth: { thresholds: [50], throttle: 100 }, + timeDelay: { delay: 2000, pauseWhenHidden: false }, + }) as SDKWithPlugins; + + sdk.use(scrollDepthPlugin); + sdk.use(timeDelayPlugin); + + await sdk.init(); + + // Before: neither condition met + const scrolled50 = (sdk.scrollDepth?.getMaxPercent() || 0) >= 50; + let delayed2s = sdk.timeDelay?.isTriggered() || false; + expect(scrolled50 && delayed2s).toBe(false); + + // Advance time + vi.advanceTimersByTime(2000); + delayed2s = sdk.timeDelay?.isTriggered() || false; + + // Still false (scroll not met) + expect(scrolled50 && delayed2s).toBe(false); + }); + + it('should support OR logic (any condition)', async () => { + const sdk = new SDK({ + exitIntent: { sensitivity: 50, minTimeOnPage: 0, disableOnMobile: false }, + timeDelay: { delay: 2000, pauseWhenHidden: false }, + }) as SDKWithPlugins; + + sdk.use(exitIntentPlugin); + sdk.use(timeDelayPlugin); + + await sdk.init(); + + // Trigger time delay + vi.advanceTimersByTime(2000); + + const exitTriggered = sdk.exitIntent?.isTriggered() || false; + const timeTriggered = sdk.timeDelay?.isTriggered() || false; + + // OR logic: one is true + expect(exitTriggered || timeTriggered).toBe(true); + }); + + it('should support NOT logic (inverse conditions)', async () => { + const sdk = new SDK({ + pageVisits: { enabled: true, autoIncrement: true }, + }) as SDKWithPlugins; + + sdk.use(pageVisitsPlugin); + + await sdk.init(); + + // After init with autoIncrement, it's no longer first visit + // (count was incremented from 0 to 1) + const isFirstVisit = sdk.pageVisits?.isFirstVisit() || false; + const totalCount = sdk.pageVisits?.getTotalCount() || 0; + + expect(totalCount).toBe(1); + expect(isFirstVisit).toBe(false); // Auto-incremented, so not "first" anymore + + // Simulate second visit + sdk.pageVisits?.increment(); + const nowCount = sdk.pageVisits?.getTotalCount() || 0; + expect(nowCount).toBe(2); + }); + }); + + describe('Performance', () => { + it('should have minimal overhead with all plugins loaded', async () => { + const startTime = performance.now(); + + const sdk = new SDK({ + exitIntent: { sensitivity: 50, minTimeOnPage: 0, disableOnMobile: false }, + scrollDepth: { thresholds: [25, 50, 75], throttle: 100 }, + pageVisits: { enabled: true }, + timeDelay: { delay: 5000, pauseWhenHidden: false }, + }) as SDKWithPlugins; + + sdk.use(exitIntentPlugin); + sdk.use(scrollDepthPlugin); + sdk.use(pageVisitsPlugin); + sdk.use(timeDelayPlugin); + + await sdk.init(); + + const endTime = performance.now(); + const duration = endTime - startTime; + + // Should initialize in less than 50ms + expect(duration).toBeLessThan(50); + }); + + it('should not leak memory with multiple resets', async () => { + const sdk = new SDK({ + exitIntent: { sensitivity: 50, minTimeOnPage: 0, disableOnMobile: false }, + scrollDepth: { thresholds: [50], throttle: 100 }, + pageVisits: { enabled: true }, + timeDelay: { delay: 1000, pauseWhenHidden: false }, + }) as SDKWithPlugins; + + sdk.use(exitIntentPlugin); + sdk.use(scrollDepthPlugin); + sdk.use(pageVisitsPlugin); + sdk.use(timeDelayPlugin); + + await sdk.init(); + + // Reset all plugins multiple times + for (let i = 0; i < 100; i++) { + sdk.exitIntent?.reset(); + sdk.scrollDepth?.reset(); + sdk.pageVisits?.reset(); + sdk.timeDelay?.reset(); + } + + // Should not throw or hang + expect(sdk.exitIntent?.isTriggered()).toBe(false); + expect(sdk.scrollDepth?.getMaxPercent()).toBe(0); + expect(sdk.timeDelay?.isTriggered()).toBe(false); + }); + }); + + describe('Cleanup', () => { + it('should cleanup all plugins on destroy', async () => { + const sdk = new SDK({ + exitIntent: { sensitivity: 50, minTimeOnPage: 0, disableOnMobile: false }, + scrollDepth: { thresholds: [50], throttle: 100 }, + pageVisits: { enabled: true }, + timeDelay: { delay: 5000, pauseWhenHidden: false }, + }) as SDKWithPlugins; + + sdk.use(exitIntentPlugin); + sdk.use(scrollDepthPlugin); + sdk.use(pageVisitsPlugin); + sdk.use(timeDelayPlugin); + + await sdk.init(); + + // Destroy SDK + sdk.emit('destroy'); + + // Advance time past all delays + vi.advanceTimersByTime(10000); + + // Plugins should be cleaned up (no crashes) + expect(() => { + vi.advanceTimersByTime(1000); + }).not.toThrow(); + }); + }); + + describe('Real-World Scenarios', () => { + it('should handle "engaged user" scenario (scroll + time)', async () => { + const sdk = new SDK({ + scrollDepth: { thresholds: [50], throttle: 100 }, + timeDelay: { delay: 5000, pauseWhenHidden: false }, + }) as SDKWithPlugins; + + sdk.use(scrollDepthPlugin); + sdk.use(timeDelayPlugin); + + await sdk.init(); + + // User spends 5 seconds (time condition met) + vi.advanceTimersByTime(5000); + + const timeElapsed = sdk.timeDelay?.isTriggered() || false; + const scrolled50 = (sdk.scrollDepth?.getMaxPercent() || 0) >= 50; + + // Could show "engaged user" offer even without scroll + expect(timeElapsed).toBe(true); + expect(timeElapsed || scrolled50).toBe(true); // OR logic + }); + + it('should handle "returning visitor exit intent" scenario', async () => { + const sdk = new SDK({ + exitIntent: { sensitivity: 50, minTimeOnPage: 0, disableOnMobile: false }, + pageVisits: { enabled: true, autoIncrement: true }, + }) as SDKWithPlugins; + + sdk.use(exitIntentPlugin); + sdk.use(pageVisitsPlugin); + + await sdk.init(); + + // Simulate more visits + sdk.pageVisits?.increment(); + sdk.pageVisits?.increment(); + + const isFirstVisit = sdk.pageVisits?.isFirstVisit() || false; + const totalVisits = sdk.pageVisits?.getTotalCount() || 0; + + // After multiple increments + expect(isFirstVisit).toBe(false); + expect(totalVisits).toBeGreaterThan(1); + + // Logic for returning visitor targeting + const isReturningVisitor = !isFirstVisit && totalVisits > 2; + expect(isReturningVisitor).toBe(true); + }); + + it('should handle "first-time visitor welcome" scenario', async () => { + const sdk = new SDK({ + pageVisits: { enabled: true, autoIncrement: true }, + timeDelay: { delay: 3000, pauseWhenHidden: false }, + }) as SDKWithPlugins; + + sdk.use(pageVisitsPlugin); + sdk.use(timeDelayPlugin); + + await sdk.init(); + + const totalCount = sdk.pageVisits?.getTotalCount() || 0; + + // Should have at least 1 visit after auto-increment + expect(totalCount).toBeGreaterThanOrEqual(1); + + // Wait 3 seconds + vi.advanceTimersByTime(3000); + const timeElapsed = sdk.timeDelay?.isTriggered() || false; + expect(timeElapsed).toBe(true); + + // Logic: Show welcome after delay for low-count visitors + const shouldShowWelcome = totalCount <= 1 && timeElapsed; + expect(shouldShowWelcome).toBe(true); + }); + }); +}); diff --git a/packages/plugins/src/page-visits/index.ts b/packages/plugins/src/page-visits/index.ts new file mode 100644 index 0000000..c95ff3d --- /dev/null +++ b/packages/plugins/src/page-visits/index.ts @@ -0,0 +1,6 @@ +/** + * Page Visits Plugin - Barrel Export + */ + +export { pageVisitsPlugin } from './page-visits'; +export type { PageVisitsEvent, PageVisitsPlugin, PageVisitsPluginConfig } from './types'; diff --git a/packages/plugins/src/page-visits/page-visits.test.ts b/packages/plugins/src/page-visits/page-visits.test.ts new file mode 100644 index 0000000..6081ccc --- /dev/null +++ b/packages/plugins/src/page-visits/page-visits.test.ts @@ -0,0 +1,562 @@ +/** + * Page Visits Plugin Tests + * + * Comprehensive tests covering: + * - Session and lifetime counting + * - First-visit detection + * - Storage persistence + * - DNT (Do Not Track) support + * - API methods + * - Event emission + */ + +import { SDK } from '@lytics/sdk-kit'; +import { storagePlugin } from '@lytics/sdk-kit-plugins'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { pageVisitsPlugin } from './index'; +import type { PageVisitsEvent, PageVisitsPlugin } from './types'; + +type SDKWithPageVisits = SDK & { + pageVisits: PageVisitsPlugin; +}; + +describe('Page Visits Plugin', () => { + let sdk: SDKWithPageVisits; + + // Helper to initialize plugin with config + const initPlugin = async (config?: any) => { + sdk = new SDK({ + pageVisits: config, + storage: { backend: 'memory' }, + }) as SDKWithPageVisits; + sdk.use(storagePlugin); + sdk.use(pageVisitsPlugin); + await sdk.init(); + }; + + beforeEach(() => { + vi.clearAllMocks(); + // Clear storage + sessionStorage.clear(); + localStorage.clear(); + // Reset DNT mock + Object.defineProperty(navigator, 'doNotTrack', { + value: '0', + configurable: true, + }); + }); + + afterEach(() => { + if (sdk) { + sdk.destroy?.(); + } + }); + + describe('Default Configuration', () => { + it('should initialize with default config', async () => { + await initPlugin(); + expect(sdk.pageVisits).toBeDefined(); + }); + + it('should auto-increment on initialization', async () => { + await initPlugin(); + expect(sdk.pageVisits.getTotalCount()).toBe(1); + expect(sdk.pageVisits.getSessionCount()).toBe(1); + }); + + it('should detect first visit', async () => { + await initPlugin(); + expect(sdk.pageVisits.isFirstVisit()).toBe(false); // After increment, no longer first + }); + }); + + describe('Session Counter (sessionStorage)', () => { + it('should increment session count on each page load', async () => { + await initPlugin(); + expect(sdk.pageVisits.getSessionCount()).toBe(1); + + // Simulate second page load (reinitialize) + sdk.destroy?.(); + await initPlugin(); + expect(sdk.pageVisits.getSessionCount()).toBe(2); + }); + + it('should reset session count when sessionStorage is cleared', async () => { + await initPlugin(); + expect(sdk.pageVisits.getSessionCount()).toBe(1); + + // Clear session storage + sessionStorage.clear(); + + // Reinitialize + sdk.destroy?.(); + await initPlugin(); + expect(sdk.pageVisits.getSessionCount()).toBe(1); // Back to 1 + }); + + it('should not persist session count across tabs', async () => { + await initPlugin(); + expect(sdk.pageVisits.getSessionCount()).toBe(1); + + // Session storage is tab-specific, so count shouldn't persist + // (This is a characteristic test, not a functional test) + expect(sessionStorage.length).toBeGreaterThan(0); + }); + }); + + describe('Lifetime Counter (localStorage)', () => { + it('should increment total count on each page load', async () => { + await initPlugin(); + expect(sdk.pageVisits.getTotalCount()).toBe(1); + + // Simulate second page load (reinitialize) + sdk.destroy?.(); + await initPlugin(); + expect(sdk.pageVisits.getTotalCount()).toBe(2); + + // Third page load + sdk.destroy?.(); + await initPlugin(); + expect(sdk.pageVisits.getTotalCount()).toBe(3); + }); + + it('should persist total count in localStorage', async () => { + await initPlugin(); + expect(sdk.pageVisits.getTotalCount()).toBe(1); + + // Check localStorage directly + const stored = localStorage.getItem('pageVisits:total'); + expect(stored).toBeDefined(); + expect(stored).not.toBeNull(); + }); + + it('should store timestamps for first and last visit', async () => { + await initPlugin(); + + const firstVisitTime = sdk.pageVisits.getFirstVisitTime(); + const lastVisitTime = sdk.pageVisits.getLastVisitTime(); + + expect(firstVisitTime).toBeDefined(); + expect(lastVisitTime).toBeDefined(); + expect(typeof firstVisitTime).toBe('number'); + expect(typeof lastVisitTime).toBe('number'); + }); + + it('should keep first visit time constant across visits', async () => { + await initPlugin(); + const firstVisitTime1 = sdk.pageVisits.getFirstVisitTime(); + + // Second visit + sdk.destroy?.(); + await initPlugin(); + const firstVisitTime2 = sdk.pageVisits.getFirstVisitTime(); + + expect(firstVisitTime1).toBe(firstVisitTime2); + }); + + it('should update last visit time on each visit', async () => { + await initPlugin(); + const lastVisitTime1 = sdk.pageVisits.getLastVisitTime(); + + // Wait a bit + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Second visit + sdk.destroy?.(); + await initPlugin(); + const lastVisitTime2 = sdk.pageVisits.getLastVisitTime(); + + expect(lastVisitTime2).toBeGreaterThan(lastVisitTime1 ?? 0); + }); + }); + + describe('First Visit Detection', () => { + it('should detect first visit when no data exists', async () => { + const events: PageVisitsEvent[] = []; + sdk = new SDK({ + pageVisits: { enabled: true }, + storage: { backend: 'memory' }, + }) as SDKWithPageVisits; + sdk.use(storagePlugin); + sdk.use(pageVisitsPlugin); + + sdk.on('pageVisits:incremented', (event: PageVisitsEvent) => { + events.push(event); + }); + + await sdk.init(); + + expect(events.length).toBe(1); + expect(events[0].isFirstVisit).toBe(true); + expect(events[0].totalVisits).toBe(1); + expect(events[0].sessionVisits).toBe(1); + }); + + it('should not be first visit on subsequent loads', async () => { + // First visit + await initPlugin(); + + // Second visit - set up event listener BEFORE init + sdk.destroy?.(); + + const events: PageVisitsEvent[] = []; + sdk = new SDK({ + pageVisits: { enabled: true }, + storage: { backend: 'memory' }, + }) as SDKWithPageVisits; + sdk.use(storagePlugin); + sdk.use(pageVisitsPlugin); + + sdk.on('pageVisits:incremented', (event: PageVisitsEvent) => { + events.push(event); + }); + + await sdk.init(); + + expect(events.length).toBe(1); + expect(events[0].isFirstVisit).toBe(false); + expect(events[0].totalVisits).toBe(2); + }); + }); + + describe('DNT (Do Not Track)', () => { + it('should respect DNT when enabled', async () => { + // Mock DNT + Object.defineProperty(navigator, 'doNotTrack', { + value: '1', + configurable: true, + }); + + const events: any[] = []; + sdk = new SDK({ + pageVisits: { enabled: true, respectDNT: true }, + storage: { backend: 'memory' }, + }) as SDKWithPageVisits; + sdk.use(storagePlugin); + sdk.use(pageVisitsPlugin); + + sdk.on('pageVisits:disabled', (event: any) => { + events.push(event); + }); + + await sdk.init(); + + expect(events.length).toBe(1); + expect(events[0].reason).toBe('dnt'); + }); + + it('should not track when DNT is enabled', async () => { + // Mock DNT + Object.defineProperty(navigator, 'doNotTrack', { + value: '1', + configurable: true, + }); + + await initPlugin({ enabled: true, respectDNT: true }); + + // No tracking should occur + expect(sdk.pageVisits.getTotalCount()).toBe(0); + expect(sdk.pageVisits.getSessionCount()).toBe(0); + }); + + it('should ignore DNT when respectDNT is false', async () => { + // Mock DNT + Object.defineProperty(navigator, 'doNotTrack', { + value: '1', + configurable: true, + }); + + await initPlugin({ enabled: true, respectDNT: false }); + + // Should still track + expect(sdk.pageVisits.getTotalCount()).toBe(1); + expect(sdk.pageVisits.getSessionCount()).toBe(1); + }); + }); + + describe('API Methods', () => { + describe('getTotalCount', () => { + it('should return total visit count', async () => { + await initPlugin(); + expect(sdk.pageVisits.getTotalCount()).toBe(1); + }); + }); + + describe('getSessionCount', () => { + it('should return session visit count', async () => { + await initPlugin(); + expect(sdk.pageVisits.getSessionCount()).toBe(1); + }); + }); + + describe('isFirstVisit', () => { + it('should return false after first increment', async () => { + await initPlugin(); + expect(sdk.pageVisits.isFirstVisit()).toBe(false); + }); + }); + + describe('getFirstVisitTime', () => { + it('should return first visit timestamp', async () => { + await initPlugin(); + const time = sdk.pageVisits.getFirstVisitTime(); + expect(time).toBeDefined(); + expect(typeof time).toBe('number'); + }); + }); + + describe('getLastVisitTime', () => { + it('should return last visit timestamp', async () => { + await initPlugin(); + const time = sdk.pageVisits.getLastVisitTime(); + expect(time).toBeDefined(); + expect(typeof time).toBe('number'); + }); + }); + + describe('increment', () => { + it('should manually increment counters', async () => { + await initPlugin({ autoIncrement: false }); + expect(sdk.pageVisits.getTotalCount()).toBe(0); + + sdk.pageVisits.increment(); + expect(sdk.pageVisits.getTotalCount()).toBe(1); + expect(sdk.pageVisits.getSessionCount()).toBe(1); + }); + + it('should emit pageVisits:incremented event', async () => { + await initPlugin({ autoIncrement: false }); + + const events: PageVisitsEvent[] = []; + sdk.on('pageVisits:incremented', (event: PageVisitsEvent) => { + events.push(event); + }); + + sdk.pageVisits.increment(); + + expect(events.length).toBe(1); + expect(events[0].totalVisits).toBe(1); + expect(events[0].sessionVisits).toBe(1); + }); + }); + + describe('reset', () => { + it('should reset all counters', async () => { + await initPlugin(); + expect(sdk.pageVisits.getTotalCount()).toBe(1); + + sdk.pageVisits.reset(); + + expect(sdk.pageVisits.getTotalCount()).toBe(0); + expect(sdk.pageVisits.getSessionCount()).toBe(0); + expect(sdk.pageVisits.isFirstVisit()).toBe(false); + expect(sdk.pageVisits.getFirstVisitTime()).toBeUndefined(); + expect(sdk.pageVisits.getLastVisitTime()).toBeUndefined(); + }); + + it('should clear storage', async () => { + await initPlugin(); + sdk.pageVisits.reset(); + + const sessionData = sessionStorage.getItem('pageVisits:session'); + const totalData = localStorage.getItem('pageVisits:total'); + + expect(sessionData).toBeNull(); + expect(totalData).toBeNull(); + }); + + it('should emit pageVisits:reset event', async () => { + await initPlugin(); + + const events: any[] = []; + sdk.on('pageVisits:reset', () => { + events.push(true); + }); + + sdk.pageVisits.reset(); + + expect(events.length).toBe(1); + }); + }); + + describe('getState', () => { + it('should return full page visits state', async () => { + await initPlugin(); + + const state = sdk.pageVisits.getState(); + + expect(state).toHaveProperty('isFirstVisit'); + expect(state).toHaveProperty('totalVisits'); + expect(state).toHaveProperty('sessionVisits'); + expect(state).toHaveProperty('firstVisitTime'); + expect(state).toHaveProperty('lastVisitTime'); + expect(state).toHaveProperty('timestamp'); + }); + }); + }); + + describe('Configuration', () => { + it('should support custom storage keys', async () => { + await initPlugin({ + sessionKey: 'custom:session', + totalKey: 'custom:total', + }); + + expect(sdk.pageVisits.getTotalCount()).toBe(1); + + // Check custom keys in storage + const sessionData = sessionStorage.getItem('custom:session'); + const totalData = localStorage.getItem('custom:total'); + + expect(sessionData).toBeDefined(); + expect(totalData).toBeDefined(); + }); + + it('should support disabling via config', async () => { + const events: any[] = []; + sdk = new SDK({ + pageVisits: { enabled: false }, + storage: { backend: 'memory' }, + }) as SDKWithPageVisits; + sdk.use(storagePlugin); + sdk.use(pageVisitsPlugin); + + sdk.on('pageVisits:disabled', (event: any) => { + events.push(event); + }); + + await sdk.init(); + + expect(events.length).toBe(1); + expect(events[0].reason).toBe('config'); + }); + + it('should support disabling auto-increment', async () => { + await initPlugin({ autoIncrement: false }); + + expect(sdk.pageVisits.getTotalCount()).toBe(0); + expect(sdk.pageVisits.getSessionCount()).toBe(0); + }); + }); + + describe('Event Emission', () => { + it('should emit pageVisits:incremented with full payload', async () => { + const events: PageVisitsEvent[] = []; + sdk = new SDK({ + pageVisits: { enabled: true }, + storage: { backend: 'memory' }, + }) as SDKWithPageVisits; + sdk.use(storagePlugin); + sdk.use(pageVisitsPlugin); + + sdk.on('pageVisits:incremented', (event: PageVisitsEvent) => { + events.push(event); + }); + + await sdk.init(); + + expect(events.length).toBe(1); + expect(events[0]).toMatchObject({ + isFirstVisit: true, + totalVisits: 1, + sessionVisits: 1, + }); + expect(events[0].firstVisitTime).toBeDefined(); + expect(events[0].lastVisitTime).toBeDefined(); + expect(events[0].timestamp).toBeDefined(); + }); + }); + + describe('Integration Scenarios', () => { + it('should track session-scoped visits correctly', async () => { + // First page load + await initPlugin(); + const session1 = sdk.pageVisits.getSessionCount(); + expect(session1).toBe(1); + + // Second page load (same session) + sdk.destroy?.(); + await initPlugin(); + const session2 = sdk.pageVisits.getSessionCount(); + expect(session2).toBe(2); + + // Clear sessionStorage (simulate new session) + sessionStorage.clear(); + sdk.destroy?.(); + await initPlugin(); + const session3 = sdk.pageVisits.getSessionCount(); + expect(session3).toBe(1); // Reset + }); + + it('should track lifetime visits across sessions', async () => { + // First visit + await initPlugin(); + const total1 = sdk.pageVisits.getTotalCount(); + expect(total1).toBe(1); + + // Second visit + sdk.destroy?.(); + await initPlugin(); + const total2 = sdk.pageVisits.getTotalCount(); + expect(total2).toBe(2); + + // Clear sessionStorage (new session) but keep localStorage + sessionStorage.clear(); + sdk.destroy?.(); + await initPlugin(); + const total3 = sdk.pageVisits.getTotalCount(); + expect(total3).toBe(3); // Continues incrementing + }); + + it('should support first-visit detection', async () => { + const events: PageVisitsEvent[] = []; + sdk = new SDK({ + pageVisits: { enabled: true }, + storage: { backend: 'memory' }, + }) as SDKWithPageVisits; + sdk.use(storagePlugin); + sdk.use(pageVisitsPlugin); + + sdk.on('pageVisits:incremented', (event: PageVisitsEvent) => { + events.push(event); + }); + + await sdk.init(); + + expect(events[0].isFirstVisit).toBe(true); + }); + + it('should support all comparison operators in targeting', async () => { + await initPlugin(); + + // Simulate 5 visits + for (let i = 0; i < 4; i++) { + sdk.destroy?.(); + await initPlugin(); + } + + const count = sdk.pageVisits.getTotalCount(); + expect(count).toBe(5); + + // Support all operators for flexible targeting + expect(count >= 5).toBe(true); + expect(count === 5).toBe(true); + expect(count < 10).toBe(true); + }); + }); + + describe('Storage Backend Integration', () => { + it('should auto-load storage plugin if missing', async () => { + // Don't manually load storagePlugin + sdk = new SDK({ + pageVisits: { enabled: true }, + }) as SDKWithPageVisits; + sdk.use(pageVisitsPlugin); + + await sdk.init(); + + // Should still work (auto-loaded) + expect(sdk.pageVisits.getTotalCount()).toBe(1); + }); + }); +}); diff --git a/packages/plugins/src/page-visits/page-visits.ts b/packages/plugins/src/page-visits/page-visits.ts new file mode 100644 index 0000000..c632a2e --- /dev/null +++ b/packages/plugins/src/page-visits/page-visits.ts @@ -0,0 +1,314 @@ +/** + * Page Visits Plugin + * + * Generic page visit tracking for any SDK built on sdk-kit. + * + * Features: + * - Session-scoped counter (sessionStorage) + * - Lifetime counter with timestamps (localStorage) + * - First-visit detection + * - DNT (Do Not Track) support + * - GDPR-compliant expiration + * - Auto-loads storage plugin if missing + * + * Events emitted: + * - 'pageVisits:incremented' with PageVisitsEvent + * - 'pageVisits:reset' + * - 'pageVisits:disabled' with { reason: 'dnt' | 'config' } + * + * @example + * ```typescript + * import { SDK } from '@lytics/sdk-kit'; + * import { storagePlugin, pageVisitsPlugin } from '@lytics/sdk-kit-plugins'; + * + * const sdk = new SDK({ + * pageVisits: { + * enabled: true, + * respectDNT: true, + * ttl: 31536000 // 1 year + * } + * }); + * + * sdk.use(storagePlugin); + * sdk.use(pageVisitsPlugin); + * + * // Listen to visit events + * sdk.on('pageVisits:incremented', (event) => { + * console.log('Visit count:', event.totalVisits); + * if (event.isFirstVisit) { + * console.log('Welcome, first-time visitor!'); + * } + * }); + * + * // API methods + * console.log(sdk.pageVisits.getTotalCount()); // 5 + * console.log(sdk.pageVisits.getSessionCount()); // 2 + * console.log(sdk.pageVisits.isFirstVisit()); // false + * ``` + */ + +import type { PluginFunction, SDK } from '@lytics/sdk-kit'; +import { type StoragePlugin, storagePlugin } from '@lytics/sdk-kit-plugins'; +import type { PageVisitsEvent, PageVisitsPlugin } from './types'; + +/** + * Storage data format for lifetime visits + */ +interface TotalData { + count: number; + first: number; // Timestamp + last: number; // Timestamp +} + +/** + * Pure function: Check if Do Not Track is enabled + */ +export function respectsDNT(): boolean { + if (typeof navigator === 'undefined') return false; + return ( + navigator.doNotTrack === '1' || + (navigator as any).msDoNotTrack === '1' || + (window as any).doNotTrack === '1' + ); +} + +/** + * Pure function: Build storage key with optional prefix + */ +export function buildStorageKey(key: string, prefix?: string): string { + return prefix ? `${prefix}${key}` : key; +} + +/** + * Pure function: Create PageVisitsEvent payload + */ +export function createVisitsEvent( + isFirstVisit: boolean, + totalVisits: number, + sessionVisits: number, + firstVisitTime: number | undefined, + lastVisitTime: number | undefined, + timestamp: number +): PageVisitsEvent { + return { + isFirstVisit, + totalVisits, + sessionVisits, + firstVisitTime, + lastVisitTime, + timestamp, + }; +} + +export const pageVisitsPlugin: PluginFunction = (plugin, instance, config) => { + plugin.ns('pageVisits'); + + // Set defaults + plugin.defaults({ + pageVisits: { + enabled: true, + respectDNT: true, + sessionKey: 'pageVisits:session', + totalKey: 'pageVisits:total', + ttl: undefined, + autoIncrement: true, + }, + }); + + // Auto-load storage plugin if not present + if (!(instance as SDK & { storage?: StoragePlugin }).storage) { + console.warn('[PageVisits] Storage plugin not found, auto-loading...'); + instance.use(storagePlugin); + } + + // Cast instance to include storage + const sdkInstance = instance as SDK & { storage: StoragePlugin }; + + // Internal state + let sessionCount = 0; + let totalCount = 0; + let firstVisitTime: number | undefined; + let lastVisitTime: number | undefined; + let isFirstVisitFlag = false; + let initialized = false; + + /** + * Load existing visit data from storage + */ + function loadData(): void { + const sessionKey = config.get('pageVisits.sessionKey') ?? 'pageVisits:session'; + const totalKey = config.get('pageVisits.totalKey') ?? 'pageVisits:total'; + + // Load session count + const storedSession = sdkInstance.storage.get(sessionKey, { + backend: 'sessionStorage', + }); + sessionCount = storedSession ?? 0; + + // Load total data + const storedTotal = sdkInstance.storage.get(totalKey, { + backend: 'localStorage', + }); + + if (storedTotal) { + totalCount = storedTotal.count ?? 0; + firstVisitTime = storedTotal.first; + lastVisitTime = storedTotal.last; + isFirstVisitFlag = false; + } else { + totalCount = 0; + firstVisitTime = undefined; + lastVisitTime = undefined; + isFirstVisitFlag = true; + } + } + + /** + * Save visit data to storage + */ + function saveData(): void { + const sessionKey = config.get('pageVisits.sessionKey') ?? 'pageVisits:session'; + const totalKey = config.get('pageVisits.totalKey') ?? 'pageVisits:total'; + const ttl = config.get('pageVisits.ttl'); + + // Save session count + sdkInstance.storage.set(sessionKey, sessionCount, { + backend: 'sessionStorage', + }); + + // Save total data + const totalData: TotalData = { + count: totalCount, + first: firstVisitTime ?? Date.now(), + last: lastVisitTime ?? Date.now(), + }; + + sdkInstance.storage.set(totalKey, totalData, { + backend: 'localStorage', + ...(ttl && { ttl }), + }); + } + + /** + * Increment visit counters + */ + function increment(): void { + if (!initialized) { + loadData(); + initialized = true; + } + + // Increment counters + sessionCount += 1; + totalCount += 1; + const now = Date.now(); + + // Set first visit time if needed + if (isFirstVisitFlag) { + firstVisitTime = now; + } + + // Update last visit time + lastVisitTime = now; + + // Save to storage + saveData(); + + // Emit event using pure function + const event = createVisitsEvent( + isFirstVisitFlag, + totalCount, + sessionCount, + firstVisitTime, + lastVisitTime, + now + ); + + plugin.emit('pageVisits:incremented', event); + + // After first increment, no longer first visit + if (isFirstVisitFlag) { + isFirstVisitFlag = false; + } + } + + /** + * Reset all data + */ + function reset(): void { + const sessionKey = config.get('pageVisits.sessionKey') ?? 'pageVisits:session'; + const totalKey = config.get('pageVisits.totalKey') ?? 'pageVisits:total'; + + // Clear storage + sdkInstance.storage.remove(sessionKey, { backend: 'sessionStorage' }); + sdkInstance.storage.remove(totalKey, { backend: 'localStorage' }); + + // Reset state + sessionCount = 0; + totalCount = 0; + firstVisitTime = undefined; + lastVisitTime = undefined; + isFirstVisitFlag = false; + initialized = false; + + // Emit event + plugin.emit('pageVisits:reset'); + } + + /** + * Get current state + */ + function getState(): PageVisitsEvent { + return createVisitsEvent( + isFirstVisitFlag, + totalCount, + sessionCount, + firstVisitTime, + lastVisitTime, + Date.now() + ); + } + + /** + * Initialize plugin + */ + function initialize(): void { + const enabled = config.get('pageVisits.enabled') ?? true; + const respectDNTConfig = config.get('pageVisits.respectDNT') ?? true; + const autoIncrement = config.get('pageVisits.autoIncrement') ?? true; + + // Check DNT using pure function + if (respectDNTConfig && respectsDNT()) { + plugin.emit('pageVisits:disabled', { reason: 'dnt' }); + return; + } + + // Check enabled + if (!enabled) { + plugin.emit('pageVisits:disabled', { reason: 'config' }); + return; + } + + // Auto-increment on load + if (autoIncrement) { + increment(); + } + } + + // Initialize on SDK ready + instance.on('sdk:ready', initialize); + + // Expose public API + plugin.expose({ + pageVisits: { + getTotalCount: () => totalCount, + getSessionCount: () => sessionCount, + isFirstVisit: () => isFirstVisitFlag, + getFirstVisitTime: () => firstVisitTime, + getLastVisitTime: () => lastVisitTime, + increment, + reset, + getState, + } satisfies PageVisitsPlugin, + }); +}; diff --git a/packages/plugins/src/page-visits/types.ts b/packages/plugins/src/page-visits/types.ts new file mode 100644 index 0000000..d13bd93 --- /dev/null +++ b/packages/plugins/src/page-visits/types.ts @@ -0,0 +1,119 @@ +/** + * Page Visits Plugin Types + * + * Generic page visit tracking for any SDK built on sdk-kit. + * Tracks session and lifetime visit counts with first-visit detection. + */ + +/** + * Page visits plugin configuration + */ +export interface PageVisitsPluginConfig { + pageVisits?: { + /** + * Enable/disable page visit tracking + * @default true + */ + enabled?: boolean; + + /** + * Honor Do Not Track browser setting + * @default true + */ + respectDNT?: boolean; + + /** + * Storage key for session count + * @default 'pageVisits:session' + */ + sessionKey?: string; + + /** + * Storage key for lifetime data + * @default 'pageVisits:total' + */ + totalKey?: string; + + /** + * TTL for lifetime data in seconds (GDPR compliance) + * @default undefined (no expiration) + */ + ttl?: number; + + /** + * Automatically increment on plugin load + * @default true + */ + autoIncrement?: boolean; + }; +} + +/** + * Page visits event payload + */ +export interface PageVisitsEvent { + /** Whether this is the user's first visit ever */ + isFirstVisit: boolean; + + /** Total visits across all sessions (lifetime) */ + totalVisits: number; + + /** Visits in current session */ + sessionVisits: number; + + /** Timestamp of first visit (unix ms) */ + firstVisitTime?: number; + + /** Timestamp of last visit (unix ms) */ + lastVisitTime?: number; + + /** Timestamp of current visit (unix ms) */ + timestamp: number; +} + +/** + * Page visits plugin API + */ +export interface PageVisitsPlugin { + /** + * Get total visit count (lifetime) + */ + getTotalCount(): number; + + /** + * Get session visit count + */ + getSessionCount(): number; + + /** + * Check if this is the first visit + */ + isFirstVisit(): boolean; + + /** + * Get timestamp of first visit + */ + getFirstVisitTime(): number | undefined; + + /** + * Get timestamp of last visit + */ + getLastVisitTime(): number | undefined; + + /** + * Manually increment page visit + * (useful if autoIncrement is disabled) + */ + increment(): void; + + /** + * Reset all counters and data + * (useful for testing or user opt-out) + */ + reset(): void; + + /** + * Get full page visits state + */ + getState(): PageVisitsEvent; +} diff --git a/packages/plugins/src/scroll-depth/index.ts b/packages/plugins/src/scroll-depth/index.ts new file mode 100644 index 0000000..000abd5 --- /dev/null +++ b/packages/plugins/src/scroll-depth/index.ts @@ -0,0 +1,6 @@ +/** + * Scroll Depth Plugin - Barrel Export + */ + +export { scrollDepthPlugin } from './scroll-depth'; +export type { ScrollDepthEvent, ScrollDepthPlugin, ScrollDepthPluginConfig } from './types'; diff --git a/packages/plugins/src/scroll-depth/scroll-depth.test.ts b/packages/plugins/src/scroll-depth/scroll-depth.test.ts new file mode 100644 index 0000000..9690748 --- /dev/null +++ b/packages/plugins/src/scroll-depth/scroll-depth.test.ts @@ -0,0 +1,545 @@ +/** + * @vitest-environment jsdom + */ + +import { SDK } from '@lytics/sdk-kit'; +import { storagePlugin } from '@lytics/sdk-kit-plugins'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { scrollDepthPlugin } from './index'; +import type { ScrollDepthPluginConfig } from './types'; + +// Extend SDK type to include scrollDepth API +interface SDKWithScrollDepth extends SDK { + scrollDepth: { + getMaxPercent: () => number; + getCurrentPercent: () => number; + getThresholdsCrossed: () => number[]; + reset: () => void; + }; +} + +describe('scrollDepthPlugin', () => { + let sdk: SDKWithScrollDepth; + let scrollEventListeners: Record = {}; + let resizeEventListeners: Record = {}; + let addEventListenerSpy: any; + let _removeEventListenerSpy: any; + + /** + * Helper to initialize plugin with config + */ + const initPlugin = async (config?: ScrollDepthPluginConfig['scrollDepth']) => { + sdk = new SDK({ + name: 'test-sdk', + storage: { backend: 'memory' }, + }) as SDKWithScrollDepth; + + if (config) { + sdk.set('scrollDepth', config); + } + + sdk.use(storagePlugin); + sdk.use(scrollDepthPlugin); + await sdk.init(); + }; + + /** + * Helper to set document height and scroll position + */ + const setScrollPosition = (scrollTop: number, scrollHeight: number, clientHeight: number) => { + // Mock scrollingElement + Object.defineProperty(document, 'scrollingElement', { + writable: true, + configurable: true, + value: { + scrollTop, + scrollHeight, + clientHeight, + }, + }); + + // Also mock documentElement as fallback + Object.defineProperty(document, 'documentElement', { + writable: true, + configurable: true, + value: { + scrollTop, + scrollHeight, + clientHeight, + }, + }); + }; + + /** + * Helper to simulate scroll event + */ + const simulateScroll = (scrollTop: number, scrollHeight: number, clientHeight: number) => { + setScrollPosition(scrollTop, scrollHeight, clientHeight); + const handler = scrollEventListeners.scroll; + if (handler) { + handler(); + } + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + + // Reset event listener tracking + scrollEventListeners = {}; + resizeEventListeners = {}; + + // Spy on addEventListener/removeEventListener + addEventListenerSpy = vi + .spyOn(window, 'addEventListener') + .mockImplementation((event: string, handler: any) => { + if (event === 'scroll') { + scrollEventListeners[event] = handler; + } else if (event === 'resize') { + resizeEventListeners[event] = handler; + } + }); + + _removeEventListenerSpy = vi + .spyOn(window, 'removeEventListener') + .mockImplementation((event: string) => { + if (event === 'scroll') { + delete scrollEventListeners[event]; + } else if (event === 'resize') { + delete resizeEventListeners[event]; + } + }); + }); + + afterEach(async () => { + if (sdk) { + await sdk.destroy(); + } + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + describe('initialization', () => { + it('should register with default config', async () => { + await initPlugin(); + + expect(sdk.scrollDepth).toBeDefined(); + expect(sdk.scrollDepth.getMaxPercent).toBeDefined(); + expect(sdk.scrollDepth.getCurrentPercent).toBeDefined(); + expect(sdk.scrollDepth.getThresholdsCrossed).toBeDefined(); + expect(sdk.scrollDepth.reset).toBeDefined(); + }); + + it('should use default thresholds [25, 50, 75, 100]', async () => { + await initPlugin(); + vi.advanceTimersByTime(0); + + // No thresholds crossed initially (no scroll event yet) + expect(sdk.scrollDepth.getThresholdsCrossed()).toEqual([]); + + // After scrolling, thresholds should trigger + simulateScroll(1000, 2000, 1000); // 100% + vi.advanceTimersByTime(100); + expect(sdk.scrollDepth.getThresholdsCrossed()).toEqual([25, 50, 75, 100]); + }); + + it('should register scroll listener', async () => { + await initPlugin(); + vi.advanceTimersByTime(0); + + expect(addEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function), { + passive: true, + }); + }); + + it('should register resize listener by default', async () => { + await initPlugin(); + vi.advanceTimersByTime(0); + + expect(addEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function), { + passive: true, + }); + }); + + it('should not register resize listener when disabled', async () => { + await initPlugin({ recalculateOnResize: false }); + vi.advanceTimersByTime(0); + + const resizeCalls = addEventListenerSpy.mock.calls.filter((call) => call[0] === 'resize'); + expect(resizeCalls).toHaveLength(0); + }); + + it('should not check initial scroll position (waits for user interaction)', async () => { + // Set initial scroll to 100% + setScrollPosition(1000, 2000, 1000); + + await initPlugin(); + vi.advanceTimersByTime(0); + + // Should not trigger thresholds until first scroll event + expect(sdk.scrollDepth.getThresholdsCrossed()).toEqual([]); + + // Simulate scroll - now thresholds should trigger + simulateScroll(1000, 2000, 1000); + vi.advanceTimersByTime(100); + expect(sdk.scrollDepth.getThresholdsCrossed()).toEqual([25, 50, 75, 100]); + }); + }); + + describe('scroll percentage calculation', () => { + it('should calculate percentage with viewport included (default)', async () => { + await initPlugin(); + vi.advanceTimersByTime(0); + + // scrollTop=0, scrollHeight=2000, clientHeight=1000 + // (0 + 1000) / 2000 = 50% + setScrollPosition(0, 2000, 1000); + expect(sdk.scrollDepth.getCurrentPercent()).toBe(50); + + // scrollTop=500, scrollHeight=2000, clientHeight=1000 + // (500 + 1000) / 2000 = 75% + setScrollPosition(500, 2000, 1000); + expect(sdk.scrollDepth.getCurrentPercent()).toBe(75); + + // scrollTop=1000, scrollHeight=2000, clientHeight=1000 + // (1000 + 1000) / 2000 = 100% + setScrollPosition(1000, 2000, 1000); + expect(sdk.scrollDepth.getCurrentPercent()).toBe(100); + }); + + it('should calculate percentage without viewport (Pathfora method)', async () => { + await initPlugin({ includeViewportHeight: false }); + vi.advanceTimersByTime(0); + + // scrollTop=0, scrollHeight=2000, clientHeight=1000 + // 0 / (2000 - 1000) = 0% + setScrollPosition(0, 2000, 1000); + expect(sdk.scrollDepth.getCurrentPercent()).toBe(0); + + // scrollTop=500, scrollHeight=2000, clientHeight=1000 + // 500 / (2000 - 1000) = 50% + setScrollPosition(500, 2000, 1000); + expect(sdk.scrollDepth.getCurrentPercent()).toBe(50); + + // scrollTop=1000, scrollHeight=2000, clientHeight=1000 + // 1000 / (2000 - 1000) = 100% + setScrollPosition(1000, 2000, 1000); + expect(sdk.scrollDepth.getCurrentPercent()).toBe(100); + }); + + it('should handle content shorter than viewport', async () => { + await initPlugin(); + vi.advanceTimersByTime(0); + + // scrollHeight <= clientHeight → treat as 100% + setScrollPosition(0, 500, 1000); + expect(sdk.scrollDepth.getCurrentPercent()).toBe(100); + }); + + it('should cap percentage at 100', async () => { + await initPlugin(); + vi.advanceTimersByTime(0); + + // Edge case: scrollTop + clientHeight > scrollHeight + setScrollPosition(1500, 2000, 1000); + expect(sdk.scrollDepth.getCurrentPercent()).toBe(100); + }); + }); + + describe('threshold triggering', () => { + it('should emit event when threshold is crossed', async () => { + const emitSpy = vi.fn(); + + await initPlugin({ thresholds: [50] }); + sdk.on('trigger:scrollDepth', emitSpy); + vi.advanceTimersByTime(0); + + // Scroll to 50% (scrollTop=0, scrollHeight=2000, clientHeight=1000 → 50%) + simulateScroll(0, 2000, 1000); + vi.advanceTimersByTime(100); // Throttle delay + + expect(emitSpy).toHaveBeenCalledWith( + expect.objectContaining({ + triggered: true, + threshold: 50, + percent: 50, + maxPercent: 50, + thresholdsCrossed: [50], + }) + ); + }); + + it('should trigger multiple thresholds in order', async () => { + const events: any[] = []; + + await initPlugin({ thresholds: [25, 50, 75] }); + sdk.on('trigger:scrollDepth', (payload) => events.push(payload)); + vi.advanceTimersByTime(0); + + // Scroll to 55% + simulateScroll(100, 2000, 1000); // (100 + 1000) / 2000 = 55% + vi.advanceTimersByTime(100); + + expect(events).toHaveLength(2); + expect(events[0].threshold).toBe(25); + expect(events[1].threshold).toBe(50); + + // Scroll to 80% + simulateScroll(600, 2000, 1000); // (600 + 1000) / 2000 = 80% + vi.advanceTimersByTime(100); + + expect(events).toHaveLength(3); + expect(events[2].threshold).toBe(75); + }); + + it('should only trigger each threshold once', async () => { + const emitSpy = vi.fn(); + + await initPlugin({ thresholds: [50] }); + sdk.on('trigger:scrollDepth', emitSpy); + vi.advanceTimersByTime(0); + + // Scroll to 50% + simulateScroll(0, 2000, 1000); + vi.advanceTimersByTime(100); + expect(emitSpy).toHaveBeenCalledTimes(1); + + // Scroll to 60% (should not re-trigger) + simulateScroll(200, 2000, 1000); + vi.advanceTimersByTime(100); + expect(emitSpy).toHaveBeenCalledTimes(1); + + // Scroll back to 40% and then to 50% again (should not re-trigger) + simulateScroll(0, 2000, 2000); // 0% + vi.advanceTimersByTime(100); + simulateScroll(0, 2000, 1000); // 50% + vi.advanceTimersByTime(100); + expect(emitSpy).toHaveBeenCalledTimes(1); + }); + + it('should track max scroll percentage', async () => { + await initPlugin({ thresholds: [50, 75] }); + vi.advanceTimersByTime(0); + + // Scroll to 60% + simulateScroll(200, 2000, 1000); // (200 + 1000) / 2000 = 60% + vi.advanceTimersByTime(100); + expect(sdk.scrollDepth.getMaxPercent()).toBe(60); + + // Scroll to 80% + simulateScroll(600, 2000, 1000); // (600 + 1000) / 2000 = 80% + vi.advanceTimersByTime(100); + expect(sdk.scrollDepth.getMaxPercent()).toBe(80); + + // Scroll back to 50% (max should still be 80%) + simulateScroll(0, 2000, 1000); // 50% + vi.advanceTimersByTime(100); + expect(sdk.scrollDepth.getMaxPercent()).toBe(80); + }); + + it('should handle custom thresholds', async () => { + const events: any[] = []; + + await initPlugin({ thresholds: [10, 90] }); + sdk.on('trigger:scrollDepth', (payload) => events.push(payload)); + vi.advanceTimersByTime(0); + + // Scroll to 50% (should trigger 10 only) + simulateScroll(0, 2000, 1000); // 50% + vi.advanceTimersByTime(100); + expect(events).toHaveLength(1); + expect(events[0].threshold).toBe(10); + + // Scroll to 95% + simulateScroll(900, 2000, 1000); // (900 + 1000) / 2000 = 95% + vi.advanceTimersByTime(100); + expect(events).toHaveLength(2); + expect(events[1].threshold).toBe(90); + }); + }); + + describe('throttling', () => { + it('should throttle scroll events (default 100ms)', async () => { + const emitSpy = vi.fn(); + + await initPlugin({ thresholds: [25, 50, 75] }); + sdk.on('trigger:scrollDepth', emitSpy); + vi.advanceTimersByTime(0); + + // First scroll triggers immediately + simulateScroll(100, 2000, 1000); // 55% + expect(emitSpy).toHaveBeenCalledTimes(2); // 25%, 50% + + // Rapid subsequent scrolls should be throttled + simulateScroll(150, 2000, 1000); // 57.5% + simulateScroll(200, 2000, 1000); // 60% + simulateScroll(250, 2000, 1000); // 62.5% + simulateScroll(300, 2000, 1000); // 65% + + // Still only 2 events (throttled) + expect(emitSpy).toHaveBeenCalledTimes(2); + + // Advance past throttle - no new thresholds crossed yet + vi.advanceTimersByTime(100); + expect(emitSpy).toHaveBeenCalledTimes(2); + + // Now scroll past next threshold + simulateScroll(600, 2000, 1000); // 80% + vi.advanceTimersByTime(100); + expect(emitSpy).toHaveBeenCalledTimes(3); // 75% + }); + + it('should respect custom throttle interval', async () => { + const emitSpy = vi.fn(); + + await initPlugin({ thresholds: [50], throttle: 200 }); + sdk.on('trigger:scrollDepth', emitSpy); + vi.advanceTimersByTime(0); + + simulateScroll(0, 2000, 1000); // 50% + + // First scroll fires immediately + expect(emitSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('resize handling', () => { + it('should recalculate scroll on resize', async () => { + const emitSpy = vi.fn(); + + await initPlugin({ thresholds: [50] }); + sdk.on('trigger:scrollDepth', emitSpy); + vi.advanceTimersByTime(0); + + // Initial: scrollTop=0, scrollHeight=2000, clientHeight=1000 → 50% + setScrollPosition(0, 2000, 1000); + const resizeHandler = resizeEventListeners.resize; + if (resizeHandler && typeof resizeHandler === 'function') { + resizeHandler(new Event('resize') as any); + } + vi.advanceTimersByTime(100); + + expect(emitSpy).toHaveBeenCalledWith( + expect.objectContaining({ + threshold: 50, + percent: 50, + }) + ); + }); + }); + + describe('API methods', () => { + it('should return max scroll percentage', async () => { + await initPlugin(); + vi.advanceTimersByTime(0); + + // Initially 0 + expect(sdk.scrollDepth.getMaxPercent()).toBe(0); + + // After scrolling, should update + simulateScroll(500, 2000, 1000); // 75% + vi.advanceTimersByTime(100); + expect(sdk.scrollDepth.getMaxPercent()).toBe(75); + }); + + it('should return current scroll percentage', async () => { + await initPlugin(); + vi.advanceTimersByTime(0); + + setScrollPosition(300, 2000, 1000); // (300 + 1000) / 2000 = 65% + expect(sdk.scrollDepth.getCurrentPercent()).toBe(65); + }); + + it('should return crossed thresholds in sorted order', async () => { + await initPlugin({ thresholds: [75, 25, 50, 100] }); + vi.advanceTimersByTime(0); + + simulateScroll(500, 2000, 1000); // 75% + vi.advanceTimersByTime(100); + + expect(sdk.scrollDepth.getThresholdsCrossed()).toEqual([25, 50, 75]); + }); + + it('should reset tracking', async () => { + await initPlugin({ thresholds: [50, 75] }); + vi.advanceTimersByTime(0); + + // Scroll and trigger threshold + simulateScroll(500, 2000, 1000); // 75% + vi.advanceTimersByTime(100); + expect(sdk.scrollDepth.getMaxPercent()).toBe(75); + expect(sdk.scrollDepth.getThresholdsCrossed()).toEqual([50, 75]); + + // Reset + sdk.scrollDepth.reset(); + expect(sdk.scrollDepth.getMaxPercent()).toBe(0); + expect(sdk.scrollDepth.getThresholdsCrossed()).toEqual([]); + + // Can re-trigger after reset + const emitSpy = vi.fn(); + sdk.on('trigger:scrollDepth', emitSpy); + simulateScroll(500, 2000, 1000); // 75% + vi.advanceTimersByTime(100); + expect(emitSpy).toHaveBeenCalledTimes(2); // Both 50 and 75 fire again + }); + }); + + describe('cleanup', () => { + it('should allow manual cleanup via returned function', async () => { + await initPlugin(); + vi.advanceTimersByTime(0); + + // Verify listeners were added + expect(scrollEventListeners.scroll).toBeDefined(); + expect(resizeEventListeners.resize).toBeDefined(); + + // Plugin exposes a cleanup function that can be called manually + // In practice, this is handled automatically by sdk-kit on destroy + // For now, we just verify the listeners exist + expect(sdk.scrollDepth).toBeDefined(); + expect(sdk.scrollDepth.getMaxPercent).toBeDefined(); + }); + }); + + describe('Pathfora compatibility tests', () => { + it('should match Pathfora test: scrollPercentageToDisplay 50', async () => { + const emitSpy = vi.fn(); + + // Pathfora config + await initPlugin({ + thresholds: [50], + includeViewportHeight: false, // Pathfora method + }); + sdk.on('trigger:scrollDepth', emitSpy); + vi.advanceTimersByTime(0); + + // Body height: 4000px, scroll to full height + simulateScroll(4000, 4000, 1000); // 100% scrolled + vi.advanceTimersByTime(200); // Pathfora test uses 200ms delay + + expect(emitSpy).toHaveBeenCalled(); + expect(sdk.scrollDepth.getThresholdsCrossed()).toContain(50); + }); + + it('should match Pathfora test: scrollPercentageToDisplay 30 with scroll to height/2', async () => { + const emitSpy = vi.fn(); + + // Pathfora config + await initPlugin({ + thresholds: [30], + includeViewportHeight: false, + }); + sdk.on('trigger:scrollDepth', emitSpy); + vi.advanceTimersByTime(0); + + // Body height: 4000px, scroll to height/2 (2000px) + // scrollTop=2000, scrollHeight=4000, clientHeight=1000 + // 2000 / (4000 - 1000) = 66.67% + simulateScroll(2000, 4000, 1000); + vi.advanceTimersByTime(100); + + expect(emitSpy).toHaveBeenCalled(); + expect(sdk.scrollDepth.getThresholdsCrossed()).toContain(30); + }); + }); +}); diff --git a/packages/plugins/src/scroll-depth/scroll-depth.ts b/packages/plugins/src/scroll-depth/scroll-depth.ts new file mode 100644 index 0000000..b77804a --- /dev/null +++ b/packages/plugins/src/scroll-depth/scroll-depth.ts @@ -0,0 +1,400 @@ +/** @module scrollDepthPlugin */ + +import type { PluginFunction } from '@lytics/sdk-kit'; +import type { ScrollDepthEvent, ScrollDepthPluginConfig } from './types'; + +/** + * Pure function: Detect device type based on user agent and screen size + */ +export function detectDevice(): 'mobile' | 'tablet' | 'desktop' { + if (typeof window === 'undefined') return 'desktop'; + + const ua = navigator.userAgent; + const isMobile = /Android|webOS|iPhone|iPod|BlackBerry|IEMobile|Opera Mini/i.test(ua); + const isTablet = /iPad|Android(?!.*Mobile)/i.test(ua); + + // Also check screen size as fallback + const width = window.innerWidth; + if (width < 768) return 'mobile'; + if (width < 1024) return 'tablet'; + if (isMobile) return 'mobile'; + if (isTablet) return 'tablet'; + + return 'desktop'; +} + +/** + * Pure function: Throttle helper + * @param func Function to throttle + * @param wait Wait time in milliseconds + * @returns Throttled function + */ +export function throttle void>( + func: T, + wait: number +): (...args: Parameters) => void { + let timeout: ReturnType | null = null; + let previous = 0; + + return function throttled(...args: Parameters) { + const now = Date.now(); + const remaining = wait - (now - previous); + + if (remaining <= 0 || remaining > wait) { + if (timeout) { + clearTimeout(timeout); + timeout = null; + } + previous = now; + func(...args); + } else if (!timeout) { + timeout = setTimeout(() => { + previous = Date.now(); + timeout = null; + func(...args); + }, remaining); + } + }; +} + +/** + * Pure function: Calculate scroll percentage + */ +export function calculateScrollPercent(includeViewportHeight: boolean): number { + if (typeof document === 'undefined') return 0; + + // Browser compatibility: Use scrollingElement or fallback + const scrollingElement = document.scrollingElement || document.documentElement; + + const scrollTop = scrollingElement.scrollTop; + const scrollHeight = scrollingElement.scrollHeight; + const clientHeight = scrollingElement.clientHeight; + + // Handle edge case: content shorter than viewport + if (scrollHeight <= clientHeight) { + return 100; // Treat as fully scrolled + } + + if (includeViewportHeight) { + // Include viewport: more intuitive for users + // 100% when bottom of viewport reaches end of content + return Math.min(((scrollTop + clientHeight) / scrollHeight) * 100, 100); + } + + // Exclude viewport: traditional method + // 100% when top of viewport reaches scrollable end + return Math.min((scrollTop / (scrollHeight - clientHeight)) * 100, 100); +} + +/** + * Pure function: Calculate engagement score from metrics + */ +export function calculateEngagementScore( + velocity: number, + fastScrollThreshold: number, + directionChanges: number, + timeScrollingUp: number, + totalTime: number +): number { + // Lower score = more engaged (slower scrolling, fewer direction changes) + // Higher score = skimming (fast scrolling, lots of seeking) + const velocityScore = Math.min((velocity / fastScrollThreshold) * 50, 50); + const directionScore = Math.min((directionChanges / 5) * 30, 30); + const seekingScore = Math.min((timeScrollingUp / totalTime) * 20, 20); + + return Math.max(0, 100 - (velocityScore + directionScore + seekingScore)); +} + +/** + * Scroll Depth Plugin + * + * Tracks scroll depth and emits `trigger:scrollDepth` events when thresholds are crossed. + * + * ## How It Works + * + * 1. **Detection**: Listens to `scroll` events (throttled) + * 2. **Calculation**: Calculates current scroll percentage + * 3. **Tracking**: Tracks maximum scroll depth and threshold crossings + * 4. **Emission**: Emits `trigger:scrollDepth` events when thresholds are crossed + * + * ## Configuration + * + * ```typescript + * init({ + * scrollDepth: { + * thresholds: [25, 50, 75, 100], // Percentages to track + * throttle: 100, // Throttle interval (ms) + * includeViewportHeight: true, // Calculation method + * recalculateOnResize: true // Recalculate on resize + * } + * }); + * ``` + * + * ## Experience Targeting + * + * ```typescript + * register('mid-article-cta', { + * type: 'banner', + * content: { message: 'Enjoying the article?' }, + * targeting: { + * custom: (ctx) => (ctx.triggers?.scrollDepth?.percent || 0) >= 50 + * } + * }); + * ``` + * + * ## API Methods + * + * ```typescript + * // Get maximum scroll percentage reached + * instance.scrollDepth.getMaxPercent(); // 73 + * + * // Get current scroll percentage + * instance.scrollDepth.getCurrentPercent(); // 50 + * + * // Get all crossed thresholds + * instance.scrollDepth.getThresholdsCrossed(); // [25, 50] + * + * // Reset tracking (useful for testing) + * instance.scrollDepth.reset(); + * ``` + * + * @param plugin Plugin interface from sdk-kit + * @param instance SDK instance + * @param config SDK configuration + */ +export const scrollDepthPlugin: PluginFunction = (plugin, instance, config) => { + plugin.ns('experiences.scrollDepth'); + + // Set defaults + plugin.defaults({ + scrollDepth: { + thresholds: [25, 50, 75, 100], + throttle: 100, + includeViewportHeight: true, + recalculateOnResize: true, + trackAdvancedMetrics: false, + fastScrollVelocityThreshold: 3, + disableOnMobile: false, + }, + }); + + // Get config + const scrollConfig = config.get('scrollDepth') as ScrollDepthPluginConfig['scrollDepth']; + if (!scrollConfig) return; + + // TypeScript guard: scrollConfig is now guaranteed to be defined + const cfg = scrollConfig; + + // Check device and disable if needed (using pure function) + const device = detectDevice(); + if (cfg.disableOnMobile && device === 'mobile') { + return; // Skip initialization on mobile + } + + // State + let maxScrollPercent = 0; + const triggeredThresholds = new Set(); + + // Advanced metrics state + const pageLoadTime = Date.now(); + let lastScrollPosition = 0; + let lastScrollTime = Date.now(); + let lastScrollDirection: 'up' | 'down' | null = null; + let directionChangesSinceLastThreshold = 0; + let timeScrollingUp = 0; + const thresholdTimes = new Map(); // threshold -> time reached + + /** + * Handle scroll event + */ + function handleScroll() { + // Use pure function for calculation + const currentPercent = calculateScrollPercent(cfg.includeViewportHeight ?? true); + const now = Date.now(); + const scrollingElement = document.scrollingElement || document.documentElement; + const currentPosition = scrollingElement.scrollTop; + + // Track advanced metrics if enabled + let velocity = 0; + let _directionChange = false; + + if (cfg.trackAdvancedMetrics) { + // Calculate velocity (pixels per millisecond) + const timeDelta = now - lastScrollTime; + const positionDelta = currentPosition - lastScrollPosition; + velocity = timeDelta > 0 ? Math.abs(positionDelta) / timeDelta : 0; + + // Detect direction changes + const currentDirection = + positionDelta > 0 ? 'down' : positionDelta < 0 ? 'up' : lastScrollDirection; + if (currentDirection && lastScrollDirection && currentDirection !== lastScrollDirection) { + directionChangesSinceLastThreshold++; + _directionChange = true; + } + + // Track time spent scrolling up (seeking behavior) + if (currentDirection === 'up' && timeDelta > 0) { + timeScrollingUp += timeDelta; + } + + lastScrollDirection = currentDirection; + lastScrollPosition = currentPosition; + lastScrollTime = now; + } + + // Update max scroll + maxScrollPercent = Math.max(maxScrollPercent, currentPercent); + + // Check thresholds + for (const threshold of cfg.thresholds || []) { + if (currentPercent >= threshold && !triggeredThresholds.has(threshold)) { + triggeredThresholds.add(threshold); + + // Record time to threshold + if (cfg.trackAdvancedMetrics) { + thresholdTimes.set(threshold, now - pageLoadTime); + } + + // Build event payload + const eventPayload: ScrollDepthEvent = { + triggered: true, + timestamp: now, + percent: Math.round(currentPercent * 100) / 100, + maxPercent: Math.round(maxScrollPercent * 100) / 100, + threshold, + thresholdsCrossed: Array.from(triggeredThresholds).sort((a, b) => a - b), + device, + }; + + // Add advanced metrics if enabled + if (cfg.trackAdvancedMetrics) { + const fastScrollThreshold = cfg.fastScrollVelocityThreshold || 3; + const isFastScrolling = velocity > fastScrollThreshold; + + // Calculate engagement score using pure function + const engagementScore = calculateEngagementScore( + velocity, + fastScrollThreshold, + directionChangesSinceLastThreshold, + timeScrollingUp, + now - pageLoadTime + ); + + eventPayload.advanced = { + timeToThreshold: now - pageLoadTime, + velocity: Math.round(velocity * 1000) / 1000, // Round to 3 decimals + isFastScrolling, + directionChanges: directionChangesSinceLastThreshold, + timeScrollingUp, + engagementScore: Math.round(engagementScore), + }; + + // Reset direction changes counter after threshold + directionChangesSinceLastThreshold = 0; + } + + instance.emit('trigger:scrollDepth', eventPayload); + } + } + } + + // Throttle scroll handler (using pure function) + const throttledScrollHandler = throttle(handleScroll, cfg.throttle || 100); + + // Throttle resize handler (using pure function) + const throttledResizeHandler = throttle(handleScroll, cfg.throttle || 100); + + // Initialize + function initialize() { + if (typeof window === 'undefined' || typeof document === 'undefined') { + return; // Not in browser environment + } + + // Add scroll listener + window.addEventListener('scroll', throttledScrollHandler, { passive: true }); + + // Add resize listener (optional) + if (cfg.recalculateOnResize) { + window.addEventListener('resize', throttledResizeHandler, { passive: true }); + } + + // Don't check initial scroll position - wait for first user interaction + // This avoids triggering all thresholds immediately on pages that start scrolled + } + + // Cleanup function + function cleanup() { + window.removeEventListener('scroll', throttledScrollHandler); + window.removeEventListener('resize', throttledResizeHandler); + } + + // Setup destroy handler + const destroyHandler = () => { + cleanup(); + }; + instance.on('destroy', destroyHandler); + + // Expose API + plugin.expose({ + scrollDepth: { + /** + * Get the maximum scroll percentage reached during the session + */ + getMaxPercent: () => maxScrollPercent, + + /** + * Get the current scroll percentage + */ + getCurrentPercent: () => calculateScrollPercent(cfg.includeViewportHeight ?? true), + + /** + * Get all thresholds that have been crossed + */ + getThresholdsCrossed: () => Array.from(triggeredThresholds).sort((a, b) => a - b), + + /** + * Get the detected device type + */ + getDevice: () => device, + + /** + * Get advanced metrics (only available when trackAdvancedMetrics is enabled) + */ + getAdvancedMetrics: () => { + if (!cfg.trackAdvancedMetrics) return null; + + const now = Date.now(); + return { + timeOnPage: now - pageLoadTime, + directionChanges: directionChangesSinceLastThreshold, + timeScrollingUp, + thresholdTimes: Object.fromEntries(thresholdTimes), + }; + }, + + /** + * Reset scroll depth tracking + * Clears all triggered thresholds, max scroll, and advanced metrics + */ + reset: () => { + maxScrollPercent = 0; + triggeredThresholds.clear(); + directionChangesSinceLastThreshold = 0; + timeScrollingUp = 0; + thresholdTimes.clear(); + lastScrollDirection = null; + }, + }, + }); + + // Initialize on next tick to ensure DOM is ready + if (typeof window !== 'undefined') { + setTimeout(initialize, 0); + } + + // Return cleanup function + return () => { + cleanup(); + instance.off('destroy', destroyHandler); + }; +}; diff --git a/packages/plugins/src/scroll-depth/types.ts b/packages/plugins/src/scroll-depth/types.ts new file mode 100644 index 0000000..a6c4e7a --- /dev/null +++ b/packages/plugins/src/scroll-depth/types.ts @@ -0,0 +1,122 @@ +/** @module scrollDepthPlugin */ + +/** + * Scroll Depth Plugin API + */ +export interface ScrollDepthPlugin { + getMaxPercent(): number; + getCurrentPercent(): number; + getThresholdsCrossed(): number[]; + getDevice(): 'mobile' | 'tablet' | 'desktop'; + getAdvancedMetrics(): { + timeOnPage: number; + directionChanges: number; + timeScrollingUp: number; + thresholdTimes: Record; + } | null; + reset(): void; +} + +/** + * Scroll Depth Plugin Configuration + * + * Tracks scroll depth and emits trigger:scrollDepth events when thresholds are crossed. + */ +export interface ScrollDepthPluginConfig { + scrollDepth?: { + /** + * Array of scroll percentage thresholds to track (0-100). + * When user scrolls past a threshold, a trigger:scrollDepth event is emitted. + * @default [25, 50, 75, 100] + * @example [50, 100] + */ + thresholds?: number[]; + + /** + * Throttle interval in milliseconds for scroll event handler. + * Lower values are more responsive but impact performance. + * @default 100 + * @example 200 + */ + throttle?: number; + + /** + * Include viewport height in scroll percentage calculation. + * + * - true: (scrollTop + viewportHeight) / totalHeight + * More intuitive: 100% when bottom of viewport reaches end + * - false: scrollTop / (totalHeight - viewportHeight) + * Pathfora's method: 100% when top of viewport reaches end + * + * @default true + */ + includeViewportHeight?: boolean; + + /** + * Recalculate scroll on window resize. + * Useful for responsive layouts where content height changes. + * @default true + */ + recalculateOnResize?: boolean; + + /** + * Track advanced metrics (velocity, direction, time-to-threshold). + * Enables advanced engagement quality analysis. + * Slight performance overhead but provides rich insights. + * @default false + */ + trackAdvancedMetrics?: boolean; + + /** + * Velocity threshold (px/ms) to consider "fast scrolling". + * Fast scrolling often indicates skimming rather than reading. + * Only used when trackAdvancedMetrics is true. + * @default 3 + */ + fastScrollVelocityThreshold?: number; + + /** + * Disable scroll tracking on mobile devices. + * Useful since mobile scroll behavior differs significantly from desktop. + * @default false + */ + disableOnMobile?: boolean; + }; +} + +/** + * Scroll Depth Event Payload + * + * Emitted as `trigger:scrollDepth` when a threshold is crossed. + */ +export interface ScrollDepthEvent { + /** Whether the trigger has fired */ + triggered: boolean; + /** Timestamp when the event was emitted */ + timestamp: number; + /** Current scroll percentage (0-100) */ + percent: number; + /** Maximum scroll percentage reached during session */ + maxPercent: number; + /** The threshold that was just crossed */ + threshold: number; + /** All thresholds that have been triggered */ + thresholdsCrossed: number[]; + /** Device type (mobile, tablet, desktop) */ + device: 'mobile' | 'tablet' | 'desktop'; + /** Advanced metrics (only present when trackAdvancedMetrics is enabled) */ + advanced?: { + /** Time in milliseconds to reach this threshold from page load */ + timeToThreshold: number; + /** Current scroll velocity in pixels per millisecond */ + velocity: number; + /** Whether user is scrolling fast (indicates skimming) */ + isFastScrolling: boolean; + /** Number of direction changes (up/down) since last threshold */ + directionChanges: number; + /** Total time spent scrolling up (indicates seeking behavior) */ + timeScrollingUp: number; + /** Scroll quality score (0-100, higher = more engaged) */ + engagementScore: number; + }; +} diff --git a/packages/plugins/src/time-delay/index.ts b/packages/plugins/src/time-delay/index.ts new file mode 100644 index 0000000..b7004c0 --- /dev/null +++ b/packages/plugins/src/time-delay/index.ts @@ -0,0 +1,6 @@ +/** + * Time Delay Plugin - Barrel Export + */ + +export { timeDelayPlugin } from './time-delay'; +export type { TimeDelayEvent, TimeDelayPlugin, TimeDelayPluginConfig } from './types'; diff --git a/packages/plugins/src/time-delay/time-delay.test.ts b/packages/plugins/src/time-delay/time-delay.test.ts new file mode 100644 index 0000000..cb81b33 --- /dev/null +++ b/packages/plugins/src/time-delay/time-delay.test.ts @@ -0,0 +1,477 @@ +/** @module timeDelayPlugin */ + +import { SDK } from '@lytics/sdk-kit'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { timeDelayPlugin } from './time-delay'; +import type { TimeDelayEvent, TimeDelayPluginConfig } from './types'; + +describe('Time Delay Plugin', () => { + // Use fake timers for time-based tests + beforeEach(() => { + vi.useFakeTimers(); + // Ensure document is visible by default + Object.defineProperty(document, 'hidden', { + writable: true, + configurable: true, + value: false, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + /** + * Helper to initialize SDK with time delay plugin + */ + function initPlugin(config: TimeDelayPluginConfig = {}) { + const sdk = new SDK(config); + sdk.use(timeDelayPlugin); + return sdk; + } + + describe('Basic Functionality', () => { + it('should trigger after configured delay', async () => { + const events: TimeDelayEvent[] = []; + const sdk = initPlugin({ timeDelay: { delay: 5000, pauseWhenHidden: false } }); + + sdk.on('trigger:timeDelay', (event) => { + events.push(event); + }); + + await sdk.init(); + + // Before delay + expect(events.length).toBe(0); + + // Fast forward to trigger time + vi.advanceTimersByTime(5000); + + // Should have triggered + expect(events.length).toBe(1); + expect(events[0].elapsed).toBeGreaterThanOrEqual(5000); + expect(events[0].activeElapsed).toBeGreaterThanOrEqual(5000); + expect(events[0].wasPaused).toBe(false); + }); + + it('should not trigger if delay is 0', async () => { + const events: TimeDelayEvent[] = []; + const sdk = initPlugin({ timeDelay: { delay: 0 } }); + + sdk.on('trigger:timeDelay', (event) => { + events.push(event); + }); + + await sdk.init(); + + // Advance some time + vi.advanceTimersByTime(10000); + + // Should not trigger + expect(events.length).toBe(0); + }); + + it('should update context with elapsed time', async () => { + const events: TimeDelayEvent[] = []; + const sdk = initPlugin({ timeDelay: { delay: 3000, pauseWhenHidden: false } }); + + sdk.on('trigger:timeDelay', (event) => { + events.push(event); + }); + + await sdk.init(); + + vi.advanceTimersByTime(3000); + + expect(events.length).toBe(1); + expect(events[0].timestamp).toBeDefined(); + expect(events[0].elapsed).toBeGreaterThanOrEqual(3000); + expect(events[0].activeElapsed).toBeGreaterThanOrEqual(3000); + }); + + it('should only trigger once', async () => { + const events: TimeDelayEvent[] = []; + const sdk = initPlugin({ timeDelay: { delay: 2000, pauseWhenHidden: false } }); + + sdk.on('trigger:timeDelay', (event) => { + events.push(event); + }); + + await sdk.init(); + + vi.advanceTimersByTime(2000); + expect(events.length).toBe(1); + + // Advance more time + vi.advanceTimersByTime(5000); + expect(events.length).toBe(1); // Still only 1 + }); + }); + + describe('Visibility Handling', () => { + it('should pause timer when tab is hidden', async () => { + const events: TimeDelayEvent[] = []; + const sdk = initPlugin({ timeDelay: { delay: 5000, pauseWhenHidden: true } }); + + sdk.on('trigger:timeDelay', (event) => { + events.push(event); + }); + + await sdk.init(); + + // 2 seconds active + vi.advanceTimersByTime(2000); + + // Hide tab + Object.defineProperty(document, 'hidden', { + writable: true, + configurable: true, + value: true, + }); + document.dispatchEvent(new Event('visibilitychange')); + + // 3 seconds hidden (should be paused) + vi.advanceTimersByTime(3000); + + // Should NOT have triggered yet + expect(events.length).toBe(0); + + // Show tab + Object.defineProperty(document, 'hidden', { + writable: true, + configurable: true, + value: false, + }); + document.dispatchEvent(new Event('visibilitychange')); + + // 3 more seconds active (total 5 active) + vi.advanceTimersByTime(3000); + + // Should have triggered + expect(events.length).toBe(1); + expect(events[0].activeElapsed).toBeGreaterThanOrEqual(5000); + expect(events[0].elapsed).toBeGreaterThanOrEqual(8000); // 2 + 3 + 3 + expect(events[0].wasPaused).toBe(true); + expect(events[0].visibilityChanges).toBeGreaterThan(0); + }); + + it('should not pause if pauseWhenHidden is false', async () => { + const events: TimeDelayEvent[] = []; + const sdk = initPlugin({ timeDelay: { delay: 5000, pauseWhenHidden: false } }); + + sdk.on('trigger:timeDelay', (event) => { + events.push(event); + }); + + await sdk.init(); + + // 2 seconds active + vi.advanceTimersByTime(2000); + + // Hide tab + Object.defineProperty(document, 'hidden', { + writable: true, + configurable: true, + value: true, + }); + document.dispatchEvent(new Event('visibilitychange')); + + // 3 more seconds (should still count) + vi.advanceTimersByTime(3000); + + // Should have triggered (total 5 seconds) + expect(events.length).toBe(1); + expect(events[0].elapsed).toBeGreaterThanOrEqual(5000); + expect(events[0].wasPaused).toBe(false); // Never paused + }); + + it('should handle rapid visibility changes', async () => { + const events: TimeDelayEvent[] = []; + const sdk = initPlugin({ timeDelay: { delay: 10000, pauseWhenHidden: true } }); + + sdk.on('trigger:timeDelay', (event) => { + events.push(event); + }); + + await sdk.init(); + + // Multiple hide/show cycles + for (let i = 0; i < 5; i++) { + vi.advanceTimersByTime(1000); // 1s active + + // Hide + Object.defineProperty(document, 'hidden', { + writable: true, + configurable: true, + value: true, + }); + document.dispatchEvent(new Event('visibilitychange')); + vi.advanceTimersByTime(500); // 0.5s hidden + + // Show + Object.defineProperty(document, 'hidden', { + writable: true, + configurable: true, + value: false, + }); + document.dispatchEvent(new Event('visibilitychange')); + } + + // Total: 5s active, 2.5s hidden = 7.5s elapsed, 5s active + expect(events.length).toBe(0); // Not triggered yet (need 10s active) + + // Add 5 more seconds active (total 10s active) + vi.advanceTimersByTime(5000); + + // Should trigger + expect(events.length).toBe(1); + expect(events[0].activeElapsed).toBeGreaterThanOrEqual(9000); // Allow some tolerance + expect(events[0].wasPaused).toBe(true); + expect(events[0].visibilityChanges).toBeGreaterThan(5); + }); + + it('should handle starting hidden', async () => { + // Set document hidden before init + Object.defineProperty(document, 'hidden', { + writable: true, + configurable: true, + value: true, + }); + + const events: TimeDelayEvent[] = []; + const sdk = initPlugin({ timeDelay: { delay: 3000, pauseWhenHidden: true } }); + + sdk.on('trigger:timeDelay', (event) => { + events.push(event); + }); + + await sdk.init(); + + // 2 seconds hidden (should be paused from start) + vi.advanceTimersByTime(2000); + expect(events.length).toBe(0); + + // Show tab + Object.defineProperty(document, 'hidden', { writable: true, value: false }); + document.dispatchEvent(new Event('visibilitychange')); + + // 3 seconds active + vi.advanceTimersByTime(3000); + + // Should trigger + expect(events.length).toBe(1); + expect(events[0].activeElapsed).toBeGreaterThanOrEqual(3000); + }); + }); + + describe('API Methods', () => { + it('should expose getElapsed method', async () => { + const sdk = initPlugin({ timeDelay: { delay: 5000, pauseWhenHidden: false } }); + await sdk.init(); + + vi.advanceTimersByTime(2000); + + const elapsed = sdk.timeDelay.getElapsed(); + expect(elapsed).toBeGreaterThanOrEqual(2000); + expect(elapsed).toBeLessThan(3000); + }); + + it('should expose getActiveElapsed method', async () => { + const sdk = initPlugin({ timeDelay: { delay: 5000, pauseWhenHidden: true } }); + await sdk.init(); + + // 2s active + vi.advanceTimersByTime(2000); + + // Hide for 3s + Object.defineProperty(document, 'hidden', { writable: true, value: true }); + document.dispatchEvent(new Event('visibilitychange')); + vi.advanceTimersByTime(3000); + + const activeElapsed = sdk.timeDelay.getActiveElapsed(); + const totalElapsed = sdk.timeDelay.getElapsed(); + + expect(activeElapsed).toBeGreaterThanOrEqual(2000); + expect(activeElapsed).toBeLessThan(3000); + expect(totalElapsed).toBeGreaterThanOrEqual(5000); + }); + + it('should expose getRemaining method', async () => { + const sdk = initPlugin({ timeDelay: { delay: 5000, pauseWhenHidden: false } }); + await sdk.init(); + + vi.advanceTimersByTime(2000); + + const remaining = sdk.timeDelay.getRemaining(); + expect(remaining).toBeGreaterThan(2000); + expect(remaining).toBeLessThanOrEqual(3000); + }); + + it('should return 0 for getRemaining after trigger', async () => { + const sdk = initPlugin({ timeDelay: { delay: 2000, pauseWhenHidden: false } }); + await sdk.init(); + + vi.advanceTimersByTime(2000); + + const remaining = sdk.timeDelay.getRemaining(); + expect(remaining).toBe(0); + }); + + it('should expose isPaused method', async () => { + const sdk = initPlugin({ timeDelay: { delay: 5000, pauseWhenHidden: true } }); + await sdk.init(); + + expect(sdk.timeDelay.isPaused()).toBe(false); + + // Hide tab + Object.defineProperty(document, 'hidden', { writable: true, value: true }); + document.dispatchEvent(new Event('visibilitychange')); + + expect(sdk.timeDelay.isPaused()).toBe(true); + }); + + it('should expose isTriggered method', async () => { + const sdk = initPlugin({ timeDelay: { delay: 2000, pauseWhenHidden: false } }); + await sdk.init(); + + expect(sdk.timeDelay.isTriggered()).toBe(false); + + vi.advanceTimersByTime(2000); + + expect(sdk.timeDelay.isTriggered()).toBe(true); + }); + + it('should expose reset method', async () => { + const events: TimeDelayEvent[] = []; + const sdk = initPlugin({ timeDelay: { delay: 3000, pauseWhenHidden: false } }); + + sdk.on('trigger:timeDelay', (event) => { + events.push(event); + }); + + await sdk.init(); + + vi.advanceTimersByTime(3000); + expect(events.length).toBe(1); + expect(sdk.timeDelay.isTriggered()).toBe(true); + + // Reset + sdk.timeDelay.reset(); + expect(sdk.timeDelay.isTriggered()).toBe(false); + + // Should trigger again after 3s + vi.advanceTimersByTime(3000); + expect(events.length).toBe(2); + }); + }); + + describe('Cleanup', () => { + it('should cleanup timers on destroy', async () => { + const events: TimeDelayEvent[] = []; + const sdk = initPlugin({ timeDelay: { delay: 5000, pauseWhenHidden: false } }); + + sdk.on('trigger:timeDelay', (event) => { + events.push(event); + }); + + await sdk.init(); + + vi.advanceTimersByTime(2000); + + // Destroy SDK + sdk.emit('destroy'); + + // Advance past trigger time + vi.advanceTimersByTime(5000); + + // Should NOT have triggered (timer was cleared) + expect(events.length).toBe(0); + }); + + it('should cleanup visibility listeners on destroy', async () => { + const sdk = initPlugin({ timeDelay: { delay: 5000, pauseWhenHidden: true } }); + await sdk.init(); + + const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener'); + + // Destroy SDK + sdk.emit('destroy'); + + // Should have removed visibility listener + expect(removeEventListenerSpy).toHaveBeenCalledWith('visibilitychange', expect.any(Function)); + }); + }); + + describe('Edge Cases', () => { + it('should handle delay elapsed during pause', async () => { + const events: TimeDelayEvent[] = []; + const sdk = initPlugin({ timeDelay: { delay: 2000, pauseWhenHidden: true } }); + + sdk.on('trigger:timeDelay', (event) => { + events.push(event); + }); + + await sdk.init(); + + // 1s active + vi.advanceTimersByTime(1000); + + // Hide tab + Object.defineProperty(document, 'hidden', { + writable: true, + configurable: true, + value: true, + }); + document.dispatchEvent(new Event('visibilitychange')); + + // 5s hidden (enough to complete delay if not paused) + vi.advanceTimersByTime(5000); + + // Should NOT have triggered yet + expect(events.length).toBe(0); + + // Show tab + Object.defineProperty(document, 'hidden', { + writable: true, + configurable: true, + value: false, + }); + document.dispatchEvent(new Event('visibilitychange')); + + // 1 more second active (total 2s active) + vi.advanceTimersByTime(1000); + + // Should trigger + expect(events.length).toBe(1); + }); + + it('should work in environments without Page Visibility API', async () => { + // Mock missing document.hidden + const originalHidden = Object.getOwnPropertyDescriptor(document, 'hidden'); + Object.defineProperty(document, 'hidden', { + get: () => undefined, + configurable: true, + }); + + const events: TimeDelayEvent[] = []; + const sdk = initPlugin({ timeDelay: { delay: 2000, pauseWhenHidden: true } }); + + sdk.on('trigger:timeDelay', (event) => { + events.push(event); + }); + + await sdk.init(); + + vi.advanceTimersByTime(2000); + + // Should still trigger (falls back to no pause) + expect(events.length).toBe(1); + + // Restore + if (originalHidden) { + Object.defineProperty(document, 'hidden', originalHidden); + } + }); + }); +}); diff --git a/packages/plugins/src/time-delay/time-delay.ts b/packages/plugins/src/time-delay/time-delay.ts new file mode 100644 index 0000000..86a8887 --- /dev/null +++ b/packages/plugins/src/time-delay/time-delay.ts @@ -0,0 +1,297 @@ +/** @module timeDelayPlugin */ + +import type { PluginFunction } from '@lytics/sdk-kit'; +import type { TimeDelayEvent, TimeDelayPlugin, TimeDelayPluginConfig } from './types'; + +/** + * Pure function: Calculate elapsed time from start + */ +export function calculateElapsed(startTime: number, pausedDuration: number): number { + return Date.now() - startTime - pausedDuration; +} + +/** + * Pure function: Check if document is hidden (Page Visibility API) + */ +export function isDocumentHidden(): boolean { + if (typeof document === 'undefined') return false; + return document.hidden || false; +} + +/** + * Pure function: Create time delay event payload + */ +export function createTimeDelayEvent( + startTime: number, + pausedDuration: number, + wasPaused: boolean, + visibilityChanges: number +): TimeDelayEvent { + const timestamp = Date.now(); + const elapsed = timestamp - startTime; + const activeElapsed = elapsed - pausedDuration; + + return { + timestamp, + elapsed, + activeElapsed, + wasPaused, + visibilityChanges, + }; +} + +/** + * Time Delay Plugin + * + * Tracks time elapsed since SDK initialization and emits trigger:timeDelay events + * when the configured delay is reached. + * + * **Features:** + * - Millisecond precision timing + * - Pause/resume on tab visibility change (optional) + * - Tracks active vs total elapsed time + * - Full timer lifecycle management + * + * **Event-Driven Architecture:** + * This plugin emits `trigger:timeDelay` events when the delay threshold is reached. + * The core runtime listens for these events and automatically re-evaluates experiences. + * + * **Usage Pattern:** + * Use `targeting.custom` to check if time delay has triggered: + * + * @example Basic usage + * ```typescript + * import { init, register } from '@prosdevlab/experience-sdk'; + * + * init({ + * timeDelay: { + * delay: 5000, // 5 seconds + * pauseWhenHidden: true // Pause when tab hidden (default) + * } + * }); + * + * // Show banner after 5 seconds of active viewing time + * register('timed-offer', { + * type: 'banner', + * content: { + * message: 'Limited time offer!', + * buttons: [{ text: 'Claim Now', variant: 'primary' }] + * }, + * targeting: { + * custom: (context) => { + * const active = context.triggers?.timeDelay?.activeElapsed || 0; + * return active >= 5000; + * } + * } + * }); + * ``` + * + * @example Combining with other triggers + * ```typescript + * // Show after 10s OR on exit intent (whichever comes first) + * register('engaged-offer', { + * type: 'banner', + * content: { message: 'Special offer for engaged users!' }, + * targeting: { + * custom: (context) => { + * const timeElapsed = (context.triggers?.timeDelay?.activeElapsed || 0) >= 10000; + * const exitIntent = context.triggers?.exitIntent?.triggered; + * return timeElapsed || exitIntent; + * } + * } + * }); + * ``` + * + * @param plugin Plugin interface from sdk-kit + * @param instance SDK instance + * @param config SDK configuration + */ +export const timeDelayPlugin: PluginFunction = (plugin, instance, config) => { + plugin.ns('experiences.timeDelay'); + + // Set defaults + plugin.defaults({ + timeDelay: { + delay: 0, + pauseWhenHidden: true, + }, + }); + + // Get config + const timeDelayConfig = config.get('timeDelay') as TimeDelayPluginConfig['timeDelay']; + if (!timeDelayConfig) return; + + const delay = timeDelayConfig.delay ?? 0; + const pauseWhenHidden = timeDelayConfig.pauseWhenHidden ?? true; + + // Skip if delay is 0 (disabled) + if (delay <= 0) return; + + // State + const startTime = Date.now(); + let triggered = false; + let paused = false; + let pausedDuration = 0; + let lastPauseTime = 0; + let visibilityChanges = 0; + let timer: ReturnType | null = null; + let visibilityListener: (() => void) | null = null; + + /** + * Trigger the time delay event + */ + function trigger(): void { + if (triggered) return; + + triggered = true; + + // Create event payload using pure function + const eventPayload = createTimeDelayEvent( + startTime, + pausedDuration, + visibilityChanges > 0, + visibilityChanges + ); + + // Emit trigger event + instance.emit('trigger:timeDelay', eventPayload); + + // Cleanup + cleanup(); + } + + /** + * Schedule timer with remaining delay + */ + function scheduleTimer(remainingDelay: number): void { + if (timer) { + clearTimeout(timer); + } + + timer = setTimeout(() => { + trigger(); + }, remainingDelay); + } + + /** + * Handle visibility change + */ + function handleVisibilityChange(): void { + const hidden = isDocumentHidden(); + + if (hidden && !paused) { + // Tab just became hidden - pause timer + paused = true; + lastPauseTime = Date.now(); + visibilityChanges++; + + // Clear existing timer + if (timer) { + clearTimeout(timer); + timer = null; + } + } else if (!hidden && paused) { + // Tab just became visible - resume timer + paused = false; + const pauseDuration = Date.now() - lastPauseTime; + pausedDuration += pauseDuration; + visibilityChanges++; + + // Calculate remaining delay + const elapsed = calculateElapsed(startTime, pausedDuration); + const remaining = delay - elapsed; + + if (remaining > 0) { + scheduleTimer(remaining); + } else { + // Delay already elapsed during pause + trigger(); + } + } + } + + /** + * Cleanup listeners and timers + */ + function cleanup(): void { + if (timer) { + clearTimeout(timer); + timer = null; + } + if (visibilityListener && typeof document !== 'undefined') { + document.removeEventListener('visibilitychange', visibilityListener); + visibilityListener = null; + } + } + + /** + * Initialize timer and visibility listener + */ + function initialize(): void { + // Check if already hidden on init + if (pauseWhenHidden && isDocumentHidden()) { + paused = true; + lastPauseTime = Date.now(); + visibilityChanges++; + } else { + // Start timer + scheduleTimer(delay); + } + + // Setup visibility listener if pause is enabled + if (pauseWhenHidden && typeof document !== 'undefined') { + visibilityListener = handleVisibilityChange; + document.addEventListener('visibilitychange', visibilityListener); + } + } + + // Expose API + plugin.expose({ + timeDelay: { + getElapsed: () => { + return Date.now() - startTime; + }, + + getActiveElapsed: () => { + let currentPausedDuration = pausedDuration; + if (paused) { + // Add current pause duration + currentPausedDuration += Date.now() - lastPauseTime; + } + return calculateElapsed(startTime, currentPausedDuration); + }, + + getRemaining: () => { + if (triggered) return 0; + + const elapsed = calculateElapsed(startTime, pausedDuration); + const remaining = delay - elapsed; + return Math.max(0, remaining); + }, + + isPaused: () => paused, + + isTriggered: () => triggered, + + reset: () => { + triggered = false; + paused = false; + pausedDuration = 0; + lastPauseTime = 0; + visibilityChanges = 0; + + cleanup(); + initialize(); + }, + } satisfies TimeDelayPlugin, + }); + + // Initialize on plugin load + initialize(); + + // Cleanup on instance destroy + const destroyHandler = () => { + cleanup(); + }; + instance.on('destroy', destroyHandler); +}; diff --git a/packages/plugins/src/time-delay/types.ts b/packages/plugins/src/time-delay/types.ts new file mode 100644 index 0000000..d5d60be --- /dev/null +++ b/packages/plugins/src/time-delay/types.ts @@ -0,0 +1,89 @@ +/** @module timeDelayPlugin */ + +/** + * Time Delay Plugin Configuration + * + * Tracks time elapsed since SDK initialization and emits trigger:timeDelay events. + */ +export interface TimeDelayPluginConfig { + timeDelay?: { + /** + * Delay before emitting trigger event (milliseconds). + * Set to 0 to disable (immediate trigger on init). + * @default 0 + * @example 5000 // 5 seconds + */ + delay?: number; + + /** + * Pause timer when tab is hidden (Page Visibility API). + * When true, only counts "active viewing time". + * When false, timer runs even when tab is hidden. + * @default true + */ + pauseWhenHidden?: boolean; + }; +} + +/** + * Time Delay Event Payload + * + * Emitted via 'trigger:timeDelay' when the configured delay is reached. + */ +export interface TimeDelayEvent { + /** Timestamp when the trigger event was emitted */ + timestamp: number; + + /** Total elapsed time since init (milliseconds, includes paused time) */ + elapsed: number; + + /** Active elapsed time (milliseconds, excludes time when tab was hidden) */ + activeElapsed: number; + + /** Whether the timer was paused at any point */ + wasPaused: boolean; + + /** Number of times visibility changed (hidden/visible) */ + visibilityChanges: number; +} + +/** + * Time Delay Plugin API + */ +export interface TimeDelayPlugin { + /** + * Get total elapsed time since init (includes paused time) + * @returns Time in milliseconds + */ + getElapsed(): number; + + /** + * Get active elapsed time (excludes paused time) + * @returns Time in milliseconds + */ + getActiveElapsed(): number; + + /** + * Get remaining time until trigger + * @returns Time in milliseconds, or 0 if already triggered + */ + getRemaining(): number; + + /** + * Check if timer is currently paused (tab hidden) + * @returns True if paused + */ + isPaused(): boolean; + + /** + * Check if trigger has fired + * @returns True if triggered + */ + isTriggered(): boolean; + + /** + * Reset timer to initial state + * Clears trigger flag and restarts timing + */ + reset(): void; +} diff --git a/packages/plugins/src/utils/sanitize.ts b/packages/plugins/src/utils/sanitize.ts index 12ab2e5..b2aaf10 100644 --- a/packages/plugins/src/utils/sanitize.ts +++ b/packages/plugins/src/utils/sanitize.ts @@ -92,7 +92,7 @@ export function sanitizeHTML(html: string): string { } } - const attrString = attrs.length > 0 ? ' ' + attrs.join(' ') : ''; + const attrString = attrs.length > 0 ? ` ${attrs.join(' ')}` : ''; // Process child nodes let innerHTML = ''; diff --git a/specs/phase-0-foundation/tasks.md b/specs/phase-0-foundation/tasks.md index 456675b..0c55cc4 100644 --- a/specs/phase-0-foundation/tasks.md +++ b/specs/phase-0-foundation/tasks.md @@ -1,9 +1,20 @@ # Tasks - Phase 0: Foundation **Generated:** December 24, 2025 -**Status:** Ready for Implementation +**Status:** ✅ **COMPLETE** (December 27, 2025) -This document breaks down the implementation plan into discrete, actionable tasks suitable for GitHub issues. +This document tracks the Phase 0 foundation implementation tasks. + +--- + +## Completion Summary + +**Bundle Size:** 8.4 KB gzipped (target: <15 KB) ✅ +**Test Coverage:** 222 tests passing ✅ +**Playground:** Deployed at https://xp-examples.vercel.app/ ✅ +**Documentation:** Complete ✅ + +All 13 tasks completed successfully. --- @@ -23,13 +34,13 @@ Define all TypeScript types and interfaces for the Experience SDK - `packages/core/src/types.ts` **Acceptance Criteria:** -- [ ] `Experience` interface defined -- [ ] `TargetingRules` interfaces defined -- [ ] `Decision` interface defined (with reasons & trace) -- [ ] `Context` interface defined -- [ ] `ExperienceConfig` interface defined -- [ ] All types exported -- [ ] `pnpm typecheck` passes +- [x] `Experience` interface defined +- [x] `TargetingRules` interfaces defined +- [x] `Decision` interface defined (with reasons & trace) +- [x] `Context` interface defined +- [x] `ExperienceConfig` interface defined +- [x] All types exported +- [x] `pnpm typecheck` passes **Dependencies:** None @@ -51,15 +62,15 @@ Build the core `ExperienceRuntime` class with sdk-kit integration - `packages/core/src/runtime.ts` **Acceptance Criteria:** -- [ ] Class created with SDK instance -- [ ] `init()` method implemented -- [ ] `register()` method implemented -- [ ] `evaluate()` method implemented -- [ ] `explain()` method implemented -- [ ] URL rule evaluation works (contains, equals, matches) -- [ ] Decision includes reasons array -- [ ] Decision includes trace array -- [ ] Events emitted for lifecycle hooks +- [x] Class created with SDK instance +- [x] `init()` method implemented +- [x] `register()` method implemented +- [x] `evaluate()` method implemented +- [x] `explain()` method implemented +- [x] URL rule evaluation works (contains, equals, matches) +- [x] Decision includes reasons array +- [x] Decision includes trace array +- [x] Events emitted for lifecycle hooks **Dependencies:** Task 1.1 @@ -79,14 +90,14 @@ Set up singleton + createInstance export pattern - `packages/core/src/index.ts` **Acceptance Criteria:** -- [ ] `createInstance()` function exported -- [ ] Default singleton instance created -- [ ] Named exports from singleton (init, register, evaluate, etc.) -- [ ] Default export available -- [ ] `experiences` object exported for IIFE -- [ ] All types re-exported -- [ ] ESM build works -- [ ] IIFE build works with global `experiences` +- [x] `createInstance()` function exported +- [x] Default singleton instance created +- [x] Named exports from singleton (init, register, evaluate, etc.) +- [x] Default export available +- [x] `experiences` object exported for IIFE +- [x] All types re-exported +- [x] ESM build works +- [x] IIFE build works with global `experiences` **Dependencies:** Task 2.1 @@ -107,14 +118,14 @@ Write comprehensive unit tests for ExperienceRuntime - `packages/core/src/index.test.ts` **Test Coverage:** -- [ ] Initialization tests -- [ ] Registration tests -- [ ] Evaluation tests (all URL rule types) -- [ ] Explainability tests (reasons & trace) -- [ ] State inspection tests -- [ ] Event emission tests -- [ ] Export pattern tests -- [ ] Coverage > 90% +- [x] Initialization tests +- [x] Registration tests +- [x] Evaluation tests (all URL rule types) +- [x] Explainability tests (reasons & trace) +- [x] State inspection tests +- [x] Event emission tests +- [x] Export pattern tests +- [x] Coverage > 90% **Dependencies:** Task 2.1, Task 2.2 @@ -136,15 +147,15 @@ Implement frequency capping plugin that leverages sdk-kit's storage plugin - `packages/plugins/src/frequency/index.ts` **Acceptance Criteria:** -- [ ] Plugin follows sdk-kit pattern -- [ ] **Uses `@lytics/sdk-kit-plugins/storage`** for persistence -- [ ] Auto-loads storage plugin if not already loaded -- [ ] Tracks impression counts per experience -- [ ] Enforces frequency caps (max per session/day/week) -- [ ] Listens to `experiences:evaluated` event -- [ ] Updates decision reasons -- [ ] Exposes `getImpressionCount()`, `hasReachedCap()`, `recordImpression()` methods -- [ ] Emits `experiences:impression-recorded` events +- [x] Plugin follows sdk-kit pattern +- [x] **Uses `@lytics/sdk-kit-plugins/storage`** for persistence +- [x] Auto-loads storage plugin if not already loaded +- [x] Tracks impression counts per experience +- [x] Enforces frequency caps (max per session/day/week) +- [x] Listens to `experiences:evaluated` event +- [x] Updates decision reasons +- [x] Exposes `getImpressionCount()`, `hasReachedCap()`, `recordImpression()` methods +- [x] Emits `experiences:impression-recorded` events **Dependencies:** Task 2.1 @@ -164,13 +175,13 @@ Implement debug plugin for event emission - `packages/plugins/src/debug/index.ts` **Acceptance Criteria:** -- [ ] Plugin follows sdk-kit pattern -- [ ] Emits window events (`experience-sdk:debug`) -- [ ] Optionally logs to console -- [ ] Respects `debug.enabled` config -- [ ] Listens to `experiences:*` wildcard -- [ ] Structured event format -- [ ] Exposes `debug.log()` method +- [x] Plugin follows sdk-kit pattern +- [x] Emits window events (`experience-sdk:debug`) +- [x] Optionally logs to console +- [x] Respects `debug.enabled` config +- [x] Listens to `experiences:*` wildcard +- [x] Structured event format +- [x] Exposes `debug.log()` method **Dependencies:** Task 2.1 @@ -190,15 +201,15 @@ Implement banner plugin for experience delivery - `packages/plugins/src/banner/index.ts` **Acceptance Criteria:** -- [ ] Plugin follows sdk-kit pattern -- [ ] Creates banner DOM element -- [ ] Supports top/bottom position -- [ ] Supports dismissable option -- [ ] Styles banner with inline CSS -- [ ] Auto-shows on `experiences:evaluated` event -- [ ] Exposes `show()`, `remove()` methods -- [ ] Cleans up on destroy -- [ ] Emits `experiences:shown` event +- [x] Plugin follows sdk-kit pattern +- [x] Creates banner DOM element +- [x] Supports top/bottom position +- [x] Supports dismissable option +- [x] Styles banner with inline CSS +- [x] Auto-shows on `experiences:evaluated` event +- [x] Exposes `show()`, `remove()` methods +- [x] Cleans up on destroy +- [x] Emits `experiences:shown` event **Dependencies:** Task 2.1 @@ -220,10 +231,10 @@ Write unit tests for all three plugins - `packages/plugins/src/banner/banner.test.ts` **Test Coverage:** -- [ ] Frequency plugin tests (impressions, caps, storage integration) -- [ ] Debug plugin tests (window events, console) -- [ ] Banner plugin tests (rendering, dismissal, cleanup) -- [ ] Coverage > 80% +- [x] Frequency plugin tests (impressions, caps, storage integration) +- [x] Debug plugin tests (window events, console) +- [x] Banner plugin tests (rendering, dismissal, cleanup) +- [x] Coverage > 80% **Dependencies:** Task 3.1, Task 3.2, Task 3.3 @@ -245,12 +256,12 @@ Update ExperienceRuntime to automatically register plugins - `packages/core/src/runtime.ts` **Acceptance Criteria:** -- [ ] Import all three plugins -- [ ] Register plugins in constructor -- [ ] Plugins work in sequence -- [ ] Storage plugin affects decisions -- [ ] Debug plugin emits events -- [ ] Banner plugin auto-renders +- [x] Import all three plugins +- [x] Register plugins in constructor +- [x] Plugins work in sequence +- [x] Storage plugin affects decisions +- [x] Debug plugin emits events +- [x] Banner plugin auto-renders **Dependencies:** Task 2.1, Task 3.1, Task 3.2, Task 3.3 @@ -272,16 +283,16 @@ Build interactive demo HTML page - `demo/index.html` **Acceptance Criteria:** -- [ ] Clean, modern design -- [ ] Initialize SDK section -- [ ] Register experience section -- [ ] Evaluate section with output -- [ ] Shows decision with reasons -- [ ] Shows trace steps -- [ ] Demonstrates frequency capping -- [ ] Shows debug events in console -- [ ] Banner actually renders -- [ ] Uses IIFE bundle +- [x] Clean, modern design +- [x] Initialize SDK section +- [x] Register experience section +- [x] Evaluate section with output +- [x] Shows decision with reasons +- [x] Shows trace steps +- [x] Demonstrates frequency capping +- [x] Shows debug events in console +- [x] Banner actually renders +- [x] Uses IIFE bundle **Dependencies:** Task 4.1 @@ -303,12 +314,12 @@ Update README and add examples - `packages/plugins/README.md` **Acceptance Criteria:** -- [ ] Root README has getting started guide -- [ ] Core package README documents API -- [ ] Plugins package README documents each plugin -- [ ] Examples show script tag usage -- [ ] Examples show npm usage -- [ ] API reference complete +- [x] Root README has getting started guide +- [x] Core package README documents API +- [x] Plugins package README documents each plugin +- [x] Examples show script tag usage +- [x] Examples show npm usage +- [x] API reference complete **Dependencies:** Task 4.1 @@ -327,11 +338,11 @@ Update README and add examples Ensure bundle size is under 15KB gzipped **Acceptance Criteria:** -- [ ] IIFE bundle < 15KB gzipped -- [ ] Verify with `gzip -c dist/index.global.js | wc -c` -- [ ] Tree-shaking works for ESM -- [ ] No unnecessary dependencies bundled -- [ ] Source maps generated +- [x] IIFE bundle < 15KB gzipped +- [x] Verify with `gzip -c dist/index.global.js | wc -c` +- [x] Tree-shaking works for ESM +- [x] No unnecessary dependencies bundled +- [x] Source maps generated **Dependencies:** Task 4.1 @@ -348,13 +359,13 @@ Ensure bundle size is under 15KB gzipped Test complete flow from registration to rendering **Test Scenarios:** -- [ ] Script tag in browser works -- [ ] npm import works -- [ ] Frequency capping works across page reloads -- [ ] Debug events appear in window -- [ ] Banner renders correctly -- [ ] Multiple experiences work -- [ ] Private mode doesn't crash (memory fallback) +- [x] Script tag in browser works +- [x] npm import works +- [x] Frequency capping works across page reloads +- [x] Debug events appear in window +- [x] Banner renders correctly +- [x] Multiple experiences work +- [x] Private mode doesn't crash (memory fallback) **Dependencies:** Task 5.1 @@ -371,13 +382,13 @@ Test complete flow from registration to rendering Final cleanup and polish **Checklist:** -- [ ] All linter errors resolved -- [ ] All tests passing -- [ ] All type errors resolved -- [ ] Code comments added -- [ ] README updated with actual bundle size -- [ ] CHANGELOG.md created -- [ ] Spec updated with completion status +- [x] All linter errors resolved +- [x] All tests passing +- [x] All type errors resolved +- [x] Code comments added +- [x] README updated with actual bundle size +- [x] CHANGELOG.md created +- [x] Spec updated with completion status **Dependencies:** All previous tasks diff --git a/specs/phase-1-display-conditions/plan.md b/specs/phase-1-display-conditions/plan.md new file mode 100644 index 0000000..2d47f92 --- /dev/null +++ b/specs/phase-1-display-conditions/plan.md @@ -0,0 +1,612 @@ +# Phase 1: Display Condition Plugins - Implementation Plan + +This document provides step-by-step implementation guidance for all four display condition plugins. + +## Architecture Overview + +### Event-Driven Pattern +All display condition plugins follow the same pattern: + +1. **Detection**: Plugin detects condition (exit intent, scroll threshold, etc.) +2. **Emission**: Plugin emits `trigger:` event with metadata +3. **Context Update**: Core runtime listens, updates `context.triggers` +4. **Re-evaluation**: Core re-evaluates all experiences with updated context +5. **Targeting**: Experiences check `context.triggers` in `targeting.custom` + +### Core Infrastructure (✅ Implemented) + +#### 1. Context Type with Triggers +```typescript +// packages/core/src/types.ts +export interface Context { + url?: string; + user?: UserContext; + timestamp?: number; + custom?: Record; + triggers?: TriggerState; // NEW +} + +export interface TriggerState { + exitIntent?: { + triggered: boolean; + timestamp?: number; + lastY?: number; + previousY?: number; + velocity?: number; + timeOnPage?: number; + }; + scrollDepth?: { + triggered: boolean; + timestamp?: number; + percent?: number; + }; + pageVisits?: { + triggered: boolean; + timestamp?: number; + count?: number; + firstVisit?: boolean; + }; + timeDelay?: { + triggered: boolean; + timestamp?: number; + elapsed?: number; + }; + [key: string]: any; // Extensible +} +``` + +#### 2. Trigger Event Listener +```typescript +// packages/core/src/runtime.ts +private setupTriggerListeners(): void { + this.sdk.on('trigger:*', (eventName: string, data: any) => { + const triggerName = eventName.replace('trigger:', ''); + + // Update trigger context + this.triggerContext.triggers = this.triggerContext.triggers || {}; + this.triggerContext.triggers[triggerName] = { + triggered: true, + timestamp: Date.now(), + ...data, + }; + + // Re-evaluate with updated context + this.evaluate(this.triggerContext); + }); +} +``` + +--- + +## Plugin 1: Exit Intent (✅ Implemented) + +### Implementation Summary +- **File**: `packages/plugins/src/exit-intent/index.ts` +- **Tests**: `packages/plugins/src/exit-intent/exit-intent.test.ts` (21 tests) +- **Status**: ✅ Complete + +### Configuration +```typescript +export interface ExitIntentPluginConfig { + exitIntent?: { + sensitivity?: number; // Pixels from top (default: 50) + minTimeOnPage?: number; // Milliseconds (default: 2000) + delay?: number; // Delay before emit (default: 0) + positionHistorySize?: number; // Track last N positions (default: 30) + disableOnMobile?: boolean; // Disable on mobile (default: true) + }; +} +``` + +### Algorithm (Pathfora-Compatible) +```typescript +// Track mouse positions +document.addEventListener('mousemove', (e) => { + positions.push({ x: e.clientX, y: e.clientY }); + if (positions.length > maxSize) positions.shift(); +}); + +// Check exit intent on mouseout +document.addEventListener('mouseout', (e) => { + // Must leave document (not just an element) + const from = e.relatedTarget || e.toElement; + if (from && from.nodeName !== 'HTML') return; + + // Must have movement history + if (positions.length < 2) return; + + // Get velocity + const lastY = positions[positions.length - 1].y; + const prevY = positions[positions.length - 2].y; + const velocity = Math.abs(lastY - prevY); + + // Check: moving up + near top + const isMovingUp = lastY < prevY; + const isNearTop = lastY - velocity <= sensitivity; + + if (isMovingUp && isNearTop) { + instance.emit('trigger:exitIntent', { + lastY, previousY: prevY, velocity, timeOnPage + }); + } +}); +``` + +### Usage +```typescript +// In init config +init({ + exitIntent: { + sensitivity: 20, + minTimeOnPage: 2000 + } +}); + +// In experience targeting +register('exit-offer', { + type: 'banner', + content: { message: 'Wait! Get 15% off' }, + targeting: { + custom: (ctx) => ctx.triggers?.exitIntent?.triggered === true + }, + frequency: { max: 1, per: 'session' } +}); +``` + +--- + +## Plugin 2: Scroll Depth (TODO) + +### Research Phase +Review Pathfora's `scrollPercentageToDisplay`: +- How is scroll percentage calculated? +- Does it use `scrollTop` vs `scrollHeight`? +- How is it throttled? +- Does it support multiple thresholds? +- Edge cases: dynamic content, infinite scroll + +### Configuration +```typescript +export interface ScrollDepthPluginConfig { + scrollDepth?: { + thresholds?: number[]; // Percentages to track [25, 50, 75, 100] + throttle?: number; // Throttle interval (default: 100ms) + includeViewportHeight?: boolean; // Count viewport in calc (default: true) + }; +} +``` + +### Implementation +```typescript +export const scrollDepthPlugin: PluginFunction = (plugin, instance, config) => { + plugin.ns('experiences.scrollDepth'); + + plugin.defaults({ + scrollDepth: { + thresholds: [25, 50, 75, 100], + throttle: 100, + includeViewportHeight: true, + }, + }); + + const scrollConfig = config.get('scrollDepth'); + if (!scrollConfig) return; + + let maxScrollPercent = 0; + let triggeredThresholds = new Set(); + + function calculateScrollPercent(): number { + const scrollTop = window.pageYOffset || document.documentElement.scrollTop; + const docHeight = document.documentElement.scrollHeight; + const winHeight = window.innerHeight; + + if (scrollConfig.includeViewportHeight) { + return ((scrollTop + winHeight) / docHeight) * 100; + } + return (scrollTop / (docHeight - winHeight)) * 100; + } + + const throttledHandler = throttle(() => { + const currentPercent = calculateScrollPercent(); + maxScrollPercent = Math.max(maxScrollPercent, currentPercent); + + // Check thresholds + for (const threshold of scrollConfig.thresholds) { + if (currentPercent >= threshold && !triggeredThresholds.has(threshold)) { + triggeredThresholds.add(threshold); + instance.emit('trigger:scrollDepth', { + percent: currentPercent, + threshold, + maxPercent: maxScrollPercent, + }); + } + } + }, scrollConfig.throttle); + + window.addEventListener('scroll', throttledHandler, { passive: true }); + + // Cleanup + const destroyHandler = () => { + window.removeEventListener('scroll', throttledHandler); + }; + instance.on('destroy', destroyHandler); + + // Expose API + plugin.expose({ + scrollDepth: { + getMaxPercent: () => maxScrollPercent, + getCurrentPercent: () => calculateScrollPercent(), + reset: () => { + maxScrollPercent = 0; + triggeredThresholds.clear(); + }, + }, + }); +}; +``` + +### Usage +```typescript +// Show CTA after 50% scroll +register('mid-article-cta', { + type: 'banner', + content: { message: 'Enjoying the article? Subscribe!' }, + targeting: { + custom: (ctx) => (ctx.triggers?.scrollDepth?.percent || 0) >= 50 + } +}); +``` + +### Test Cases +- [ ] Calculate scroll percentage correctly +- [ ] Trigger at configured thresholds +- [ ] Throttle scroll events +- [ ] Handle dynamic content (height changes) +- [ ] Handle resize events +- [ ] Multiple thresholds don't re-trigger +- [ ] Reset clears all state + +--- + +## Plugin 3: Page Visits (TODO) + +### Research Phase +Review Pathfora's `pageVisits`: +- Session visits vs total visits? +- How is it stored (sessionStorage vs localStorage)? +- Does it reset on logout? +- How does it detect "first visit"? +- Privacy considerations + +### Configuration +```typescript +export interface PageVisitsPluginConfig { + pageVisits?: { + storage?: 'session' | 'local'; // Storage type (default: 'local') + namespace?: string; // Storage key prefix + trackFirstVisit?: boolean; // Track first-time visitors (default: true) + }; +} +``` + +### Implementation +```typescript +export const pageVisitsPlugin: PluginFunction = (plugin, instance, config) => { + plugin.ns('experiences.pageVisits'); + + plugin.defaults({ + pageVisits: { + storage: 'local', + namespace: 'xp:visits', + trackFirstVisit: true, + }, + }); + + const visitConfig = config.get('pageVisits'); + if (!visitConfig) return; + + // Determine storage + const storage = visitConfig.storage === 'session' ? sessionStorage : localStorage; + const key = `${visitConfig.namespace}:${window.location.pathname}`; + const sessionKey = `${key}:session`; + + // Get counts + function getSessionCount(): number { + try { + return parseInt(sessionStorage.getItem(sessionKey) || '0', 10); + } catch { + return 0; + } + } + + function getTotalCount(): number { + try { + return parseInt(storage.getItem(key) || '0', 10); + } catch { + return 0; + } + } + + // Increment on page load + const sessionCount = getSessionCount() + 1; + const totalCount = getTotalCount() + 1; + const isFirstVisit = totalCount === 1; + + try { + sessionStorage.setItem(sessionKey, sessionCount.toString()); + storage.setItem(key, totalCount.toString()); + } catch { + // Ignore storage errors + } + + // Emit immediately + instance.emit('trigger:pageVisits', { + count: totalCount, + sessionCount, + firstVisit: isFirstVisit, + }); + + // Expose API + plugin.expose({ + pageVisits: { + getSessionCount: () => getSessionCount(), + getTotalCount: () => getTotalCount(), + isFirstVisit: () => isFirstVisit, + reset: () => { + try { + sessionStorage.removeItem(sessionKey); + storage.removeItem(key); + } catch { + // Ignore + } + }, + }, + }); +}; +``` + +### Usage +```typescript +// First-time visitor welcome +register('welcome', { + type: 'banner', + content: { message: 'Welcome! Get 10% off your first order' }, + targeting: { + custom: (ctx) => ctx.triggers?.pageVisits?.firstVisit === true + } +}); + +// Show after 3 visits +register('loyalty-offer', { + type: 'modal', + content: { message: 'Thanks for coming back! Here\'s a gift' }, + targeting: { + custom: (ctx) => (ctx.triggers?.pageVisits?.count || 0) >= 3 + } +}); +``` + +### Test Cases +- [ ] Increment session count on each load +- [ ] Increment total count across sessions +- [ ] Detect first visit correctly +- [ ] Handle storage errors gracefully +- [ ] Reset clears all counts +- [ ] Works with localStorage and sessionStorage +- [ ] Namespace prevents collisions + +--- + +## Plugin 4: Time Delay (TODO) + +### Research Phase +Review Pathfora's `showDelay` and `hideAfter`: +- How are timers managed? +- What happens on page visibility change? +- How is cleanup handled? +- Can multiple delays coexist? +- Edge cases: rapid navigation, dismissals + +### Configuration +```typescript +export interface TimeDelayPluginConfig { + timeDelay?: { + showDelay?: number; // Delay before showing (ms) + hideAfter?: number; // Auto-hide duration (ms) + pauseWhenHidden?: boolean; // Pause on visibility change (default: true) + }; +} +``` + +### Implementation +```typescript +export const timeDelayPlugin: PluginFunction = (plugin, instance, config) => { + plugin.ns('experiences.timeDelay'); + + plugin.defaults({ + timeDelay: { + showDelay: 0, + hideAfter: 0, + pauseWhenHidden: true, + }, + }); + + const delayConfig = config.get('timeDelay'); + if (!delayConfig) return; + + let startTime = Date.now(); + let elapsed = 0; + let paused = false; + let showTimer: number | null = null; + let hideTimer: number | null = null; + + // Show delay + if (delayConfig.showDelay > 0) { + showTimer = window.setTimeout(() => { + elapsed = Date.now() - startTime; + instance.emit('trigger:timeDelay', { + elapsed, + type: 'show', + }); + }, delayConfig.showDelay); + } + + // Handle visibility changes + if (delayConfig.pauseWhenHidden) { + document.addEventListener('visibilitychange', () => { + if (document.hidden) { + // Pause timers + paused = true; + elapsed = Date.now() - startTime; + if (showTimer) { + clearTimeout(showTimer); + showTimer = null; + } + } else { + // Resume timers + paused = false; + startTime = Date.now() - elapsed; + + if (delayConfig.showDelay > 0 && elapsed < delayConfig.showDelay) { + showTimer = window.setTimeout(() => { + instance.emit('trigger:timeDelay', { + elapsed: delayConfig.showDelay, + type: 'show', + }); + }, delayConfig.showDelay - elapsed); + } + } + }); + } + + // Cleanup + const destroyHandler = () => { + if (showTimer) clearTimeout(showTimer); + if (hideTimer) clearTimeout(hideTimer); + }; + instance.on('destroy', destroyHandler); + + // Expose API + plugin.expose({ + timeDelay: { + getElapsed: () => paused ? elapsed : Date.now() - startTime, + isPaused: () => paused, + reset: () => { + startTime = Date.now(); + elapsed = 0; + paused = false; + if (showTimer) clearTimeout(showTimer); + if (hideTimer) clearTimeout(hideTimer); + }, + }, + }); +}; +``` + +### Usage +```typescript +// Show after 3 seconds +register('delayed-popup', { + type: 'modal', + content: { message: 'Special offer just for you!' }, + targeting: { + custom: (ctx) => (ctx.triggers?.timeDelay?.elapsed || 0) >= 3000 + } +}); +``` + +### Test Cases +- [ ] Trigger after configured delay +- [ ] Pause on visibility change +- [ ] Resume on visibility restore +- [ ] Handle rapid visibility changes +- [ ] Cleanup timers on destroy +- [ ] Reset clears all state +- [ ] Multiple timers can coexist + +--- + +## Integration Testing + +### Composition Tests +Test all plugins working together: + +```typescript +// Exit intent + scroll depth + time delay +register('engaged-user-offer', { + type: 'banner', + content: { message: 'You seem interested! Here\'s a deal' }, + targeting: { + custom: (ctx) => { + const scrolled = (ctx.triggers?.scrollDepth?.percent || 0) >= 50; + const timeElapsed = (ctx.triggers?.timeDelay?.elapsed || 0) >= 5000; + const exitIntent = ctx.triggers?.exitIntent?.triggered; + + // Show if: scrolled 50% AND (time > 5s OR exit intent) + return scrolled && (timeElapsed || exitIntent); + } + } +}); +``` + +### Test Cases +- [ ] Multiple triggers fire independently +- [ ] Complex logic in targeting.custom works +- [ ] Context is updated correctly for each trigger +- [ ] No memory leaks with multiple plugins +- [ ] Performance: <10ms total overhead + +--- + +## Documentation & Playground + +### Documentation +Create migration guide in `docs/`: +- API reference for each plugin +- Configuration options +- Event payloads +- Pathfora comparison table +- Common patterns + +### Playground Examples +Add to `experience-sdk-playground/`: +- Exit intent demo +- Scroll depth demo +- Page visits demo +- Time delay demo +- Combined triggers demo + +--- + +## Implementation Order + +1. ✅ **Exit Intent** (Complete) +2. **Scroll Depth** (Next) + - Simplest remaining plugin + - No storage dependencies + - Good test of throttling +3. **Page Visits** + - Requires storage + - Good test of persistence +4. **Time Delay** + - Most complex (timers, visibility API) + - Build on lessons from others +5. **Integration** + - Test composition + - Performance benchmarks + - Documentation + - Playground + +--- + +## Success Criteria + +- [ ] All 4 plugins implemented +- [ ] All plugins match Pathfora behavior +- [ ] >80% test coverage per plugin +- [ ] Integration tests pass +- [ ] Bundle size <8KB total (gzipped) +- [ ] Performance <10ms overhead +- [ ] Docs complete with examples +- [ ] Playground has working demos + diff --git a/specs/phase-1-display-conditions/spec.md b/specs/phase-1-display-conditions/spec.md new file mode 100644 index 0000000..a66e7b5 --- /dev/null +++ b/specs/phase-1-display-conditions/spec.md @@ -0,0 +1,258 @@ +# Phase 1: Display Condition Plugins + +**Status:** In Progress +**Started:** December 2024 +**Goal:** Implement trigger-based display condition plugins matching industry standards (informed by Pathfora, OptinMonster, Wisepops, and others) + +## Overview + +Add four display condition plugins to enable experiences triggered by user behavior: +- **Exit Intent** - Detect when users are about to leave the page +- **Scroll Depth** - Trigger at specific scroll thresholds +- **Page Visits** - Show experiences based on visit count +- **Time Delay** - Trigger after configurable delays + +## Goals + +### Primary Goals +1. **Event-Driven Architecture** - Clean separation between detection and evaluation (ahead of most commercial tools) +2. **Industry-Leading Capabilities** - Match or exceed capabilities from Pathfora, OptinMonster, Wisepops, and other leading tools +3. **Composability** - Plugins work independently and can be combined (unique advantage) +4. **Explainability** - All trigger states visible in context for debugging (competitive differentiator) + +### Secondary Goals +1. **Performance** - Minimal overhead for unused plugins +2. **Testability** - Comprehensive test coverage for all behaviors +3. **Developer Experience** - Simple, intuitive API + +## Scope + +### In Scope +- Exit intent detection (mouse leave from top) +- Scroll depth percentage tracking +- Page visit counting (session and total) +- Time-based delays (show after X seconds) +- Event-driven trigger system +- Context-based targeting rules +- Session persistence for triggers + +### Out of Scope (Future) +- Device-specific conditions (already handled by exit intent config) +- Geo-targeting (requires backend) +- A/B testing framework +- Complex DSL for display conditions + +## User Stories + +### Exit Intent +**As a** marketer +**I want** to show an offer when users are about to leave +**So that** I can reduce bounce rate and capture leads + +**Acceptance Criteria:** +- Detects upward mouse movement toward browser chrome +- Configurable sensitivity threshold +- Respects minimum time on page +- Only triggers once per session +- Can be combined with other targeting rules + +### Scroll Depth +**As a** content publisher +**I want** to show CTAs after users scroll 50% of the page +**So that** I engage users who are actually reading + +**Acceptance Criteria:** +- Tracks scroll percentage accurately +- Supports multiple thresholds +- Throttled for performance +- Works with dynamic content +- Emits events for analytics + +### Page Visits +**As a** product manager +**I want** to show onboarding only to first-time visitors +**So that** I don't annoy returning users + +**Acceptance Criteria:** +- Counts session visits +- Counts total visits across sessions +- Detects first-time vs returning +- Supports min/max ranges +- Respects user privacy (localStorage) + +### Time Delay +**As a** UX designer +**I want** to delay popups by 3 seconds +**So that** users have time to orient themselves + +**Acceptance Criteria:** +- Configurable delay before showing +- Configurable auto-hide duration +- Cleanup on experience dismiss +- Multiple timers can coexist +- Respects page visibility (pause when hidden) + +## Architecture + +### Event-Driven Pattern +```typescript +// Plugin detects condition +instance.emit('trigger:exitIntent', { timestamp, velocity, ... }); + +// Core listens and updates context +context.triggers.exitIntent = { triggered: true, timestamp, ... }; + +// Core re-evaluates all experiences +runtime.evaluate(context); + +// Experience checks trigger in targeting +targeting: { + custom: (ctx) => ctx.triggers.exitIntent?.triggered === true +} +``` + +### Benefits +1. **Decoupling** - Plugins don't know about experiences +2. **Composability** - Multiple triggers can fire independently +3. **Debuggability** - Full trigger state visible in context +4. **Testability** - Pure functions, easy to mock + +## Dependencies + +### Required +- Core SDK with trigger support (✅ Implemented) +- Context type with triggers field (✅ Implemented) +- Event listener for `trigger:*` events (✅ Implemented) + +### Optional +- Storage plugin (for page visits persistence) +- Debug plugin (for trigger visibility) + +## Success Metrics + +### Implementation +- [ ] All 4 plugins implemented +- [ ] >80% test coverage per plugin +- [ ] Integration tests with multiple triggers +- [ ] Zero linter errors + +### Quality +- [ ] Matches or exceeds industry standards (validated against Pathfora, OptinMonster, Wisepops) +- [ ] Performance: <5ms overhead per plugin (competitive with or better than alternatives) +- [ ] Bundle size: <2KB per plugin (gzipped, lighter than most solutions) +- [ ] Documentation with migration guides from multiple tools + +### Developer Experience +- [ ] Clear API documentation +- [ ] Working examples in playground +- [ ] TypeScript types for all configs +- [ ] Helpful error messages + +## Migration from Pathfora + +### Exit Intent +```javascript +// Pathfora +displayConditions: { + showOnExitIntent: true +} + +// Experience SDK +targeting: { + custom: (ctx) => ctx.triggers?.exitIntent?.triggered === true +} +``` + +### Scroll Depth +```javascript +// Pathfora +displayConditions: { + scrollPercentageToDisplay: 50 +} + +// Experience SDK +targeting: { + custom: (ctx) => (ctx.triggers?.scrollDepth?.percent || 0) >= 50 +} +``` + +### Page Visits +```javascript +// Pathfora +displayConditions: { + pageVisits: 3 +} + +// Experience SDK +targeting: { + custom: (ctx) => (ctx.triggers?.pageVisits?.count || 0) >= 3 +} +``` + +### Time Delay +```javascript +// Pathfora +displayConditions: { + showDelay: 3000, + hideAfter: 10000 +} + +// Experience SDK +targeting: { + custom: (ctx) => ctx.triggers?.timeDelay?.elapsed >= 3000 +} +// Auto-hide handled by plugin config +``` + +## Risks & Mitigations + +### Risk: Performance Impact +**Mitigation:** +- Throttle scroll listeners (100ms) +- Only track when plugin is enabled +- Cleanup listeners on trigger + +### Risk: Browser Compatibility +**Mitigation:** +- Polyfill for older browsers if needed +- Graceful degradation +- Comprehensive browser testing + +### Risk: Privacy Concerns +**Mitigation:** +- Only use sessionStorage/localStorage +- No external tracking +- Respect Do Not Track +- Clear data on opt-out + +## Timeline + +- ✅ **Week 1:** Exit Intent plugin (Complete) +- [ ] **Week 2:** Scroll Depth plugin +- [ ] **Week 3:** Page Visits plugin +- [ ] **Week 4:** Time Delay plugin +- [ ] **Week 5:** Integration tests, docs, playground examples + +## References + +### Competitive Analysis +- [Exit Intent Competitive Analysis](../../notes/exit-intent-competitive-analysis.md) - Analysis of 8+ tools +- [Exit Intent Architecture Analysis](../../notes/exit-intent-architecture.md) - Event-driven design + +### Industry Tools Analyzed +- [Pathfora Display Conditions](https://lytics.github.io/pathforadocs/display_conditions/) - Primary reference +- OptinMonster - Exit intent leader, 200+ customers +- Wisepops - Modern popup platform +- Picreel - Exit intent specialist +- Privy - E-commerce focused +- Sumo - Popular exit intent tool + +### Our Research +- [Exit Intent Research](../../notes/exit-intent-research.md) - Library implementation details + +### Key Competitive Advantages +1. **Event-driven architecture** - Most tools use tight coupling +2. **Composability** - Most tools don't allow combining triggers +3. **Explainability** - Most tools are black boxes +4. **Developer-friendly** - TypeScript, testable, open-source + diff --git a/specs/phase-1-display-conditions/tasks.md b/specs/phase-1-display-conditions/tasks.md new file mode 100644 index 0000000..73c9b17 --- /dev/null +++ b/specs/phase-1-display-conditions/tasks.md @@ -0,0 +1,410 @@ +# Phase 1: Display Condition Plugins - Tasks + +This file breaks down Phase 2 into GitHub-ready issues. + +## Labels +- `phase-1` - Phase 1: Display Conditions +- `plugin` - Plugin implementation +- `testing` - Test-related work +- `documentation` - Documentation updates +- `enhancement` - New feature + +## Milestone +**Phase 1: Display Condition Plugins** + +--- + +## Task 1: Core Infrastructure for Triggers +**Status:** ✅ Complete +**PR:** #TBD (Already merged to `feat/display-condition-plugins`) + +### Description +Add core infrastructure to support trigger-based display conditions: +- `triggers` field in Context type +- `trigger:*` event listener in ExperienceRuntime +- Event-driven pattern for plugin composition + +### Acceptance Criteria +- [x] Context type includes `triggers?: TriggerState` +- [x] TriggerState interface supports all 4 plugins +- [x] Runtime listens for `trigger:*` events +- [x] Runtime updates context on trigger events +- [x] Runtime re-evaluates experiences after trigger +- [x] All existing tests still pass + +--- + +## Task 2: Exit Intent Plugin +**Status:** ✅ Complete +**PR:** #TBD (Already merged to `feat/display-condition-plugins`) +**Labels:** `phase-1`, `plugin`, `enhancement` + +### Description +Implement exit intent detection plugin matching Pathfora's `showOnExitIntent`. + +### Acceptance Criteria +- [x] Plugin detects upward mouse movement near top +- [x] Configurable sensitivity, minTimeOnPage, delay +- [x] Emits `trigger:exitIntent` event +- [x] Session-based suppression (one trigger per session) +- [x] Mobile detection and disabling +- [x] API: `isTriggered()`, `reset()`, `getPositions()` +- [x] 21+ tests covering all Pathfora behaviors +- [x] TypeScript types exported +- [x] Integrated into core runtime + +--- + +## Task 3: Research Scroll Depth Plugin +**Status:** TODO +**Labels:** `phase-1`, `research` + +### Description +Research Pathfora's `scrollPercentageToDisplay` implementation and design our API. + +### Tasks +- [ ] Review Pathfora source code for scroll tracking +- [ ] Document scroll percentage calculation algorithm +- [ ] Identify edge cases (dynamic content, resize, etc.) +- [ ] Research throttling strategies +- [ ] Document multiple threshold support +- [ ] Create `notes/scroll-depth-research.md` + +### Acceptance Criteria +- [ ] Research notes document Pathfora behavior +- [ ] Edge cases identified and documented +- [ ] API design proposed +- [ ] Test cases outlined + +--- + +## Task 4: Implement Scroll Depth Plugin +**Status:** TODO +**Depends on:** Task 3 +**Labels:** `phase-1`, `plugin`, `enhancement` + +### Description +Implement scroll depth tracking plugin. + +### Tasks +- [ ] Create `packages/plugins/src/scroll-depth/index.ts` +- [ ] Create `packages/plugins/src/scroll-depth/types.ts` +- [ ] Implement scroll percentage calculation +- [ ] Add throttled scroll listener +- [ ] Support multiple thresholds +- [ ] Handle viewport height inclusion +- [ ] Emit `trigger:scrollDepth` events +- [ ] Add cleanup on destroy +- [ ] Export from `packages/plugins/src/index.ts` +- [ ] Auto-register in core runtime + +### Acceptance Criteria +- [ ] Plugin tracks scroll percentage accurately +- [ ] Throttling works (100ms default) +- [ ] Multiple thresholds supported +- [ ] Events emitted with correct data +- [ ] API: `getMaxPercent()`, `getCurrentPercent()`, `reset()` +- [ ] Handles dynamic content height changes +- [ ] No memory leaks + +--- + +## Task 5: Test Scroll Depth Plugin +**Status:** TODO +**Depends on:** Task 4 +**Labels:** `phase-1`, `plugin`, `testing` + +### Description +Write comprehensive tests for scroll depth plugin. + +### Tasks +- [ ] Create `packages/plugins/src/scroll-depth/scroll-depth.test.ts` +- [ ] Test scroll percentage calculation +- [ ] Test threshold triggering +- [ ] Test throttling behavior +- [ ] Test dynamic content handling +- [ ] Test resize handling +- [ ] Test reset functionality +- [ ] Test API methods +- [ ] Achieve >80% coverage + +### Acceptance Criteria +- [ ] All Pathfora test cases covered +- [ ] >80% test coverage +- [ ] All tests pass +- [ ] No flaky tests + +--- + +## Task 6: Research Page Visits Plugin +**Status:** TODO +**Labels:** `phase-1`, `research` + +### Description +Research Pathfora's `pageVisits` implementation and design our API. + +### Tasks +- [ ] Review Pathfora source code for visit tracking +- [ ] Document session vs total visit counting +- [ ] Identify storage strategy (sessionStorage vs localStorage) +- [ ] Research first-time visitor detection +- [ ] Document privacy considerations +- [ ] Create `notes/page-visits-research.md` + +### Acceptance Criteria +- [ ] Research notes document Pathfora behavior +- [ ] Storage strategy decided +- [ ] API design proposed +- [ ] Test cases outlined + +--- + +## Task 7: Implement Page Visits Plugin +**Status:** TODO +**Depends on:** Task 6 +**Labels:** `phase-1`, `plugin`, `enhancement` + +### Description +Implement page visit tracking plugin. + +### Tasks +- [ ] Create `packages/plugins/src/page-visits/index.ts` +- [ ] Create `packages/plugins/src/page-visits/types.ts` +- [ ] Implement session visit counter +- [ ] Implement total visit counter +- [ ] Add first-visit detection +- [ ] Emit `trigger:pageVisits` on load +- [ ] Handle storage errors gracefully +- [ ] Add cleanup on destroy +- [ ] Export from `packages/plugins/src/index.ts` +- [ ] Auto-register in core runtime + +### Acceptance Criteria +- [ ] Plugin counts session visits correctly +- [ ] Plugin counts total visits correctly +- [ ] First visit detection works +- [ ] Storage errors handled gracefully +- [ ] API: `getSessionCount()`, `getTotalCount()`, `isFirstVisit()`, `reset()` +- [ ] Namespace prevents collisions + +--- + +## Task 8: Test Page Visits Plugin +**Status:** TODO +**Depends on:** Task 7 +**Labels:** `phase-1`, `plugin`, `testing` + +### Description +Write comprehensive tests for page visits plugin. + +### Tasks +- [ ] Create `packages/plugins/src/page-visits/page-visits.test.ts` +- [ ] Test session counter increment +- [ ] Test total counter increment +- [ ] Test first visit detection +- [ ] Test storage error handling +- [ ] Test reset functionality +- [ ] Test API methods +- [ ] Test namespace isolation +- [ ] Achieve >80% coverage + +### Acceptance Criteria +- [ ] All Pathfora test cases covered +- [ ] >80% test coverage +- [ ] All tests pass +- [ ] No flaky tests + +--- + +## Task 9: Research Time Delay Plugin +**Status:** TODO +**Labels:** `phase-1`, `research` + +### Description +Research Pathfora's `showDelay` and `hideAfter` implementation and design our API. + +### Tasks +- [ ] Review Pathfora source code for delay handling +- [ ] Document timer management strategy +- [ ] Research Page Visibility API integration +- [ ] Identify edge cases (rapid navigation, dismissals) +- [ ] Document cleanup requirements +- [ ] Create `notes/time-delay-research.md` + +### Acceptance Criteria +- [ ] Research notes document Pathfora behavior +- [ ] Timer management strategy decided +- [ ] API design proposed +- [ ] Test cases outlined + +--- + +## Task 10: Implement Time Delay Plugin +**Status:** TODO +**Depends on:** Task 9 +**Labels:** `phase-1`, `plugin`, `enhancement` + +### Description +Implement time-based delay plugin. + +### Tasks +- [ ] Create `packages/plugins/src/time-delay/index.ts` +- [ ] Create `packages/plugins/src/time-delay/types.ts` +- [ ] Implement show delay timer +- [ ] Implement hide after timer +- [ ] Add Page Visibility API support +- [ ] Emit `trigger:timeDelay` events +- [ ] Handle timer cleanup +- [ ] Add pause/resume on visibility change +- [ ] Export from `packages/plugins/src/index.ts` +- [ ] Auto-register in core runtime + +### Acceptance Criteria +- [ ] Plugin delays showing correctly +- [ ] Plugin auto-hides after duration +- [ ] Visibility changes handled correctly +- [ ] Timers cleaned up on destroy +- [ ] API: `getElapsed()`, `isPaused()`, `reset()` +- [ ] Multiple timers can coexist + +--- + +## Task 11: Test Time Delay Plugin +**Status:** TODO +**Depends on:** Task 10 +**Labels:** `phase-1`, `plugin`, `testing` + +### Description +Write comprehensive tests for time delay plugin. + +### Tasks +- [ ] Create `packages/plugins/src/time-delay/time-delay.test.ts` +- [ ] Test show delay triggering +- [ ] Test hide after duration +- [ ] Test visibility pause/resume +- [ ] Test rapid visibility changes +- [ ] Test cleanup on destroy +- [ ] Test reset functionality +- [ ] Test API methods +- [ ] Achieve >80% coverage + +### Acceptance Criteria +- [ ] All Pathfora test cases covered +- [ ] >80% test coverage +- [ ] All tests pass +- [ ] No flaky tests + +--- + +## Task 12: Integration Testing +**Status:** TODO +**Depends on:** Tasks 2, 5, 8, 11 +**Labels:** `phase-1`, `testing` + +### Description +Test all plugins working together in combination. + +### Tasks +- [ ] Create integration test suite +- [ ] Test multiple triggers firing independently +- [ ] Test complex targeting logic +- [ ] Test context updates across plugins +- [ ] Test memory usage with all plugins +- [ ] Test performance overhead +- [ ] Run in multiple browsers + +### Acceptance Criteria +- [ ] All plugins work independently +- [ ] Plugins compose correctly +- [ ] No context conflicts +- [ ] No memory leaks +- [ ] Performance <10ms total overhead +- [ ] Works in Chrome, Firefox, Safari + +--- + +## Task 13: Documentation +**Status:** TODO +**Depends on:** Task 12 +**Labels:** `phase-1`, `documentation` + +### Description +Document all display condition plugins. + +### Tasks +- [ ] Update `docs/reference/plugins.mdx` +- [ ] Add exit intent section +- [ ] Add scroll depth section +- [ ] Add page visits section +- [ ] Add time delay section +- [ ] Create Pathfora migration guide +- [ ] Add configuration examples +- [ ] Add targeting examples +- [ ] Add composition examples +- [ ] Update README + +### Acceptance Criteria +- [ ] All plugins documented +- [ ] Migration guide complete +- [ ] Examples for each plugin +- [ ] Composition patterns shown +- [ ] API reference complete + +--- + +## Task 14: Playground Examples +**Status:** TODO +**Depends on:** Task 12 +**Labels:** `phase-1`, `playground`, `documentation` + +### Description +Add working examples to the playground. + +### Tasks +- [ ] Create exit intent demo page +- [ ] Create scroll depth demo page +- [ ] Create page visits demo page +- [ ] Create time delay demo page +- [ ] Create combined triggers demo page +- [ ] Add explainability views +- [ ] Add reset buttons for testing +- [ ] Update homepage with new examples + +### Acceptance Criteria +- [ ] One demo per plugin +- [ ] Combined demo showing composition +- [ ] Explainability visible (decision, trace, context) +- [ ] Easy to test and debug +- [ ] Mobile-friendly + +--- + +## Summary + +### Completed (2) +- [x] Task 1: Core Infrastructure +- [x] Task 2: Exit Intent Plugin + +### In Progress (0) + +### TODO (12) +- [ ] Task 3: Research Scroll Depth +- [ ] Task 4: Implement Scroll Depth +- [ ] Task 5: Test Scroll Depth +- [ ] Task 6: Research Page Visits +- [ ] Task 7: Implement Page Visits +- [ ] Task 8: Test Page Visits +- [ ] Task 9: Research Time Delay +- [ ] Task 10: Implement Time Delay +- [ ] Task 11: Test Time Delay +- [ ] Task 12: Integration Testing +- [ ] Task 13: Documentation +- [ ] Task 14: Playground Examples + +### Estimated Timeline +- **Week 1:** ✅ Tasks 1-2 (Complete) +- **Week 2:** Tasks 3-5 (Scroll Depth) +- **Week 3:** Tasks 6-8 (Page Visits) +- **Week 4:** Tasks 9-11 (Time Delay) +- **Week 5:** Tasks 12-14 (Integration, Docs, Playground) +