diff --git a/docs/campaign-system.md b/docs/campaign-system.md new file mode 100644 index 000000000..db269ae0e --- /dev/null +++ b/docs/campaign-system.md @@ -0,0 +1,637 @@ +# Intro.js Campaign System + +The Intro.js Campaign System is a powerful no-code solution for creating and managing guided tours and hints using JSON configuration files. It enables you to create sophisticated user onboarding experiences without writing any JavaScript code. + +## Table of Contents + +- [Overview](#overview) +- [Installation](#installation) +- [Quick Start](#quick-start) +- [Campaign Structure](#campaign-structure) +- [Trigger Types](#trigger-types) +- [Frequency Management](#frequency-management) +- [Targeting Options](#targeting-options) +- [Analytics](#analytics) +- [Examples](#examples) +- [API Reference](#api-reference) + +## Overview + +The Campaign System allows you to: + +- Create tours and hints using JSON configuration +- Define multiple trigger conditions (first visit, element click, idle user, etc.) +- Control campaign frequency and timing +- Target specific user segments +- Track campaign analytics +- Manage multiple campaigns simultaneously + +## Installation + +The campaign system is included in Intro.js. Import it as follows: + +```javascript +import { initializeCampaigns } from 'intro.js/campaign'; +``` + +Or using CommonJS: + +```javascript +const { initializeCampaigns } = require('intro.js/campaign'); +``` + +## Quick Start + +### 1. Create a Campaign JSON File + +Create a `campaigns.json` file: + +```json +{ + "version": "1.0.0", + "campaigns": [ + { + "id": "welcome-tour", + "name": "Welcome Tour", + "active": true, + "mode": "tour", + "triggers": [ + { + "type": "first_visit", + "delay": 1000 + } + ], + "tourOptions": { + "steps": [ + { + "element": "#header", + "intro": "Welcome! This is the header.", + "position": "bottom" + } + ] + } + } + ] +} +``` + +### 2. Initialize Campaigns + +```javascript +import { initializeCampaigns } from 'intro.js/campaign'; + +// Load from URL +await initializeCampaigns('/campaigns.json'); + +// Or load from object +await initializeCampaigns({ + version: '1.0.0', + campaigns: [/* your campaigns */] +}); +``` + +## Campaign Structure + +### Basic Campaign Schema + +```typescript +interface Campaign { + id: string; // Unique identifier + name: string; // Display name + description?: string; // Optional description + version?: string; // Campaign version + active: boolean; // Enable/disable campaign + mode: "tour" | "hint"; // Tour or Hint mode + triggers: CampaignTrigger[]; // Trigger conditions + tourOptions?: Partial; // Tour configuration + hintOptions?: Partial; // Hint configuration + frequency?: CampaignFrequency; // Frequency settings + targeting?: CampaignTargeting; // Targeting rules + analytics?: CampaignAnalytics; // Analytics configuration +} +``` + +## Trigger Types + +### 1. First Visit Trigger + +Triggers when a user visits the page for the first time. + +```json +{ + "type": "first_visit", + "delay": 1000, + "cookieName": "custom-cookie-name" +} +``` + +### 2. Element Click Trigger + +Triggers when a user clicks on a specific element. + +```json +{ + "type": "element_click", + "selector": ".help-button", + "delay": 500 +} +``` + +### 3. Element Hover Trigger + +Triggers when a user hovers over a specific element. + +```json +{ + "type": "element_hover", + "selector": ".feature-icon", + "hoverDuration": 1000 +} +``` + +### 4. Idle User Trigger + +Triggers when a user is idle for a specified period. + +```json +{ + "type": "idle_user", + "idleTime": 30000 +} +``` + +### 5. Page Load Trigger + +Triggers when the page finishes loading. + +```json +{ + "type": "page_load", + "delay": 2000 +} +``` + +### 6. Scroll to Element Trigger + +Triggers when a user scrolls to a specific element. + +```json +{ + "type": "scroll_to_element", + "selector": ".pricing-section", + "threshold": 0.5 +} +``` + +### 7. Time on Page Trigger + +Triggers after a user spends a specific amount of time on the page. + +```json +{ + "type": "time_on_page", + "duration": 60000 +} +``` + +### 8. Exit Intent Trigger + +Triggers when a user shows exit intent (mouse leaves viewport). + +```json +{ + "type": "exit_intent", + "sensitivity": 10 +} +``` + +### 9. Form Interaction Trigger + +Triggers when a user interacts with form elements. + +```json +{ + "type": "form_interaction", + "selector": "#signup-form", + "interactionType": "focus" +} +``` + +### 10. Custom Event Trigger + +Triggers on a custom JavaScript event. + +```json +{ + "type": "custom_event", + "eventName": "userRegistered" +} +``` + +### 11. URL Match Trigger + +Triggers when the URL matches a specific pattern. + +```json +{ + "type": "url_match", + "pattern": "/dashboard.*", + "matchType": "regex" +} +``` + +### 12. Device Type Trigger + +Triggers for specific device types. + +```json +{ + "type": "device_type", + "device": "mobile" +} +``` + +### 13. Returning User Trigger + +Triggers for users who have visited before. + +```json +{ + "type": "returning_user", + "minVisits": 2 +} +``` + +### 14. Session Count Trigger + +Triggers based on session count. + +```json +{ + "type": "session_count", + "count": 3, + "operator": "equal" +} +``` + +### 15. Scroll Depth Trigger + +Triggers when a user scrolls to a specific depth. + +```json +{ + "type": "scroll_depth", + "percentage": 50 +} +``` + +### 16. Element Visible Trigger + +Triggers when a specific element becomes visible. + +```json +{ + "type": "element_visible", + "selector": ".product-demo", + "threshold": 0.75 +} +``` + +## Frequency Management + +Control how often campaigns are shown: + +```json +{ + "frequency": { + "type": "once", // once, daily, weekly, monthly, session, always + "limit": 3, // Maximum number of times to show + "cooldownMs": 86400000 // Cooldown period in milliseconds + } +} +``` + +### Frequency Types + +- **once**: Show only once ever +- **daily**: Show once per day +- **weekly**: Show once per week +- **monthly**: Show once per month +- **session**: Show once per session +- **always**: Show every time (use with cooldown) + +## Targeting Options + +Target specific user segments: + +```json +{ + "targeting": { + "userAgent": ["Chrome.*", "Firefox.*"], + "language": ["en", "en-US"], + "referrer": ["google\\.com", "facebook\\.com"], + "queryParams": { + "utm_campaign": "summer-sale" + }, + "localStorage": { + "userType": "premium" + }, + "sessionStorage": { + "firstVisit": "true" + }, + "customFunction": "isEligibleUser" + } +} +``` + +## Analytics + +Track campaign performance: + +```json +{ + "analytics": { + "trackViews": true, + "trackCompletions": true, + "trackSkips": true, + "trackStepChanges": true, + "customEvents": ["button_click", "form_submit"], + "callbackFunction": "handleCampaignAnalytics" + } +} +``` + +### Analytics Callback Example + +```javascript +window.handleCampaignAnalytics = function(event, campaign, context) { + console.log('Campaign Event:', event); + console.log('Campaign:', campaign.name); + console.log('Context:', context); + + // Send to your analytics service + analytics.track(`campaign_${event}`, { + campaignId: campaign.id, + campaignName: campaign.name, + userId: context.user.id + }); +}; +``` + +## Examples + +### Example 1: Welcome Tour for First-Time Users + +```json +{ + "id": "welcome-tour", + "name": "Welcome Tour", + "active": true, + "mode": "tour", + "triggers": [ + { + "type": "first_visit", + "delay": 1000 + } + ], + "frequency": { + "type": "once" + }, + "tourOptions": { + "steps": [ + { + "element": "#dashboard", + "intro": "Welcome to your dashboard!", + "position": "bottom" + }, + { + "element": "#menu", + "intro": "Access all features from here.", + "position": "right" + } + ], + "showProgress": true + }, + "analytics": { + "trackCompletions": true + } +} +``` + +### Example 2: Feature Discovery on Button Click + +```json +{ + "id": "feature-tour", + "name": "Feature Discovery", + "active": true, + "mode": "tour", + "triggers": [ + { + "type": "element_click", + "selector": ".help-icon" + } + ], + "frequency": { + "type": "session" + }, + "tourOptions": { + "steps": [ + { + "element": ".advanced-features", + "intro": "Discover our advanced features!", + "position": "left" + } + ] + } +} +``` + +### Example 3: Re-engagement for Idle Users + +```json +{ + "id": "idle-reengagement", + "name": "Idle User Re-engagement", + "active": true, + "mode": "tour", + "triggers": [ + { + "type": "idle_user", + "idleTime": 30000 + } + ], + "frequency": { + "type": "daily" + }, + "tourOptions": { + "steps": [ + { + "intro": "Still here? Check out these tips!", + "position": "floating" + } + ] + } +} +``` + +### Example 4: Mobile-Only Tour + +```json +{ + "id": "mobile-tour", + "name": "Mobile Experience Tour", + "active": true, + "mode": "tour", + "triggers": [ + { + "type": "device_type", + "device": "mobile" + }, + { + "type": "page_load", + "delay": 1000 + } + ], + "targeting": { + "device": ["mobile", "tablet"] + }, + "tourOptions": { + "steps": [ + { + "element": ".mobile-menu", + "intro": "Tap here to access the mobile menu.", + "position": "bottom" + } + ] + } +} +``` + +## API Reference + +### `initializeCampaigns(config)` + +Initialize campaigns from configuration. + +**Parameters:** +- `config`: `CampaignCollection | Campaign[] | string` - Campaign configuration or URL + +**Returns:** `Promise` + +**Example:** +```javascript +const manager = await initializeCampaigns('/campaigns.json'); +``` + +### `getCampaignManager()` + +Get the global campaign manager instance. + +**Returns:** `CampaignManager` + +**Example:** +```javascript +import { getCampaignManager } from 'intro.js/campaign'; + +const manager = getCampaignManager(); +``` + +### CampaignManager Methods + +#### `addCampaign(campaign: Campaign)` + +Add a campaign programmatically. + +```javascript +await manager.addCampaign({ + id: 'new-campaign', + name: 'New Campaign', + active: true, + mode: 'tour', + triggers: [{ type: 'page_load' }], + tourOptions: { steps: [] } +}); +``` + +#### `removeCampaign(campaignId: string)` + +Remove a campaign. + +```javascript +manager.removeCampaign('welcome-tour'); +``` + +#### `getCampaigns(): Campaign[]` + +Get all active campaigns. + +```javascript +const campaigns = manager.getCampaigns(); +``` + +#### `getCampaign(campaignId: string): Campaign | undefined` + +Get a specific campaign. + +```javascript +const campaign = manager.getCampaign('welcome-tour'); +``` + +#### `stopAllCampaigns()` + +Stop all running campaigns. + +```javascript +await manager.stopAllCampaigns(); +``` + +#### `destroy()` + +Destroy the campaign manager and clean up. + +```javascript +manager.destroy(); +``` + +## Best Practices + +1. **Start Simple**: Begin with basic triggers and gradually add complexity +2. **Test Thoroughly**: Test campaigns across different devices and browsers +3. **Monitor Analytics**: Track campaign performance and iterate +4. **Respect Users**: Don't show campaigns too frequently +5. **Mobile First**: Ensure campaigns work well on mobile devices +6. **Performance**: Keep campaign JSON files small and load them asynchronously +7. **Accessibility**: Ensure campaigns are keyboard-navigable and screen-reader friendly + +## Troubleshooting + +### Campaign Not Triggering + +- Check that `active` is set to `true` +- Verify trigger conditions are met +- Check browser console for errors +- Ensure elements referenced in selectors exist + +### Campaign Shows Too Often + +- Adjust `frequency` settings +- Add `cooldownMs` to prevent frequent displays +- Use `limit` to cap the number of times shown + +### Performance Issues + +- Reduce the number of active campaigns +- Optimize trigger conditions +- Use `delay` to defer campaign execution +- Load campaigns asynchronously + +## Support + +For issues, questions, or contributions, please visit: +- GitHub: https://github.com/usablica/intro.js +- Documentation: https://introjs.com/docs + +## License + +The Campaign System is part of Intro.js and follows the same license. diff --git a/example/campaigns/element-click-tour.json b/example/campaigns/element-click-tour.json new file mode 100644 index 000000000..dc3f9dbff --- /dev/null +++ b/example/campaigns/element-click-tour.json @@ -0,0 +1,43 @@ +{ + "version": "1.0.0", + "campaigns": [ + { + "id": "feature-discovery", + "name": "Feature Discovery Tour", + "description": "Tour that starts when user clicks on a specific element", + "active": true, + "mode": "tour", + "triggers": [ + { + "type": "element_click", + "selector": ".help-button", + "delay": 500 + } + ], + "frequency": { + "type": "session" + }, + "tourOptions": { + "steps": [ + { + "element": ".feature-panel", + "intro": "This is our main feature panel where you can access advanced tools.", + "position": "left" + }, + { + "element": ".settings-icon", + "intro": "Click here to customize your preferences.", + "position": "bottom" + }, + { + "element": ".export-button", + "intro": "Use this button to export your data in various formats.", + "position": "top" + } + ], + "showProgress": true, + "showBullets": false + } + } + ] +} diff --git a/example/campaigns/exit-intent-promotion.json b/example/campaigns/exit-intent-promotion.json new file mode 100644 index 000000000..d72c6e727 --- /dev/null +++ b/example/campaigns/exit-intent-promotion.json @@ -0,0 +1,40 @@ +{ + "version": "1.0.0", + "campaigns": [ + { + "id": "exit-intent-promotion", + "name": "Exit Intent - Special Offer", + "description": "Show special offer when user tries to leave the page", + "active": true, + "mode": "tour", + "triggers": [ + { + "type": "exit_intent", + "sensitivity": 10 + } + ], + "frequency": { + "type": "once" + }, + "tourOptions": { + "steps": [ + { + "intro": "

Wait! Don't leave yet!

We have a special offer just for you. Get 20% off your first purchase!

", + "position": "floating" + }, + { + "element": ".promo-code", + "intro": "Use code WELCOME20 at checkout to get your discount.", + "position": "bottom" + } + ], + "exitOnEsc": true, + "exitOnOverlayClick": true + }, + "analytics": { + "trackViews": true, + "trackCompletions": true + } + } + ] +} diff --git a/example/campaigns/first-visit-tour.json b/example/campaigns/first-visit-tour.json new file mode 100644 index 000000000..a3bf26d65 --- /dev/null +++ b/example/campaigns/first-visit-tour.json @@ -0,0 +1,48 @@ +{ + "version": "1.0.0", + "campaigns": [ + { + "id": "welcome-tour", + "name": "Welcome Tour - First Visit", + "description": "A welcome tour that appears when a user visits the site for the first time", + "active": true, + "mode": "tour", + "triggers": [ + { + "type": "first_visit", + "delay": 1000 + } + ], + "frequency": { + "type": "once" + }, + "tourOptions": { + "steps": [ + { + "element": "#header", + "intro": "Welcome to our application! Let's take a quick tour.", + "position": "bottom" + }, + { + "element": "#navigation", + "intro": "This is the main navigation menu. You can access all features from here.", + "position": "right" + }, + { + "element": "#content", + "intro": "Your main content will appear here.", + "position": "top" + }, + { + "element": "#footer", + "intro": "That's it! Enjoy using our application.", + "position": "top" + } + ], + "showProgress": true, + "showBullets": true, + "exitOnOverlayClick": false + } + } + ] +} diff --git a/example/campaigns/form-assistance.json b/example/campaigns/form-assistance.json new file mode 100644 index 000000000..8141749db --- /dev/null +++ b/example/campaigns/form-assistance.json @@ -0,0 +1,43 @@ +{ + "version": "1.0.0", + "campaigns": [ + { + "id": "form-help-tour", + "name": "Form Assistance Tour", + "description": "Provide help when user starts filling out a form", + "active": true, + "mode": "tour", + "triggers": [ + { + "type": "form_interaction", + "selector": "#signup-form input", + "interactionType": "focus" + } + ], + "frequency": { + "type": "once" + }, + "tourOptions": { + "steps": [ + { + "element": "#email-field", + "intro": "Enter your email address. We'll never share it with anyone.", + "position": "right" + }, + { + "element": "#password-field", + "intro": "Choose a strong password with at least 8 characters, including numbers and symbols.", + "position": "right" + }, + { + "element": "#terms-checkbox", + "intro": "Please review and accept our terms of service and privacy policy.", + "position": "top" + } + ], + "exitOnEsc": true, + "showBullets": false + } + } + ] +} diff --git a/example/campaigns/idle-user-engagement.json b/example/campaigns/idle-user-engagement.json new file mode 100644 index 000000000..ac50020ed --- /dev/null +++ b/example/campaigns/idle-user-engagement.json @@ -0,0 +1,41 @@ +{ + "version": "1.0.0", + "campaigns": [ + { + "id": "idle-engagement", + "name": "Idle User Engagement", + "description": "Re-engage users who have been idle for a certain period", + "active": true, + "mode": "tour", + "triggers": [ + { + "type": "idle_user", + "idleTime": 30000 + } + ], + "frequency": { + "type": "session" + }, + "tourOptions": { + "steps": [ + { + "intro": "Still here? Let us show you some features you might have missed!", + "position": "floating" + }, + { + "element": ".shortcuts-panel", + "intro": "Did you know you can use keyboard shortcuts? Press '?' to see all available shortcuts.", + "position": "right" + }, + { + "element": ".recent-items", + "intro": "Your recently accessed items are always available here for quick access.", + "position": "left" + } + ], + "exitOnEsc": true, + "exitOnOverlayClick": true + } + } + ] +} diff --git a/example/campaigns/returning-user-features.json b/example/campaigns/returning-user-features.json new file mode 100644 index 000000000..302496aee --- /dev/null +++ b/example/campaigns/returning-user-features.json @@ -0,0 +1,50 @@ +{ + "version": "1.0.0", + "campaigns": [ + { + "id": "returning-user-advanced", + "name": "Advanced Features for Returning Users", + "description": "Show advanced features to users who have visited before", + "active": true, + "mode": "tour", + "triggers": [ + { + "type": "returning_user", + "minVisits": 2, + "delay": 2000 + } + ], + "frequency": { + "type": "once" + }, + "tourOptions": { + "steps": [ + { + "intro": "Welcome back! We noticed you've been here before. Let us show you some advanced features!", + "position": "floating" + }, + { + "element": ".keyboard-shortcuts", + "intro": "Press 'Ctrl+K' to access the command palette for quick navigation.", + "position": "bottom" + }, + { + "element": ".advanced-filters", + "intro": "Use advanced filters to find exactly what you need quickly.", + "position": "left" + }, + { + "element": ".bulk-actions", + "intro": "Select multiple items and perform bulk actions to save time.", + "position": "top" + } + ], + "showProgress": true + }, + "analytics": { + "trackViews": true, + "trackCompletions": true + } + } + ] +} diff --git a/example/campaigns/scroll-depth-newsletter.json b/example/campaigns/scroll-depth-newsletter.json new file mode 100644 index 000000000..09167abea --- /dev/null +++ b/example/campaigns/scroll-depth-newsletter.json @@ -0,0 +1,40 @@ +{ + "version": "1.0.0", + "campaigns": [ + { + "id": "newsletter-signup-50", + "name": "Newsletter Signup at 50% Scroll", + "description": "Prompt newsletter signup when user scrolls 50% down the page", + "active": true, + "mode": "tour", + "triggers": [ + { + "type": "scroll_depth", + "percentage": 50 + } + ], + "frequency": { + "type": "once" + }, + "tourOptions": { + "steps": [ + { + "intro": "

Enjoying our content?

Subscribe to our newsletter for weekly updates and exclusive content!

", + "position": "floating" + }, + { + "element": "#newsletter-form", + "intro": "Enter your email here to stay updated with our latest articles and news.", + "position": "top" + } + ], + "exitOnEsc": true, + "exitOnOverlayClick": true + }, + "analytics": { + "trackViews": true, + "trackCompletions": true + } + } + ] +} diff --git a/example/campaigns/scroll-triggered-feature.json b/example/campaigns/scroll-triggered-feature.json new file mode 100644 index 000000000..7b2576f7f --- /dev/null +++ b/example/campaigns/scroll-triggered-feature.json @@ -0,0 +1,47 @@ +{ + "version": "1.0.0", + "campaigns": [ + { + "id": "pricing-tour", + "name": "Pricing Section Tour", + "description": "Tour that triggers when user scrolls to pricing section", + "active": true, + "mode": "tour", + "triggers": [ + { + "type": "scroll_to_element", + "selector": "#pricing-section", + "threshold": 0.6 + } + ], + "frequency": { + "type": "session" + }, + "tourOptions": { + "steps": [ + { + "element": ".pricing-card-basic", + "intro": "Our Basic plan is perfect for individuals just getting started.", + "position": "top" + }, + { + "element": ".pricing-card-pro", + "intro": "The Pro plan includes all features plus priority support.", + "position": "top" + }, + { + "element": ".pricing-card-enterprise", + "intro": "Enterprise plan offers custom solutions for large teams.", + "position": "top" + }, + { + "element": ".pricing-comparison", + "intro": "Compare all plans side-by-side to find the perfect fit.", + "position": "bottom" + } + ], + "showBullets": true + } + } + ] +} diff --git a/src/packages/campaign/index.ts b/src/packages/campaign/index.ts new file mode 100644 index 000000000..f51a29083 --- /dev/null +++ b/src/packages/campaign/index.ts @@ -0,0 +1,39 @@ +/** + * Intro.js Campaign System + * + * A no-code campaign management system for Intro.js that allows you to create + * and manage guided tours and hints using JSON configuration files. + * + * @example + * ```typescript + * import { initializeCampaigns } from 'intro.js/campaign'; + * + * // Load campaigns from JSON file + * await initializeCampaigns('/campaigns.json'); + * + * // Or load campaigns from object + * await initializeCampaigns({ + * version: '1.0.0', + * campaigns: [ + * { + * id: 'welcome-tour', + * name: 'Welcome Tour', + * active: true, + * mode: 'tour', + * triggers: [{ type: 'first_visit' }], + * tourOptions: { + * steps: [ + * { element: '#step1', intro: 'Welcome!' } + * ] + * } + * } + * ] + * }); + * ``` + */ + +export * from "./types"; +export { CampaignManager, getCampaignManager, initializeCampaigns } from "./manager"; +export { TriggerDetector } from "./triggers"; +export { UserTracker } from "./userTracker"; +export { CampaignStorage } from "./storage"; diff --git a/src/packages/campaign/manager.ts b/src/packages/campaign/manager.ts new file mode 100644 index 000000000..c704d961c --- /dev/null +++ b/src/packages/campaign/manager.ts @@ -0,0 +1,397 @@ +import { Campaign, CampaignTrigger, CampaignContext, CampaignCollection } from "./types"; +import { Tour } from "../tour/tour"; +import { TriggerDetector } from "./triggers"; +import { UserTracker } from "./userTracker"; +import { CampaignStorage } from "./storage"; +import isFunction from "../../util/isFunction"; + +/** + * Campaign Manager - Main class for managing and executing campaigns + */ +export class CampaignManager { + private campaigns: Map = new Map(); + private triggerDetector: TriggerDetector; + private userTracker: UserTracker; + private storage: CampaignStorage; + private activeTours: Map = new Map(); + private isInitialized = false; + + constructor() { + this.triggerDetector = new TriggerDetector(); + this.userTracker = new UserTracker(); + this.storage = new CampaignStorage(); + } + + /** + * Initialize the campaign manager + */ + async initialize(): Promise { + if (this.isInitialized) return; + + await this.userTracker.initialize(); + await this.triggerDetector.initialize(); + + this.isInitialized = true; + } + + /** + * Load campaigns from JSON configuration + */ + async loadCampaigns(config: CampaignCollection | Campaign[]): Promise { + const campaigns = Array.isArray(config) ? config : config.campaigns; + + for (const campaign of campaigns) { + if (campaign.active) { + this.campaigns.set(campaign.id, campaign); + await this.setupCampaignTriggers(campaign); + } + } + } + + /** + * Load campaigns from URL + */ + async loadCampaignsFromUrl(url: string): Promise { + try { + const response = await fetch(url); + const config = await response.json(); + await this.loadCampaigns(config); + } catch (error) { + console.error("Failed to load campaigns from URL:", error); + } + } + + /** + * Add a single campaign + */ + async addCampaign(campaign: Campaign): Promise { + if (campaign.active) { + this.campaigns.set(campaign.id, campaign); + await this.setupCampaignTriggers(campaign); + } + } + + /** + * Remove a campaign + */ + removeCampaign(campaignId: string): void { + const campaign = this.campaigns.get(campaignId); + if (campaign) { + this.triggerDetector.removeCampaignTriggers(campaignId); + this.campaigns.delete(campaignId); + + // Stop active tour if running + const activeTour = this.activeTours.get(campaignId); + if (activeTour) { + activeTour.exit(); + this.activeTours.delete(campaignId); + } + } + } + + /** + * Get all active campaigns + */ + getCampaigns(): Campaign[] { + return Array.from(this.campaigns.values()); + } + + /** + * Get a specific campaign + */ + getCampaign(campaignId: string): Campaign | undefined { + return this.campaigns.get(campaignId); + } + + /** + * Check if a campaign should be executed based on frequency and targeting + */ + private async shouldExecuteCampaign(campaign: Campaign): Promise { + // Check frequency constraints + if (campaign.frequency) { + const canExecute = await this.storage.canExecuteCampaign( + campaign.id, + campaign.frequency + ); + if (!canExecute) return false; + } + + // Check targeting constraints + if (campaign.targeting) { + const matches = await this.checkTargeting(campaign.targeting); + if (!matches) return false; + } + + return true; + } + + /** + * Check if targeting conditions are met + */ + private async checkTargeting(targeting: any): Promise { + const userContext = this.userTracker.getUserContext(); + + // Check user agent + if (targeting.userAgent) { + const matches = targeting.userAgent.some((pattern: string) => + new RegExp(pattern).test(userContext.userAgent) + ); + if (!matches) return false; + } + + // Check language + if (targeting.language) { + if (!targeting.language.includes(userContext.language)) return false; + } + + // Check referrer + if (targeting.referrer) { + const referrer = document.referrer; + const matches = targeting.referrer.some((pattern: string) => + new RegExp(pattern).test(referrer) + ); + if (!matches) return false; + } + + // Check query parameters + if (targeting.queryParams) { + const urlParams = new URLSearchParams(window.location.search); + for (const [key, value] of Object.entries(targeting.queryParams)) { + if (urlParams.get(key) !== value) return false; + } + } + + // Check localStorage + if (targeting.localStorage) { + for (const [key, value] of Object.entries(targeting.localStorage)) { + if (localStorage.getItem(key) !== value) return false; + } + } + + // Check sessionStorage + if (targeting.sessionStorage) { + for (const [key, value] of Object.entries(targeting.sessionStorage)) { + if (sessionStorage.getItem(key) !== value) return false; + } + } + + // Check custom function + if (targeting.customFunction) { + const customFn = (window as any)[targeting.customFunction]; + if (isFunction(customFn)) { + const result = await customFn(userContext); + if (!result) return false; + } + } + + return true; + } + + /** + * Execute a campaign + */ + async executeCampaign(campaignId: string, trigger: CampaignTrigger): Promise { + const campaign = this.campaigns.get(campaignId); + if (!campaign) return false; + + // Check if campaign should be executed + if (!(await this.shouldExecuteCampaign(campaign))) { + return false; + } + + // Create campaign context + const context: CampaignContext = { + campaign, + trigger, + user: this.userTracker.getUserContext(), + page: { + url: window.location.href, + referrer: document.referrer, + title: document.title, + loadTime: new Date(), + }, + }; + + // Track campaign execution + await this.storage.trackCampaignExecution(campaignId); + + // Convert campaign steps to tour steps + const tourSteps = campaign.tour.steps.map((step) => ({ + step: step.step, + title: step.title, + intro: step.intro, + element: step.element, + position: step.position, + scrollTo: step.scrollTo, + disableInteraction: step.disableInteraction, + tooltipClass: step.customClass, + })); + + // Create and configure tour + const tour = new Tour(); + tour.setOptions({ + ...campaign.tour, + steps: tourSteps, + }); + + // Set up campaign-specific callbacks + this.setupCampaignCallbacks(tour, campaign, context); + + // Store active tour + this.activeTours.set(campaignId, tour); + + // Start the tour + try { + await tour.start(); + return true; + } catch (error) { + console.error("Failed to start campaign tour:", error); + this.activeTours.delete(campaignId); + return false; + } + } + + /** + * Setup campaign-specific callbacks + */ + private setupCampaignCallbacks(tour: Tour, campaign: Campaign): void { + // On complete callback + tour.onComplete(() => { + this.activeTours.delete(campaign.id); + }); + + // On exit callback + tour.onExit(() => { + this.activeTours.delete(campaign.id); + }); + + // Custom step callbacks for campaign features + tour.onAfterChange((element, stepIndex) => { + const campaignStep = campaign.tour.steps[stepIndex]; + if (campaignStep) { + this.handleStepActions(campaignStep, element); + + // Auto advance if configured + if (campaignStep.autoAdvance) { + setTimeout(() => { + tour.nextStep(); + }, campaignStep.autoAdvance); + } + } + }); + } + + /** + * Handle step-specific actions + */ + private handleStepActions(step: any, element: HTMLElement): void { + if (step.actions) { + step.actions.forEach((action: any) => { + setTimeout(() => { + switch (action.type) { + case "highlight": + if (action.selector) { + const targetElement = document.querySelector(action.selector); + if (targetElement) { + targetElement.classList.add("introjs-campaign-highlight"); + } + } + break; + case "scroll": + if (action.selector) { + const targetElement = document.querySelector(action.selector); + if (targetElement) { + targetElement.scrollIntoView({ behavior: "smooth" }); + } + } + break; + case "click": + if (action.selector) { + const targetElement = document.querySelector(action.selector) as HTMLElement; + if (targetElement) { + targetElement.click(); + } + } + break; + case "focus": + if (action.selector) { + const targetElement = document.querySelector(action.selector) as HTMLElement; + if (targetElement) { + targetElement.focus(); + } + } + break; + case "custom_function": + if (action.functionName) { + const customFn = (window as any)[action.functionName]; + if (isFunction(customFn)) { + customFn(element, step); + } + } + break; + } + }, action.delay || 0); + }); + } + } + + /** + * Setup triggers for a campaign + */ + private async setupCampaignTriggers(campaign: Campaign): Promise { + for (const trigger of campaign.triggers) { + await this.triggerDetector.addTrigger(campaign.id, trigger, (triggeredCampaignId, triggeredTrigger) => { + this.executeCampaign(triggeredCampaignId, triggeredTrigger); + }); + } + } + + /** + * Stop all active campaigns + */ + async stopAllCampaigns(): Promise { + for (const [campaignId, tour] of this.activeTours) { + await tour.exit(); + } + this.activeTours.clear(); + } + + /** + * Destroy the campaign manager + */ + destroy(): void { + this.stopAllCampaigns(); + this.triggerDetector.destroy(); + this.campaigns.clear(); + this.isInitialized = false; + } +} + +// Global campaign manager instance +let globalCampaignManager: CampaignManager | null = null; + +/** + * Get or create the global campaign manager instance + */ +export function getCampaignManager(): CampaignManager { + if (!globalCampaignManager) { + globalCampaignManager = new CampaignManager(); + } + return globalCampaignManager; +} + +/** + * Initialize campaigns from configuration + */ +export async function initializeCampaigns(config: CampaignCollection | Campaign[] | string): Promise { + const manager = getCampaignManager(); + await manager.initialize(); + + if (typeof config === "string") { + await manager.loadCampaignsFromUrl(config); + } else { + await manager.loadCampaigns(config); + } + + return manager; +} diff --git a/src/packages/campaign/storage.ts b/src/packages/campaign/storage.ts new file mode 100644 index 000000000..2db225a45 --- /dev/null +++ b/src/packages/campaign/storage.ts @@ -0,0 +1,150 @@ +import { CampaignFrequency } from "./types"; + +/** + * Campaign storage - manages campaign execution history and frequency + */ +export class CampaignStorage { + private storagePrefix = "introjs-campaign-"; + + /** + * Check if a campaign can be executed based on frequency settings + */ + async canExecuteCampaign(campaignId: string, frequency: CampaignFrequency): Promise { + const key = `${this.storagePrefix}${campaignId}`; + const data = this.getExecutionData(key); + + // Check execution limit + if (frequency.limit && data.count >= frequency.limit) { + return false; + } + + // Check frequency type + switch (frequency.type) { + case "once": + return data.count === 0; + + case "session": + return !sessionStorage.getItem(key); + + case "daily": + return this.checkTimeWindow(data.lastExecution, 24 * 60 * 60 * 1000); + + case "weekly": + return this.checkTimeWindow(data.lastExecution, 7 * 24 * 60 * 60 * 1000); + + case "monthly": + return this.checkTimeWindow(data.lastExecution, 30 * 24 * 60 * 60 * 1000); + + case "always": + // Check cooldown if specified + if (frequency.cooldownMs) { + return this.checkTimeWindow(data.lastExecution, frequency.cooldownMs); + } + return true; + + default: + return true; + } + } + + /** + * Track campaign execution + */ + async trackCampaignExecution(campaignId: string): Promise { + const key = `${this.storagePrefix}${campaignId}`; + const data = this.getExecutionData(key); + + data.count += 1; + data.lastExecution = Date.now(); + + localStorage.setItem(key, JSON.stringify(data)); + sessionStorage.setItem(key, "executed"); + } + + /** + * Get execution data for a campaign + */ + private getExecutionData(key: string): { count: number; lastExecution: number | null } { + const stored = localStorage.getItem(key); + if (!stored) { + return { count: 0, lastExecution: null }; + } + + try { + return JSON.parse(stored); + } catch { + return { count: 0, lastExecution: null }; + } + } + + /** + * Check if enough time has passed since last execution + */ + private checkTimeWindow(lastExecution: number | null, windowMs: number): boolean { + if (!lastExecution) return true; + return Date.now() - lastExecution >= windowMs; + } + + /** + * Reset execution data for a campaign + */ + resetCampaign(campaignId: string): void { + const key = `${this.storagePrefix}${campaignId}`; + localStorage.removeItem(key); + sessionStorage.removeItem(key); + } + + /** + * Reset all campaign data + */ + resetAll(): void { + // Clear localStorage items with campaign prefix + const keysToRemove: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith(this.storagePrefix)) { + keysToRemove.push(key); + } + } + keysToRemove.forEach(key => localStorage.removeItem(key)); + + // Clear sessionStorage items with campaign prefix + const sessionKeysToRemove: string[] = []; + for (let i = 0; i < sessionStorage.length; i++) { + const key = sessionStorage.key(i); + if (key && key.startsWith(this.storagePrefix)) { + sessionKeysToRemove.push(key); + } + } + sessionKeysToRemove.forEach(key => sessionStorage.removeItem(key)); + } + + /** + * Get execution statistics for a campaign + */ + getStats(campaignId: string): { count: number; lastExecution: Date | null } { + const key = `${this.storagePrefix}${campaignId}`; + const data = this.getExecutionData(key); + return { + count: data.count, + lastExecution: data.lastExecution ? new Date(data.lastExecution) : null, + }; + } + + /** + * Get all campaign statistics + */ + getAllStats(): Record { + const stats: Record = {}; + + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith(this.storagePrefix)) { + const campaignId = key.replace(this.storagePrefix, ""); + stats[campaignId] = this.getStats(campaignId); + } + } + + return stats; + } +} diff --git a/src/packages/campaign/triggers.ts b/src/packages/campaign/triggers.ts new file mode 100644 index 000000000..96e372393 --- /dev/null +++ b/src/packages/campaign/triggers.ts @@ -0,0 +1,603 @@ +import { + CampaignTrigger, + isElementClickTrigger, + isElementHoverTrigger, + isIdleUserTrigger, + isScrollToElementTrigger, + isTimeOnPageTrigger, + isFormInteractionTrigger, + isCustomEventTrigger, + isUrlMatchTrigger, + isDeviceTypeTrigger, + isSessionCountTrigger, + isScrollDepthTrigger, + isElementVisibleTrigger, +} from "./types"; +import DOMEvent from "../../util/DOMEvent"; + +/** + * Trigger callback function type + */ +export type TriggerCallback = (campaignId: string, trigger: CampaignTrigger) => void; + +/** + * Trigger detector class - handles all campaign trigger detection + */ +export class TriggerDetector { + private triggers: Map = new Map(); + private eventListeners: Map void> = new Map(); + private timers: Map = new Map(); + private isInitialized = false; + + /** + * Initialize the trigger detector + */ + async initialize(): Promise { + if (this.isInitialized) return; + + this.setupGlobalTriggers(); + this.isInitialized = true; + } + + /** + * Add a trigger for a campaign + */ + async addTrigger(campaignId: string, trigger: CampaignTrigger, callback: TriggerCallback): Promise { + if (!this.triggers.has(campaignId)) { + this.triggers.set(campaignId, []); + } + + this.triggers.get(campaignId)!.push({ trigger, callback }); + await this.setupTrigger(campaignId, trigger, callback); + } + + /** + * Remove all triggers for a campaign + */ + removeCampaignTriggers(campaignId: string): void { + const campaignTriggers = this.triggers.get(campaignId); + if (campaignTriggers) { + campaignTriggers.forEach(({ trigger }) => { + this.cleanupTrigger(campaignId, trigger); + }); + this.triggers.delete(campaignId); + } + } + + /** + * Setup a specific trigger + */ + private async setupTrigger(campaignId: string, trigger: CampaignTrigger, callback: TriggerCallback): Promise { + switch (trigger.type) { + case "first_visit": + this.setupFirstVisitTrigger(campaignId, trigger, callback); + break; + + case "element_click": + if (isElementClickTrigger(trigger)) { + this.setupElementClickTrigger(campaignId, trigger, callback); + } + break; + + case "element_hover": + if (isElementHoverTrigger(trigger)) { + this.setupElementHoverTrigger(campaignId, trigger, callback); + } + break; + + case "idle_user": + if (isIdleUserTrigger(trigger)) { + this.setupIdleUserTrigger(campaignId, trigger, callback); + } + break; + + case "page_load": + this.setupPageLoadTrigger(campaignId, trigger, callback); + break; + + case "scroll_to_element": + if (isScrollToElementTrigger(trigger)) { + this.setupScrollToElementTrigger(campaignId, trigger, callback); + } + break; + + case "time_on_page": + if (isTimeOnPageTrigger(trigger)) { + this.setupTimeOnPageTrigger(campaignId, trigger, callback); + } + break; + + case "exit_intent": + this.setupExitIntentTrigger(campaignId, trigger, callback); + break; + + case "form_interaction": + if (isFormInteractionTrigger(trigger)) { + this.setupFormInteractionTrigger(campaignId, trigger, callback); + } + break; + + case "custom_event": + if (isCustomEventTrigger(trigger)) { + this.setupCustomEventTrigger(campaignId, trigger, callback); + } + break; + + case "url_match": + if (isUrlMatchTrigger(trigger)) { + this.setupUrlMatchTrigger(campaignId, trigger, callback); + } + break; + + case "device_type": + if (isDeviceTypeTrigger(trigger)) { + this.setupDeviceTypeTrigger(campaignId, trigger, callback); + } + break; + + case "returning_user": + this.setupReturningUserTrigger(campaignId, trigger, callback); + break; + + case "session_count": + if (isSessionCountTrigger(trigger)) { + this.setupSessionCountTrigger(campaignId, trigger, callback); + } + break; + + case "scroll_depth": + if (isScrollDepthTrigger(trigger)) { + this.setupScrollDepthTrigger(campaignId, trigger, callback); + } + break; + + case "element_visible": + if (isElementVisibleTrigger(trigger)) { + this.setupElementVisibleTrigger(campaignId, trigger, callback); + } + break; + } + } + + /** + * Setup global triggers that don't need specific configuration + */ + private setupGlobalTriggers(): void { + // Page visibility change for idle detection + DOMEvent.on(document, "visibilitychange" as any, () => { + if (document.hidden) { + this.pauseIdleTimers(); + } else { + this.resumeIdleTimers(); + } + }, false); + } + + /** + * Setup first visit trigger + */ + private setupFirstVisitTrigger(campaignId: string, trigger: CampaignTrigger, callback: TriggerCallback): void { + const cookieName = trigger.cookieName || `introjs-first-visit-${campaignId}`; + const hasVisited = localStorage.getItem(cookieName); + + if (!hasVisited) { + localStorage.setItem(cookieName, Date.now().toString()); + + if (trigger.delay) { + setTimeout(() => callback(campaignId, trigger), trigger.delay); + } else { + callback(campaignId, trigger); + } + } + } + + /** + * Setup element click trigger + */ + private setupElementClickTrigger(campaignId: string, trigger: CampaignTrigger & { selector: string }, callback: TriggerCallback): void { + const handler = (event: Event) => { + const target = event.target as Element; + if (target.matches(trigger.selector)) { + if (trigger.delay) { + setTimeout(() => callback(campaignId, trigger), trigger.delay); + } else { + callback(campaignId, trigger); + } + } + }; + + DOMEvent.on(document, "click", handler, false); + this.eventListeners.set(`${campaignId}-click`, () => { + DOMEvent.off(document, "click", handler, false); + }); + } + + /** + * Setup element hover trigger + */ + private setupElementHoverTrigger(campaignId: string, trigger: CampaignTrigger & { selector: string }, callback: TriggerCallback): void { + const handler = (event: Event) => { + const target = event.target as Element; + if (target.matches(trigger.selector)) { + if (trigger.delay) { + setTimeout(() => callback(campaignId, trigger), trigger.delay); + } else { + callback(campaignId, trigger); + } + } + }; + + DOMEvent.on(document, "mouseover" as any, handler, false); + this.eventListeners.set(`${campaignId}-hover`, () => { + DOMEvent.off(document, "mouseover" as any, handler, false); + }); + } + + /** + * Setup idle user trigger + */ + private setupIdleUserTrigger(campaignId: string, trigger: CampaignTrigger & { idleTime: number }, callback: TriggerCallback): void { + const idleTime = trigger.idleTime; + let idleTimer: number; + let isIdle = false; + + const resetTimer = () => { + if (isIdle) return; + + clearTimeout(idleTimer); + idleTimer = window.setTimeout(() => { + isIdle = true; + callback(campaignId, trigger); + }, idleTime); + }; + + const events = ["mousedown", "mousemove", "keypress", "scroll", "touchstart"]; + const handlers = events.map(event => { + const handler = resetTimer; + DOMEvent.on(document, event as any, handler, true); + return () => DOMEvent.off(document, event as any, handler, true); + }); + + resetTimer(); // Start the timer + + this.eventListeners.set(`${campaignId}-idle`, () => { + clearTimeout(idleTimer); + handlers.forEach(cleanup => cleanup()); + }); + } + + /** + * Setup page load trigger + */ + private setupPageLoadTrigger(campaignId: string, trigger: CampaignTrigger, callback: TriggerCallback): void { + if (document.readyState === "complete") { + if (trigger.delay) { + setTimeout(() => callback(campaignId, trigger), trigger.delay); + } else { + callback(campaignId, trigger); + } + } else { + const handler = () => { + if (trigger.delay) { + setTimeout(() => callback(campaignId, trigger), trigger.delay); + } else { + callback(campaignId, trigger); + } + }; + + DOMEvent.on(window, "load" as any, handler, false); + this.eventListeners.set(`${campaignId}-load`, () => { + DOMEvent.off(window, "load" as any, handler, false); + }); + } + } + + /** + * Setup scroll to element trigger + */ + private setupScrollToElementTrigger(campaignId: string, trigger: CampaignTrigger & { selector: string; threshold?: number }, callback: TriggerCallback): void { + const handler = () => { + const element = document.querySelector(trigger.selector); + if (element) { + const rect = element.getBoundingClientRect(); + const threshold = trigger.threshold || 0.5; + const elementHeight = rect.height; + const visibleHeight = Math.min(rect.bottom, window.innerHeight) - Math.max(rect.top, 0); + const visibilityRatio = visibleHeight / elementHeight; + + if (visibilityRatio >= threshold) { + callback(campaignId, trigger); + DOMEvent.off(window, "scroll", handler, false); + } + } + }; + + DOMEvent.on(window, "scroll", handler, false); + this.eventListeners.set(`${campaignId}-scroll`, () => { + DOMEvent.off(window, "scroll", handler, false); + }); + } + + /** + * Setup time on page trigger + */ + private setupTimeOnPageTrigger(campaignId: string, trigger: CampaignTrigger & { duration: number }, callback: TriggerCallback): void { + const timer = window.setTimeout(() => { + callback(campaignId, trigger); + }, trigger.duration); + + this.timers.set(`${campaignId}-time`, timer); + } + + /** + * Setup exit intent trigger + */ + private setupExitIntentTrigger(campaignId: string, trigger: CampaignTrigger, callback: TriggerCallback): void { + let hasTriggered = false; + + const handler = (event: MouseEvent) => { + if (hasTriggered) return; + + if (event.clientY <= 0) { + hasTriggered = true; + callback(campaignId, trigger); + } + }; + + DOMEvent.on(document, "mouseleave" as any, handler, false); + this.eventListeners.set(`${campaignId}-exit`, () => { + DOMEvent.off(document, "mouseleave" as any, handler, false); + }); + } + + /** + * Setup form interaction trigger + */ + private setupFormInteractionTrigger(campaignId: string, trigger: CampaignTrigger & { selector?: string }, callback: TriggerCallback): void { + const selector = trigger.selector || "form input, form textarea, form select"; + + const handler = (event: Event) => { + const target = event.target as Element; + if (target.matches(selector)) { + callback(campaignId, trigger); + } + }; + + DOMEvent.on(document, "focus" as any, handler, true); + this.eventListeners.set(`${campaignId}-form`, () => { + DOMEvent.off(document, "focus" as any, handler, true); + }); + } + + /** + * Setup custom event trigger + */ + private setupCustomEventTrigger(campaignId: string, trigger: CampaignTrigger & { eventName: string }, callback: TriggerCallback): void { + const handler = () => { + callback(campaignId, trigger); + }; + + DOMEvent.on(document, trigger.eventName as any, handler, false); + this.eventListeners.set(`${campaignId}-custom`, () => { + DOMEvent.off(document, trigger.eventName as any, handler, false); + }); + } + + /** + * Setup URL match trigger + */ + private setupUrlMatchTrigger(campaignId: string, trigger: CampaignTrigger & { pattern: string; matchType?: string }, callback: TriggerCallback): void { + const currentUrl = window.location.href; + let matches = false; + + switch (trigger.matchType) { + case "exact": + matches = currentUrl === trigger.pattern; + break; + case "contains": + matches = currentUrl.includes(trigger.pattern); + break; + case "regex": + default: + matches = new RegExp(trigger.pattern).test(currentUrl); + break; + } + + if (matches) { + if (trigger.delay) { + setTimeout(() => callback(campaignId, trigger), trigger.delay); + } else { + callback(campaignId, trigger); + } + } + } + + /** + * Setup device type trigger + */ + private setupDeviceTypeTrigger(campaignId: string, trigger: CampaignTrigger & { device: string }, callback: TriggerCallback): void { + const currentDevice = this.detectDeviceType(); + + if (currentDevice === trigger.device) { + if (trigger.delay) { + setTimeout(() => callback(campaignId, trigger), trigger.delay); + } else { + callback(campaignId, trigger); + } + } + } + + /** + * Setup returning user trigger + */ + private setupReturningUserTrigger(campaignId: string, trigger: CampaignTrigger, callback: TriggerCallback): void { + const cookieName = trigger.cookieName || "introjs-returning-user"; + const hasVisited = localStorage.getItem(cookieName); + + if (hasVisited) { + if (trigger.delay) { + setTimeout(() => callback(campaignId, trigger), trigger.delay); + } else { + callback(campaignId, trigger); + } + } else { + localStorage.setItem(cookieName, Date.now().toString()); + } + } + + /** + * Setup session count trigger + */ + private setupSessionCountTrigger(campaignId: string, trigger: CampaignTrigger & { count: number; operator?: string }, callback: TriggerCallback): void { + const cookieName = trigger.cookieName || "introjs-session-count"; + const sessionCount = parseInt(localStorage.getItem(cookieName) || "0", 10) + 1; + + localStorage.setItem(cookieName, sessionCount.toString()); + + let shouldTrigger = false; + switch (trigger.operator) { + case "equal": + shouldTrigger = sessionCount === trigger.count; + break; + case "greater": + shouldTrigger = sessionCount > trigger.count; + break; + case "less": + shouldTrigger = sessionCount < trigger.count; + break; + default: + shouldTrigger = sessionCount >= trigger.count; + break; + } + + if (shouldTrigger) { + if (trigger.delay) { + setTimeout(() => callback(campaignId, trigger), trigger.delay); + } else { + callback(campaignId, trigger); + } + } + } + + /** + * Setup scroll depth trigger + */ + private setupScrollDepthTrigger(campaignId: string, trigger: CampaignTrigger & { percentage: number }, callback: TriggerCallback): void { + const handler = () => { + const scrollPercent = (window.scrollY / (document.documentElement.scrollHeight - window.innerHeight)) * 100; + + if (scrollPercent >= trigger.percentage) { + callback(campaignId, trigger); + DOMEvent.off(window, "scroll", handler, false); + } + }; + + DOMEvent.on(window, "scroll", handler, false); + this.eventListeners.set(`${campaignId}-scroll-depth`, () => { + DOMEvent.off(window, "scroll", handler, false); + }); + } + + /** + * Setup element visible trigger + */ + private setupElementVisibleTrigger(campaignId: string, trigger: CampaignTrigger & { selector: string; threshold?: number }, callback: TriggerCallback): void { + const checkVisibility = () => { + const element = document.querySelector(trigger.selector); + if (element) { + const rect = element.getBoundingClientRect(); + const threshold = trigger.threshold || 0.5; + const elementHeight = rect.height; + const visibleHeight = Math.min(rect.bottom, window.innerHeight) - Math.max(rect.top, 0); + const visibilityRatio = visibleHeight / elementHeight; + + if (visibilityRatio >= threshold) { + callback(campaignId, trigger); + DOMEvent.off(window, "scroll", handler, false); + DOMEvent.off(window, "resize" as any, handler, false); + } + } + }; + + const handler = () => checkVisibility(); + + // Check on setup + checkVisibility(); + + // Listen for scroll and resize + DOMEvent.on(window, "scroll", handler, false); + DOMEvent.on(window, "resize" as any, handler, false); + + this.eventListeners.set(`${campaignId}-element-visible`, () => { + DOMEvent.off(window, "scroll", handler, false); + DOMEvent.off(window, "resize" as any, handler, false); + }); + } + + /** + * Detect device type based on screen size and user agent + */ + private detectDeviceType(): "mobile" | "tablet" | "desktop" { + const width = window.innerWidth; + const userAgent = navigator.userAgent.toLowerCase(); + + if (width <= 768 || /mobile|android|iphone|ipod|blackberry|iemobile|opera mini/i.test(userAgent)) { + return "mobile"; + } else if (width <= 1024 || /tablet|ipad/i.test(userAgent)) { + return "tablet"; + } else { + return "desktop"; + } + } + + /** + * Pause idle timers when page is hidden + */ + private pauseIdleTimers(): void { + // Implementation for pausing idle timers + } + + /** + * Resume idle timers when page becomes visible + */ + private resumeIdleTimers(): void { + // Implementation for resuming idle timers + } + + /** + * Clean up a specific trigger + */ + private cleanupTrigger(campaignId: string, trigger: CampaignTrigger): void { + const triggerKey = `${campaignId}-${trigger.type}`; + + // Clean up event listeners + const cleanup = this.eventListeners.get(triggerKey); + if (cleanup) { + cleanup(); + this.eventListeners.delete(triggerKey); + } + + // Clean up timers + const timer = this.timers.get(triggerKey); + if (timer) { + clearTimeout(timer); + this.timers.delete(triggerKey); + } + } + + /** + * Destroy the trigger detector + */ + destroy(): void { + // Clean up all event listeners + this.eventListeners.forEach(cleanup => cleanup()); + this.eventListeners.clear(); + + // Clean up all timers + this.timers.forEach(timer => clearTimeout(timer)); + this.timers.clear(); + + this.triggers.clear(); + this.isInitialized = false; + } +} diff --git a/src/packages/campaign/types.ts b/src/packages/campaign/types.ts new file mode 100644 index 000000000..74b6f8c53 --- /dev/null +++ b/src/packages/campaign/types.ts @@ -0,0 +1,319 @@ +import { TourOptions } from "../tour/option"; +import { HintOptions } from "../hint/option"; + +/** + * Base trigger interface that all triggers must implement + */ +export interface BaseCampaignTrigger { + type: string; + delay?: number; // Delay in milliseconds before triggering + cookieName?: string; // Custom cookie name for tracking +} + +/** + * First visit trigger - fires when user visits the page for the first time + */ +export interface FirstVisitTrigger extends BaseCampaignTrigger { + type: "first_visit"; +} + +/** + * Element click trigger - fires when user clicks on specific element + */ +export interface ElementClickTrigger extends BaseCampaignTrigger { + type: "element_click"; + selector: string; // CSS selector for the element +} + +/** + * Element hover trigger - fires when user hovers over specific element + */ +export interface ElementHoverTrigger extends BaseCampaignTrigger { + type: "element_hover"; + selector: string; // CSS selector for the element + hoverDuration?: number; // Minimum hover duration in ms +} + +/** + * Idle user trigger - fires when user is idle for specified time + */ +export interface IdleUserTrigger extends BaseCampaignTrigger { + type: "idle_user"; + idleTime: number; // Idle time threshold in milliseconds +} + +/** + * Page load trigger - fires when page finishes loading + */ +export interface PageLoadTrigger extends BaseCampaignTrigger { + type: "page_load"; +} + +/** + * Scroll to element trigger - fires when user scrolls to specific element + */ +export interface ScrollToElementTrigger extends BaseCampaignTrigger { + type: "scroll_to_element"; + selector: string; // CSS selector for the element + threshold?: number; // Visibility threshold (0-1), default 0.5 +} + +/** + * Time on page trigger - fires after user spends specified time on page + */ +export interface TimeOnPageTrigger extends BaseCampaignTrigger { + type: "time_on_page"; + duration: number; // Duration in milliseconds +} + +/** + * Exit intent trigger - fires when user shows exit intent + */ +export interface ExitIntentTrigger extends BaseCampaignTrigger { + type: "exit_intent"; + sensitivity?: number; // Mouse movement sensitivity at top (pixels) +} + +/** + * Form interaction trigger - fires when user interacts with form elements + */ +export interface FormInteractionTrigger extends BaseCampaignTrigger { + type: "form_interaction"; + selector?: string; // Optional specific form selector + interactionType?: "focus" | "input" | "change"; +} + +/** + * Custom event trigger - fires on custom JavaScript event + */ +export interface CustomEventTrigger extends BaseCampaignTrigger { + type: "custom_event"; + eventName: string; // Name of the custom event to listen for +} + +/** + * URL match trigger - fires when URL matches pattern + */ +export interface UrlMatchTrigger extends BaseCampaignTrigger { + type: "url_match"; + pattern: string; // Regular expression pattern for URL matching + matchType?: "exact" | "contains" | "regex"; +} + +/** + * Device type trigger - fires for specific device type + */ +export interface DeviceTypeTrigger extends BaseCampaignTrigger { + type: "device_type"; + device: "mobile" | "tablet" | "desktop"; +} + +/** + * Returning user trigger - fires for users who have visited before + */ +export interface ReturningUserTrigger extends BaseCampaignTrigger { + type: "returning_user"; + minVisits?: number; // Minimum number of previous visits +} + +/** + * Session count trigger - fires based on session count + */ +export interface SessionCountTrigger extends BaseCampaignTrigger { + type: "session_count"; + count: number; // Session count threshold + operator?: "equal" | "greater" | "less"; +} + +/** + * Scroll depth trigger - fires when user scrolls to specific depth + */ +export interface ScrollDepthTrigger extends BaseCampaignTrigger { + type: "scroll_depth"; + percentage: number; // Scroll depth percentage (0-100) +} + +/** + * Element visible trigger - fires when specific element becomes visible + */ +export interface ElementVisibleTrigger extends BaseCampaignTrigger { + type: "element_visible"; + selector: string; // CSS selector for the element + threshold?: number; // Visibility threshold (0-1) +} + +/** + * Union type of all possible triggers + */ +export type CampaignTrigger = + | FirstVisitTrigger + | ElementClickTrigger + | ElementHoverTrigger + | IdleUserTrigger + | PageLoadTrigger + | ScrollToElementTrigger + | TimeOnPageTrigger + | ExitIntentTrigger + | FormInteractionTrigger + | CustomEventTrigger + | UrlMatchTrigger + | DeviceTypeTrigger + | ReturningUserTrigger + | SessionCountTrigger + | ScrollDepthTrigger + | ElementVisibleTrigger; + +/** + * Campaign frequency settings + */ +export interface CampaignFrequency { + type: "once" | "daily" | "weekly" | "monthly" | "session" | "always"; + limit?: number; // Maximum number of times to show + cooldownMs?: number; // Cooldown period in milliseconds +} + +/** + * Campaign targeting options + */ +export interface CampaignTargeting { + userAgent?: string[]; // User agent patterns (regex) + language?: string[]; // Browser languages (e.g., ["en", "en-US"]) + referrer?: string[]; // Referrer patterns (regex) + queryParams?: Record; // URL query parameters + localStorage?: Record; // Local storage key-value pairs + sessionStorage?: Record; // Session storage key-value pairs + customFunction?: string; // Custom targeting function name (global window function) +} + +/** + * Main campaign configuration + */ +export interface Campaign { + id: string; // Unique campaign identifier + name: string; // Campaign name + description?: string; // Campaign description + version?: string; // Campaign version + active: boolean; // Whether campaign is active + + // Trigger configuration - can have multiple triggers (OR logic) + triggers: CampaignTrigger[]; + + // Tour or Hint mode + mode: "tour" | "hint"; + + // Tour configuration - uses existing TourOptions structure + tourOptions?: Partial; + + // Hint configuration - uses existing HintOptions structure + hintOptions?: Partial; + + // Frequency and targeting + frequency?: CampaignFrequency; + targeting?: CampaignTargeting; + + // Metadata + createdAt?: string; + updatedAt?: string; + author?: string; + tags?: string[]; + priority?: number; // Priority for when multiple campaigns match (higher = higher priority) +} + +/** + * Campaign collection (multiple campaigns) + */ +export interface CampaignCollection { + version: string; + campaigns: Campaign[]; + global?: { + targeting?: CampaignTargeting; + tourDefaults?: Partial; + hintDefaults?: Partial; + }; +} + +/** + * Campaign execution context + */ +export interface CampaignContext { + campaign: Campaign; + trigger: CampaignTrigger; + user: { + isFirstVisit: boolean; + sessionCount: number; + lastVisit?: Date; + device: "mobile" | "tablet" | "desktop"; + language: string; + userAgent: string; + }; + page: { + url: string; + referrer: string; + title: string; + loadTime: Date; + }; +} + +/** + * Campaign execution status + */ +export interface CampaignExecutionStatus { + campaignId: string; + executed: boolean; + timestamp: Date; + trigger?: CampaignTrigger; + completed?: boolean; + skipped?: boolean; + error?: string; +} + +/** + * Type guard functions for triggers + */ +export function isElementClickTrigger(trigger: CampaignTrigger): trigger is ElementClickTrigger { + return trigger.type === "element_click"; +} + +export function isElementHoverTrigger(trigger: CampaignTrigger): trigger is ElementHoverTrigger { + return trigger.type === "element_hover"; +} + +export function isScrollToElementTrigger(trigger: CampaignTrigger): trigger is ScrollToElementTrigger { + return trigger.type === "scroll_to_element"; +} + +export function isIdleUserTrigger(trigger: CampaignTrigger): trigger is IdleUserTrigger { + return trigger.type === "idle_user"; +} + +export function isTimeOnPageTrigger(trigger: CampaignTrigger): trigger is TimeOnPageTrigger { + return trigger.type === "time_on_page"; +} + +export function isFormInteractionTrigger(trigger: CampaignTrigger): trigger is FormInteractionTrigger { + return trigger.type === "form_interaction"; +} + +export function isCustomEventTrigger(trigger: CampaignTrigger): trigger is CustomEventTrigger { + return trigger.type === "custom_event"; +} + +export function isUrlMatchTrigger(trigger: CampaignTrigger): trigger is UrlMatchTrigger { + return trigger.type === "url_match"; +} + +export function isDeviceTypeTrigger(trigger: CampaignTrigger): trigger is DeviceTypeTrigger { + return trigger.type === "device_type"; +} + +export function isSessionCountTrigger(trigger: CampaignTrigger): trigger is SessionCountTrigger { + return trigger.type === "session_count"; +} + +export function isScrollDepthTrigger(trigger: CampaignTrigger): trigger is ScrollDepthTrigger { + return trigger.type === "scroll_depth"; +} + +export function isElementVisibleTrigger(trigger: CampaignTrigger): trigger is ElementVisibleTrigger { + return trigger.type === "element_visible"; +} diff --git a/src/packages/campaign/userTracker.ts b/src/packages/campaign/userTracker.ts new file mode 100644 index 000000000..613415ebc --- /dev/null +++ b/src/packages/campaign/userTracker.ts @@ -0,0 +1,96 @@ +/** + * User tracker - tracks user behavior and context + */ +export class UserTracker { + private isInitialized = false; + private userContext: { + isFirstVisit: boolean; + sessionCount: number; + lastVisit?: Date; + device: "mobile" | "tablet" | "desktop"; + language: string; + userAgent: string; + } | null = null; + + /** + * Initialize the user tracker + */ + async initialize(): Promise { + if (this.isInitialized) return; + + // Track session count + const sessionCountKey = "introjs-campaign-session-count"; + const sessionCount = parseInt(localStorage.getItem(sessionCountKey) || "0", 10) + 1; + localStorage.setItem(sessionCountKey, sessionCount.toString()); + + // Check if first visit + const firstVisitKey = "introjs-campaign-first-visit"; + const isFirstVisit = !localStorage.getItem(firstVisitKey); + if (isFirstVisit) { + localStorage.setItem(firstVisitKey, Date.now().toString()); + } + + // Get last visit + const lastVisitKey = "introjs-campaign-last-visit"; + const lastVisitStr = localStorage.getItem(lastVisitKey); + const lastVisit = lastVisitStr ? new Date(parseInt(lastVisitStr, 10)) : undefined; + localStorage.setItem(lastVisitKey, Date.now().toString()); + + // Detect device type + const device = this.detectDeviceType(); + + // Get language + const language = navigator.language || "en"; + + // Get user agent + const userAgent = navigator.userAgent; + + this.userContext = { + isFirstVisit, + sessionCount, + lastVisit, + device, + language, + userAgent, + }; + + this.isInitialized = true; + } + + /** + * Get user context + */ + getUserContext() { + if (!this.userContext) { + throw new Error("UserTracker not initialized. Call initialize() first."); + } + return this.userContext; + } + + /** + * Detect device type based on screen size and user agent + */ + private detectDeviceType(): "mobile" | "tablet" | "desktop" { + const width = window.innerWidth; + const userAgent = navigator.userAgent.toLowerCase(); + + if (width <= 768 || /mobile|android|iphone|ipod|blackberry|iemobile|opera mini/i.test(userAgent)) { + return "mobile"; + } else if (width <= 1024 || /tablet|ipad/i.test(userAgent)) { + return "tablet"; + } else { + return "desktop"; + } + } + + /** + * Reset user tracking data + */ + reset(): void { + localStorage.removeItem("introjs-campaign-session-count"); + localStorage.removeItem("introjs-campaign-first-visit"); + localStorage.removeItem("introjs-campaign-last-visit"); + this.userContext = null; + this.isInitialized = false; + } +}