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/campaigns.json b/example/campaigns/campaigns.json new file mode 100644 index 000000000..7912e5a03 --- /dev/null +++ b/example/campaigns/campaigns.json @@ -0,0 +1,343 @@ +{ + "version": "1.0.0", + "campaigns": [ + { + "id": "demo-first-visit", + "name": "First Visit", + "description": "Fires once — the very first time a user opens this page.", + "active": true, + "mode": "tour", + "triggers": [{ "type": "first_visit", "delay": 800 }], + "frequency": { "type": "once" }, + "options": { + "steps": [ + { + "element": "#hero", + "title": "first_visit", + "intro": "This tour appeared because this is your first visit.

Trigger: first_visit
Frequency: once — you will not see this again.", + "position": "bottom" + } + ] + } + }, + { + "id": "demo-page-load", + "name": "Page Load", + "description": "Fires 5 seconds after the page finishes loading.", + "active": true, + "mode": "tour", + "triggers": [{ "type": "page_load", "delay": 5000 }], + "frequency": { "type": "session" }, + "options": { + "steps": [ + { + "element": "#auto-section", + "title": "page_load", + "intro": "Trigger: page_load
Fires after the page finishes loading, with a 5 second delay.

Frequency: session — resets on each new browser session.", + "position": "bottom" + } + ] + } + }, + { + "id": "demo-idle-user", + "name": "Idle User", + "description": "Fires when the user is idle for 10 seconds.", + "active": true, + "mode": "tour", + "triggers": [{ "type": "idle_user", "idleTime": 10000 }], + "frequency": { "type": "session" }, + "options": { + "steps": [ + { + "element": "#idle-card", + "title": "idle_user", + "intro": "Trigger: idle_user
You stopped all activity (mouse, keyboard, touch, scroll) for 10 seconds.

The timer pauses when the tab is hidden and resumes when you return.", + "position": "right" + } + ] + } + }, + { + "id": "demo-time-on-page", + "name": "Time on Page", + "description": "Fires after 25 seconds on the page.", + "active": true, + "mode": "tour", + "triggers": [{ "type": "time_on_page", "duration": 25000 }], + "frequency": { "type": "session" }, + "options": { + "steps": [ + { + "element": "#time-card", + "title": "time_on_page", + "intro": "Trigger: time_on_page
You have been on this page for 25 seconds.

Unlike idle_user, this timer is not reset by activity.", + "position": "right" + } + ] + } + }, + { + "id": "demo-exit-intent", + "name": "Exit Intent", + "description": "Fires when the mouse moves toward the top of the browser.", + "active": true, + "mode": "tour", + "triggers": [{ "type": "exit_intent", "sensitivity": 15 }], + "frequency": { "type": "session" }, + "options": { + "steps": [ + { + "element": "#exit-card", + "title": "exit_intent", + "intro": "Trigger: exit_intent
Detected when the mouse left the document from the top edge (sensitivity: 15px).

Classic use case: show a discount before the user leaves.", + "position": "bottom" + } + ] + } + }, + { + "id": "demo-url-match", + "name": "URL Match", + "description": "Fires when the URL contains 'campaigns'.", + "active": true, + "mode": "tour", + "triggers": [{ "type": "url_match", "pattern": "campaigns", "matchType": "contains", "delay": 7000 }], + "frequency": { "type": "session" }, + "options": { + "steps": [ + { + "element": "#url-card", + "title": "url_match", + "intro": "Trigger: url_match
The current URL contains \"campaigns\".

matchType options: exact, contains, regex.
Fired after a 7 second delay.", + "position": "right" + } + ] + } + }, + { + "id": "demo-device-desktop", + "name": "Device Type — Desktop", + "active": true, + "mode": "tour", + "triggers": [{ "type": "device_type", "device": "desktop", "delay": 200 }], + "frequency": { "type": "once" }, + "options": { + "steps": [ + { + "element": "#device-card", + "title": "device_type — desktop", + "intro": "Trigger: device_type
Detected device: desktop (width > 1024px, no mobile UA).

You can create separate campaigns for mobile, tablet, and desktop.", + "position": "right" + } + ] + } + }, + { + "id": "demo-device-tablet", + "name": "Device Type — Tablet", + "active": true, + "mode": "tour", + "triggers": [{ "type": "device_type", "device": "tablet", "delay": 200 }], + "frequency": { "type": "once" }, + "options": { + "steps": [ + { + "element": "#device-card", + "title": "device_type — tablet", + "intro": "Trigger: device_type
Detected device: tablet (width 769–1024px).

Each device type runs its own campaign independently.", + "position": "bottom" + } + ] + } + }, + { + "id": "demo-device-mobile", + "name": "Device Type — Mobile", + "active": true, + "mode": "tour", + "triggers": [{ "type": "device_type", "device": "mobile", "delay": 200 }], + "frequency": { "type": "once" }, + "options": { + "steps": [ + { + "element": "#device-card", + "title": "device_type — mobile", + "intro": "Trigger: device_type
Detected device: mobile (width ≤ 768px or mobile UA).

Useful for showing mobile-only onboarding.", + "position": "bottom" + } + ] + } + }, + { + "id": "demo-returning-user", + "name": "Returning User", + "description": "Fires when the user has visited before.", + "active": true, + "mode": "tour", + "triggers": [{ "type": "returning_user", "minVisits": 1, "delay": 200 }], + "frequency": { "type": "once" }, + "options": { + "steps": [ + { + "element": "#returning-card", + "title": "returning_user", + "intro": "Trigger: returning_user
Welcome back! You have visited this page before.

minVisits: 1 means at least 1 previous visit is required. Frequency: once — fires only the first time you return.", + "position": "right" + } + ] + } + }, + { + "id": "demo-session-count", + "name": "Session Count", + "description": "Fires when the session count exceeds 1.", + "active": true, + "mode": "tour", + "triggers": [{ "type": "session_count", "count": 1, "operator": "greater", "delay": 200 }], + "frequency": { "type": "once" }, + "options": { + "steps": [ + { + "element": "#session-card", + "title": "session_count", + "intro": "Trigger: session_count
You have opened this page more than 1 time.

operator options: equal, greater, less.
Each page load = one session.", + "position": "right" + } + ] + } + }, + { + "id": "demo-element-click", + "name": "Element Click", + "description": "Fires when the user clicks #click-target.", + "active": true, + "mode": "tour", + "triggers": [{ "type": "element_click", "selector": "#click-target" }], + "frequency": { "type": "always" }, + "options": { + "steps": [ + { + "element": "#click-section", + "title": "element_click", + "intro": "Trigger: element_click
You clicked on #click-target.

Uses closest() internally, so clicking on any child element of the target also fires the trigger.", + "position": "bottom" + } + ] + } + }, + { + "id": "demo-element-hover", + "name": "Element Hover", + "description": "Fires when the user hovers over #hover-target for 800ms.", + "active": true, + "mode": "hint", + "triggers": [{ "type": "element_hover", "selector": "#hover-target", "hoverDuration": 800 }], + "frequency": { "type": "always" }, + "options": { + "hints": [ + { + "element": "#hover-target", + "hint": "element_hover fired! You hovered for 800ms.", + "hintPosition": "top-middle" + } + ] + } + }, + { + "id": "demo-form-interaction", + "name": "Form Interaction", + "description": "Fires when the user focuses on a field inside #contact-form.", + "active": true, + "mode": "tour", + "triggers": [{ "type": "form_interaction", "selector": "#contact-form", "interactionType": "focus" }], + "frequency": { "type": "session" }, + "options": { + "steps": [ + { + "element": "#form-section", + "title": "form_interaction", + "intro": "Trigger: form_interaction
You focused on a field inside #contact-form.

interactionType options: focus, input, change.", + "position": "top" + } + ] + } + }, + { + "id": "demo-custom-event", + "name": "Custom Event", + "description": "Fires when a 'demo_campaign_event' CustomEvent is dispatched.", + "active": true, + "mode": "tour", + "triggers": [{ "type": "custom_event", "eventName": "demo_campaign_event" }], + "frequency": { "type": "always" }, + "options": { + "steps": [ + { + "element": "#custom-section", + "title": "custom_event", + "intro": "Trigger: custom_event
Fired by dispatching a DOM event named demo_campaign_event.

Use document.dispatchEvent(new CustomEvent('demo_campaign_event')) from anywhere in your app.", + "position": "bottom" + } + ] + } + }, + { + "id": "demo-scroll-depth", + "name": "Scroll Depth", + "description": "Fires when the user scrolls past 60% of the page.", + "active": true, + "mode": "tour", + "triggers": [{ "type": "scroll_depth", "percentage": 60 }], + "frequency": { "type": "session" }, + "options": { + "steps": [ + { + "element": "#deep-section", + "title": "scroll_depth", + "intro": "Trigger: scroll_depth
You scrolled past 60% of the page.

Fires once per session. Common use case: newsletter signup after user shows intent by reading.", + "position": "top" + } + ] + } + }, + { + "id": "demo-scroll-to-element", + "name": "Scroll to Element", + "description": "Fires when #scroll-element is 50% visible.", + "active": true, + "mode": "tour", + "triggers": [{ "type": "scroll_to_element", "selector": "#scroll-element", "threshold": 0.5 }], + "frequency": { "type": "session" }, + "options": { + "steps": [ + { + "element": "#scroll-element", + "title": "scroll_to_element", + "intro": "Trigger: scroll_to_element
This element is now 50% visible in the viewport (threshold: 0.5).

Unlike element_visible, this does not fire on the initial check — only on scroll.", + "position": "top" + } + ] + } + }, + { + "id": "demo-element-visible", + "name": "Element Visible", + "description": "Fires when #visible-element reaches 70% visibility.", + "active": true, + "mode": "tour", + "triggers": [{ "type": "element_visible", "selector": "#visible-element", "threshold": 0.7 }], + "frequency": { "type": "session" }, + "options": { + "steps": [ + { + "element": "#visible-element", + "title": "element_visible", + "intro": "Trigger: element_visible
This element reached 70% visibility (threshold: 0.7).

Checks on both initial page load and every scroll/resize event.", + "position": "top" + } + ] + } + } + ] +} diff --git a/example/campaigns/element-click-tour.json b/example/campaigns/element-click-tour.json new file mode 100644 index 000000000..837995b9e --- /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" + }, + "options": { + "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..957427950 --- /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" + }, + "options": { + "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..cebaef830 --- /dev/null +++ b/example/campaigns/first-visit-tour.json @@ -0,0 +1,52 @@ +{ + "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" + }, + "options": { + "steps": [ + { + "element": "#step1", + "intro": "Welcome to the Intro.js Campaign System! Let's take a quick tour.", + "title": "Welcome", + "position": "bottom" + }, + { + "element": "#step2", + "intro": "This campaign was loaded from a JSON file and triggered automatically on your first visit.", + "title": "Automatic Triggers", + "position": "bottom" + }, + { + "element": "#step3", + "intro": "You can configure campaigns with various triggers, targeting rules, and frequencies.", + "title": "Features", + "position": "top" + }, + { + "element": ".demo-buttons", + "intro": "Use these buttons to explore different campaign features. That's it! Enjoy the campaign system.", + "title": "Try It Out", + "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..b2905cc8e --- /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" + }, + "options": { + "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..2eadcd95a --- /dev/null +++ b/example/campaigns/idle-user-engagement.json @@ -0,0 +1,49 @@ +{ + "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 + } + ], + "options": { + "steps": [ + { + "intro": "👋 Still here? Let us show you some features you might have missed!", + "title": "Welcome Back!", + "position": "floating" + }, + { + "element": "#step1", + "intro": "This is the main content area where you can find important information.", + "title": "Main Content", + "position": "bottom" + }, + { + "element": "#step2", + "intro": "Here you'll find additional details and features.", + "title": "More Information", + "position": "bottom" + }, + { + "element": ".demo-buttons", + "intro": "Use these buttons to interact with different campaign features and test various triggers.", + "title": "Action Buttons", + "position": "top" + } + ], + "showProgress": true, + "showBullets": true, + "exitOnEsc": true, + "exitOnOverlayClick": true + } + } + ] +} diff --git a/example/campaigns/index.html b/example/campaigns/index.html new file mode 100644 index 000000000..48926403e --- /dev/null +++ b/example/campaigns/index.html @@ -0,0 +1,526 @@ + + + + + + Intro.js — Campaign Triggers Demo + + + + + + + + +
+ + +
+ + +
+
Campaign System
+

+ This page demonstrates all 16 trigger types in the Intro.js Campaign System. + Campaigns are loaded from campaigns.json and fire automatically or on user interaction. + Watch the sidebar to see triggers activate in real time. + Use Reset & Reload to clear all session data and start fresh. +

+
+ + +
+
Automatic Triggers
+
+
+ idle_user +

Stop all activity for 10 seconds to trigger.

+
+
+ time_on_page +

Stay on the page for 25 seconds.

+
+
+ exit_intent +

Move your mouse to the top edge of the browser.

+
+
+ url_match +

URL contains "campaigns". Fires after 7 seconds.

+
+
+ device_type +

Detected on page load. One campaign per device type.

+
+
+ returning_user +

Fires on your second or later visit. Reset to test.

+
+
+ session_count +

Fires when session count > 1. Each page load = 1 session.

+
+
+
+ + +
+
Interaction Triggers
+ + +
+

+ element_click — Click the button below: +

+ +
+ + +
+

+ element_hover — Hover over the card for 800ms: +

+
Hover Me (hold for 800ms)
+
+ + +
+

+ form_interaction — Click any field in the form: +

+
+ + + +
+
+ + +
+

+ custom_event — Dispatches demo_campaign_event on the document: +

+ +
+
+ + +
+
Scroll Triggers
+

+ Scroll down to trigger scroll_depth, scroll_to_element, and element_visible. +

+
+ +

↓ Keep scrolling ↓

+ +
+ element_visible +

Fires when this element reaches 70% visibility in the viewport.

+
+ +

↓ Almost there ↓

+ +
+ scroll_to_element +

Fires when this element is 50% visible after a scroll event.

+
+ +

↓ One more ↓

+ +
+ scroll_depth +

You have scrolled past 60% of the page. This section is near the bottom.

+
+ +
+ +
+ + + +
+ + + + + diff --git a/example/campaigns/returning-user-features.json b/example/campaigns/returning-user-features.json new file mode 100644 index 000000000..39cb3907f --- /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" + }, + "options": { + "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..967a2f98a --- /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" + }, + "options": { + "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..b778ad6b5 --- /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" + }, + "options": { + "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/example/index.html b/example/index.html index 5cb2809a6..41d8b9551 100644 --- a/example/index.html +++ b/example/index.html @@ -39,6 +39,7 @@

Examples

  • Basic hints usage
  • Basic hints usage on an element
  • Programmatic defining hints using JSON
  • +
  • Campaign
  • diff --git a/src/index.ts b/src/index.ts index b315cb5c1..8a56f368f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import { version } from "../package.json"; import { Hint } from "./packages/hint"; import { Tour } from "./packages/tour"; +import { CampaignManager, initializeCampaigns, getCampaignManager } from "./packages/campaign"; class LegacyIntroJs extends Tour { /** @@ -58,6 +59,41 @@ introJs.tour = (elementOrSelector?: string | HTMLElement) => introJs.hint = (elementOrSelector?: string | HTMLElement) => new Hint(elementOrSelector); +/** + * Create a new Intro.js campaign manager instance + */ +introJs.campaign = () => new CampaignManager(); + +/** + * Initialize campaigns from configuration + * @param config Campaign configuration (JSON object, array, or URL to JSON file) + */ +introJs.initializeCampaigns = initializeCampaigns; + +/** + * Get the global campaign manager instance + */ +introJs.getCampaignManager = getCampaignManager; + +/** + * Manually trigger a campaign by ID + * @param campaignId The ID of the campaign to trigger + */ +introJs.triggerCampaign = async (campaignId: string): Promise => { + const manager = getCampaignManager(); + const campaign = manager.getCampaign(campaignId); + + if (!campaign) { + console.error(`Campaign "${campaignId}" not found`); + return false; + } + + return await manager.executeCampaign(campaignId, { + type: 'custom_event', + eventName: 'manual_trigger' + }); +}; + /** * Current Intro.js version */ diff --git a/src/packages/campaign/experienceFactory.ts b/src/packages/campaign/experienceFactory.ts new file mode 100644 index 000000000..bafd4c60c --- /dev/null +++ b/src/packages/campaign/experienceFactory.ts @@ -0,0 +1,95 @@ +import { Campaign, TourCampaign, HintCampaign } from "./types"; +import { Tour } from "../tour/tour"; +import { Hint } from "../hint/hint"; + +/** + * Base interface for all campaign experiences + * This allows us to work with different experience types uniformly + */ +export interface CampaignExperience { + start(): Promise; + exit(): Promise; + onComplete(callback: () => void): void; + onExit(callback: () => void): void; +} + +class TourExperienceAdapter implements CampaignExperience { + private tour: Tour; + + constructor(tour: Tour) { + this.tour = tour; + } + + async start(): Promise { + await this.tour.start(); + } + + async exit(): Promise { + await this.tour.exit(); + } + + onComplete(callback: () => void): void { + this.tour.onComplete(callback); + } + + onExit(callback: () => void): void { + this.tour.onExit(callback); + } +} + +class HintExperienceAdapter implements CampaignExperience { + private hint: Hint; + + constructor(hint: Hint) { + this.hint = hint; + } + + async start(): Promise { + await this.hint.render(); + } + + async exit(): Promise { + this.hint.destroy(); + } + + onComplete(callback: () => void): void { + this.hint.onHintClose(() => { + const allHidden = this.hint + .getHints() + .every((h) => h.isActive && !h.isActive.val); + if (allHidden) { + callback(); + } + }); + } + + onExit(callback: () => void): void { + this.hint.onHintClose(callback); + } +} + +export class ExperienceFactory { + static createExperience(campaign: Campaign): CampaignExperience { + switch (campaign.mode) { + case "tour": + return this.createTourExperience(campaign); + case "hint": + return this.createHintExperience(campaign); + default: { + const _exhaustive: never = campaign; + throw new Error(`Unsupported experience type: ${(_exhaustive as { mode: string }).mode}`); + } + } + } + + private static createTourExperience(campaign: TourCampaign): TourExperienceAdapter { + const tour = new Tour(); + tour.setOptions(campaign.options); + return new TourExperienceAdapter(tour); + } + + private static createHintExperience(campaign: HintCampaign): HintExperienceAdapter { + const hint = new Hint(undefined, campaign.options); + return new HintExperienceAdapter(hint); + } +} diff --git a/src/packages/campaign/index.ts b/src/packages/campaign/index.ts new file mode 100644 index 000000000..2448997b2 --- /dev/null +++ b/src/packages/campaign/index.ts @@ -0,0 +1,43 @@ +/** + * 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' }], + * options: { + * 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..b94774a51 --- /dev/null +++ b/src/packages/campaign/manager.ts @@ -0,0 +1,268 @@ +import { + Campaign, + CampaignTrigger, + CampaignCollection, + CampaignTargeting, +} from "./types"; +import { TriggerDetector } from "./triggers"; +import { UserTracker } from "./userTracker"; +import { CampaignStorage } from "./storage"; +import isFunction from "../../util/isFunction"; +import { + ExperienceFactory, + CampaignExperience, +} from "./experienceFactory"; + +export class CampaignManager { + private campaigns: Map = new Map(); + private triggerDetector: TriggerDetector; + private userTracker: UserTracker; + private storage: CampaignStorage; + private activeExperiences: Map = new Map(); + private globalTargeting: CampaignTargeting | undefined; + private isInitialized = false; + + constructor() { + this.triggerDetector = new TriggerDetector(); + this.userTracker = new UserTracker(); + this.storage = new CampaignStorage(); + } + + initialize(): void { + if (this.isInitialized) return; + this.userTracker.initialize(); + this.triggerDetector.initialize(); + this.isInitialized = true; + } + + loadCampaigns(config: CampaignCollection | Campaign[]): void { + if (!Array.isArray(config)) { + this.globalTargeting = config.global?.targeting; + } + + const campaigns = Array.isArray(config) ? config : config.campaigns; + for (const campaign of campaigns) { + if (campaign.active) { + this.campaigns.set(campaign.id, campaign); + this.setupCampaignTriggers(campaign); + } + } + } + + async loadCampaignsFromUrl(url: string): Promise { + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + const config = await response.json(); + this.loadCampaigns(config); + } catch (error) { + console.error("Failed to load campaigns from URL:", error); + } + } + + addCampaign(campaign: Campaign): void { + if (campaign.active) { + this.campaigns.set(campaign.id, campaign); + this.setupCampaignTriggers(campaign); + } + } + + removeCampaign(campaignId: string): void { + const campaign = this.campaigns.get(campaignId); + if (campaign) { + this.triggerDetector.removeCampaignTriggers(campaignId); + this.campaigns.delete(campaignId); + + const activeExperience = this.activeExperiences.get(campaignId); + if (activeExperience) { + this.activeExperiences.delete(campaignId); + void activeExperience.exit(); + } + } + } + + getCampaigns(): Campaign[] { + return Array.from(this.campaigns.values()); + } + + getCampaign(campaignId: string): Campaign | undefined { + return this.campaigns.get(campaignId); + } + + private async shouldExecuteCampaign(campaign: Campaign): Promise { + if (campaign.frequency) { + if (!this.storage.canExecuteCampaign(campaign.id, campaign.frequency)) { + return false; + } + } + + // Global targeting applies to all campaigns + if (this.globalTargeting) { + if (!(await this.checkTargeting(this.globalTargeting))) return false; + } + + // Per-campaign targeting + if (campaign.targeting) { + if (!(await this.checkTargeting(campaign.targeting))) return false; + } + + return true; + } + + private async checkTargeting(targeting: CampaignTargeting): Promise { + const userContext = this.userTracker.getUserContext(); + + if (targeting.userAgent) { + const matches = targeting.userAgent.some((pattern: string) => { + try { + return new RegExp(pattern).test(userContext.userAgent); + } catch { + console.warn(`Invalid userAgent regex pattern: "${pattern}"`); + return false; + } + }); + if (!matches) return false; + } + + if (targeting.language) { + if (targeting.language.indexOf(userContext.language) === -1) return false; + } + + if (targeting.referrer) { + const referrer = document.referrer; + const matches = targeting.referrer.some((pattern: string) => { + try { + return new RegExp(pattern).test(referrer); + } catch { + console.warn(`Invalid referrer regex pattern: "${pattern}"`); + return false; + } + }); + if (!matches) return false; + } + + 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; + } + } + + if (targeting.localStorage) { + for (const [key, value] of Object.entries(targeting.localStorage)) { + if (localStorage.getItem(key) !== value) return false; + } + } + + if (targeting.sessionStorage) { + for (const [key, value] of Object.entries(targeting.sessionStorage)) { + if (sessionStorage.getItem(key) !== value) return false; + } + } + + if (targeting.customFunction) { + const customFn = (window as any)[targeting.customFunction]; + if (isFunction(customFn)) { + const result = await customFn(userContext); + if (!result) return false; + } + } + + return true; + } + + async executeCampaign( + campaignId: string, + trigger: CampaignTrigger + ): Promise { + const campaign = this.campaigns.get(campaignId); + if (!campaign) return false; + + // Prevent launching the same campaign twice simultaneously + if (this.activeExperiences.has(campaignId)) return false; + + if (!(await this.shouldExecuteCampaign(campaign))) { + return false; + } + + try { + const experience = ExperienceFactory.createExperience(campaign); + + experience.onComplete(() => { + this.activeExperiences.delete(campaignId); + }); + + experience.onExit(() => { + this.activeExperiences.delete(campaignId); + }); + + this.activeExperiences.set(campaignId, experience); + + await experience.start(); + + // Track only after successful start + this.storage.trackCampaignExecution(campaignId); + + return true; + } catch (error) { + console.error(`Failed to start campaign ${campaign.mode} (trigger: ${trigger.type}):`, error); + this.activeExperiences.delete(campaignId); + return false; + } + } + + private setupCampaignTriggers(campaign: Campaign): void { + for (const trigger of campaign.triggers) { + this.triggerDetector.addTrigger( + campaign.id, + trigger, + (triggeredCampaignId, triggeredTrigger) => { + this.executeCampaign(triggeredCampaignId, triggeredTrigger).catch( + (err) => console.error("Campaign execution error:", err) + ); + } + ); + } + } + + async stopAllCampaigns(): Promise { + await Promise.all( + Array.from(this.activeExperiences.values()).map((exp) => exp.exit()) + ); + this.activeExperiences.clear(); + } + + async destroy(): Promise { + await this.stopAllCampaigns(); + this.triggerDetector.destroy(); + this.campaigns.clear(); + this.globalTargeting = undefined; + this.isInitialized = false; + } +} + +let globalCampaignManager: CampaignManager | null = null; + +export function getCampaignManager(): CampaignManager { + if (!globalCampaignManager) { + globalCampaignManager = new CampaignManager(); + } + return globalCampaignManager; +} + +export async function initializeCampaigns( + config: CampaignCollection | Campaign[] | string +): Promise { + const manager = getCampaignManager(); + manager.initialize(); + + if (typeof config === "string") { + await manager.loadCampaignsFromUrl(config); + } else { + 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..687e6366b --- /dev/null +++ b/src/packages/campaign/storage.ts @@ -0,0 +1,130 @@ +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 + */ + canExecuteCampaign(campaignId: string, frequency: CampaignFrequency): boolean { + const key = `${this.storagePrefix}${campaignId}`; + const data = this.getExecutionData(key); + + // Check execution limit — treat limit=0 as "never show" + if (frequency.limit !== undefined && data.count >= frequency.limit) { + return false; + } + + 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": + if (frequency.cooldownMs) { + return this.checkTimeWindow(data.lastExecution, frequency.cooldownMs); + } + return true; + + default: + return true; + } + } + + /** + * Track campaign execution + */ + trackCampaignExecution(campaignId: string): void { + 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"); + } + + 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 }; + } + } + + private checkTimeWindow( + lastExecution: number | null, + windowMs: number + ): boolean { + if (!lastExecution) return true; + return Date.now() - lastExecution >= windowMs; + } + + resetCampaign(campaignId: string): void { + const key = `${this.storagePrefix}${campaignId}`; + localStorage.removeItem(key); + sessionStorage.removeItem(key); + } + + resetAll(): void { + 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)); + + 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)); + } + + 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, + }; + } + + 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..1b8a6653a --- /dev/null +++ b/src/packages/campaign/triggers.ts @@ -0,0 +1,103 @@ +import { CampaignTrigger } from "./types"; +import { + TriggerCallback, + setupFirstVisitTrigger, + setupElementClickTrigger, + setupElementHoverTrigger, + setupIdleUserTrigger, + setupPageLoadTrigger, + setupScrollToElementTrigger, + setupTimeOnPageTrigger, + setupExitIntentTrigger, + setupFormInteractionTrigger, + setupCustomEventTrigger, + setupUrlMatchTrigger, + setupDeviceTypeTrigger, + setupReturningUserTrigger, + setupSessionCountTrigger, + setupScrollDepthTrigger, + setupElementVisibleTrigger, +} from "./triggers/index"; + +export class TriggerDetector { + private cleanupFunctions: Map void)[]> = new Map(); + private isInitialized = false; + + initialize(): void { + if (this.isInitialized) return; + this.isInitialized = true; + } + + addTrigger( + campaignId: string, + trigger: CampaignTrigger, + callback: TriggerCallback + ): void { + if (!this.cleanupFunctions.has(campaignId)) { + this.cleanupFunctions.set(campaignId, []); + } + + const cleanup = this.setupTrigger(campaignId, trigger, callback); + if (cleanup) { + this.cleanupFunctions.get(campaignId)!.push(cleanup); + } + } + + removeCampaignTriggers(campaignId: string): void { + const cleanups = this.cleanupFunctions.get(campaignId); + if (cleanups) { + cleanups.forEach((cleanup) => cleanup()); + this.cleanupFunctions.delete(campaignId); + } + } + + private setupTrigger( + campaignId: string, + trigger: CampaignTrigger, + callback: TriggerCallback + ): (() => void) | null { + switch (trigger.type) { + case "first_visit": + return setupFirstVisitTrigger(campaignId, trigger, callback); + case "element_click": + return setupElementClickTrigger(campaignId, trigger, callback); + case "element_hover": + return setupElementHoverTrigger(campaignId, trigger, callback); + case "idle_user": + return setupIdleUserTrigger(campaignId, trigger, callback); + case "page_load": + return setupPageLoadTrigger(campaignId, trigger, callback); + case "scroll_to_element": + return setupScrollToElementTrigger(campaignId, trigger, callback); + case "time_on_page": + return setupTimeOnPageTrigger(campaignId, trigger, callback); + case "exit_intent": + return setupExitIntentTrigger(campaignId, trigger, callback); + case "form_interaction": + return setupFormInteractionTrigger(campaignId, trigger, callback); + case "custom_event": + return setupCustomEventTrigger(campaignId, trigger, callback); + case "url_match": + return setupUrlMatchTrigger(campaignId, trigger, callback); + case "device_type": + return setupDeviceTypeTrigger(campaignId, trigger, callback); + case "returning_user": + return setupReturningUserTrigger(campaignId, trigger, callback); + case "session_count": + return setupSessionCountTrigger(campaignId, trigger, callback); + case "scroll_depth": + return setupScrollDepthTrigger(campaignId, trigger, callback); + case "element_visible": + return setupElementVisibleTrigger(campaignId, trigger, callback); + default: + return null; + } + } + + destroy(): void { + Array.from(this.cleanupFunctions.keys()).forEach((campaignId) => { + this.removeCampaignTriggers(campaignId); + }); + this.isInitialized = false; + } +} diff --git a/src/packages/campaign/triggers/customEvent.ts b/src/packages/campaign/triggers/customEvent.ts new file mode 100644 index 000000000..a6a902d5d --- /dev/null +++ b/src/packages/campaign/triggers/customEvent.ts @@ -0,0 +1,26 @@ +import { CampaignTrigger, isCustomEventTrigger } from "../types"; +import { TriggerCallback, TriggerCleanup } from "./types"; +import DOMEvent from "../../../util/DOMEvent"; + +/** + * Setup custom event trigger + */ +export function setupCustomEventTrigger( + campaignId: string, + trigger: CampaignTrigger, + callback: TriggerCallback +): TriggerCleanup { + if (!isCustomEventTrigger(trigger)) { + return () => {}; + } + + const handler = () => { + callback(campaignId, trigger); + }; + + DOMEvent.on(document, trigger.eventName as any, handler, false); + + return () => { + DOMEvent.off(document, trigger.eventName as any, handler, false); + }; +} diff --git a/src/packages/campaign/triggers/deviceType.ts b/src/packages/campaign/triggers/deviceType.ts new file mode 100644 index 000000000..62d52c244 --- /dev/null +++ b/src/packages/campaign/triggers/deviceType.ts @@ -0,0 +1,29 @@ +import { CampaignTrigger, isDeviceTypeTrigger } from "../types"; +import { TriggerCallback, TriggerCleanup } from "./types"; +import { detectDeviceType } from "./utils"; + +/** + * Setup device type trigger + */ +export function setupDeviceTypeTrigger( + campaignId: string, + trigger: CampaignTrigger, + callback: TriggerCallback +): TriggerCleanup { + if (!isDeviceTypeTrigger(trigger)) { + return () => {}; + } + + const currentDevice = detectDeviceType(); + + if (currentDevice === trigger.device) { + if (trigger.delay) { + setTimeout(() => callback(campaignId, trigger), trigger.delay); + } else { + callback(campaignId, trigger); + } + } + + // No cleanup needed for device type trigger + return () => {}; +} diff --git a/src/packages/campaign/triggers/elementClick.ts b/src/packages/campaign/triggers/elementClick.ts new file mode 100644 index 000000000..93bc5d06f --- /dev/null +++ b/src/packages/campaign/triggers/elementClick.ts @@ -0,0 +1,33 @@ +import { CampaignTrigger, isElementClickTrigger } from "../types"; +import { TriggerCallback, TriggerCleanup } from "./types"; +import DOMEvent from "../../../util/DOMEvent"; + +/** + * Setup element click trigger + */ +export function setupElementClickTrigger( + campaignId: string, + trigger: CampaignTrigger, + callback: TriggerCallback +): TriggerCleanup { + if (!isElementClickTrigger(trigger)) { + return () => {}; + } + + const handler = (event: Event) => { + const target = (event.target as Element).closest(trigger.selector); + if (target) { + if (trigger.delay) { + setTimeout(() => callback(campaignId, trigger), trigger.delay); + } else { + callback(campaignId, trigger); + } + } + }; + + DOMEvent.on(document, "click", handler, false); + + return () => { + DOMEvent.off(document, "click", handler, false); + }; +} diff --git a/src/packages/campaign/triggers/elementHover.ts b/src/packages/campaign/triggers/elementHover.ts new file mode 100644 index 000000000..cc71d6afa --- /dev/null +++ b/src/packages/campaign/triggers/elementHover.ts @@ -0,0 +1,55 @@ +import { CampaignTrigger, isElementHoverTrigger } from "../types"; +import { TriggerCallback, TriggerCleanup } from "./types"; +import DOMEvent from "../../../util/DOMEvent"; + +/** + * Setup element hover trigger + */ +export function setupElementHoverTrigger( + campaignId: string, + trigger: CampaignTrigger, + callback: TriggerCallback +): TriggerCleanup { + if (!isElementHoverTrigger(trigger)) { + return () => {}; + } + + const hoverDuration = trigger.hoverDuration ?? 0; + let hoverTimer: number | null = null; + let activeTarget: Element | null = null; + + const onMouseOver = (event: Event) => { + const target = (event.target as Element).closest(trigger.selector); + if (!target || target === activeTarget) return; + + activeTarget = target; + + if (hoverDuration > 0) { + hoverTimer = window.setTimeout(() => { + callback(campaignId, trigger); + }, hoverDuration); + } else { + callback(campaignId, trigger); + } + }; + + const onMouseOut = (event: Event) => { + const related = (event as MouseEvent).relatedTarget as Element | null; + if (activeTarget && (!related || !activeTarget.contains(related))) { + if (hoverTimer !== null) { + clearTimeout(hoverTimer); + hoverTimer = null; + } + activeTarget = null; + } + }; + + DOMEvent.on(document, "mouseover" as any, onMouseOver, false); + DOMEvent.on(document, "mouseout" as any, onMouseOut, false); + + return () => { + if (hoverTimer !== null) clearTimeout(hoverTimer); + DOMEvent.off(document, "mouseover" as any, onMouseOver, false); + DOMEvent.off(document, "mouseout" as any, onMouseOut, false); + }; +} diff --git a/src/packages/campaign/triggers/elementVisible.ts b/src/packages/campaign/triggers/elementVisible.ts new file mode 100644 index 000000000..c1c834efc --- /dev/null +++ b/src/packages/campaign/triggers/elementVisible.ts @@ -0,0 +1,54 @@ +import { CampaignTrigger, isElementVisibleTrigger } from "../types"; +import { TriggerCallback, TriggerCleanup } from "./types"; +import DOMEvent from "../../../util/DOMEvent"; + +/** + * Setup element visible trigger + */ +export function setupElementVisibleTrigger( + campaignId: string, + trigger: CampaignTrigger, + callback: TriggerCallback +): TriggerCleanup { + if (!isElementVisibleTrigger(trigger)) { + return () => {}; + } + + let hasTriggered = false; + + const checkVisibility = () => { + if (hasTriggered) return; + + const element = document.querySelector(trigger.selector); + if (!element) return; + + const rect = element.getBoundingClientRect(); + const elementHeight = rect.height; + if (elementHeight <= 0) return; + + const threshold = trigger.threshold || 0.5; + const visibleHeight = + Math.min(rect.bottom, window.innerHeight) - Math.max(rect.top, 0); + const visibilityRatio = visibleHeight / elementHeight; + + if (visibilityRatio >= threshold) { + hasTriggered = true; + callback(campaignId, trigger); + DOMEvent.off(window, "scroll", handler, false); + DOMEvent.off(window, "resize" as any, handler, false); + } + }; + + const handler = () => checkVisibility(); + + // Add listeners before initial check so DOMEvent.off inside checkVisibility works correctly + DOMEvent.on(window, "scroll", handler, false); + DOMEvent.on(window, "resize" as any, handler, false); + + checkVisibility(); + + return () => { + DOMEvent.off(window, "scroll", handler, false); + DOMEvent.off(window, "resize" as any, handler, false); + }; +} diff --git a/src/packages/campaign/triggers/exitIntent.ts b/src/packages/campaign/triggers/exitIntent.ts new file mode 100644 index 000000000..a330b3241 --- /dev/null +++ b/src/packages/campaign/triggers/exitIntent.ts @@ -0,0 +1,35 @@ +import { CampaignTrigger, isExitIntentTrigger } from "../types"; +import { TriggerCallback, TriggerCleanup } from "./types"; +import DOMEvent from "../../../util/DOMEvent"; + +/** + * Setup exit intent trigger + */ +export function setupExitIntentTrigger( + campaignId: string, + trigger: CampaignTrigger, + callback: TriggerCallback +): TriggerCleanup { + if (!isExitIntentTrigger(trigger)) { + return () => {}; + } + + const sensitivity = trigger.sensitivity ?? 10; + + let hasTriggered = false; + + const handler = (event: MouseEvent) => { + if (hasTriggered) return; + + if (event.clientY <= sensitivity) { + hasTriggered = true; + callback(campaignId, trigger); + } + }; + + DOMEvent.on(document, "mouseleave" as any, handler, false); + + return () => { + DOMEvent.off(document, "mouseleave" as any, handler, false); + }; +} diff --git a/src/packages/campaign/triggers/firstVisit.ts b/src/packages/campaign/triggers/firstVisit.ts new file mode 100644 index 000000000..90255be06 --- /dev/null +++ b/src/packages/campaign/triggers/firstVisit.ts @@ -0,0 +1,27 @@ +import { CampaignTrigger } from "../types"; +import { TriggerCallback, TriggerCleanup } from "./types"; + +/** + * Setup first visit trigger + */ +export function setupFirstVisitTrigger( + campaignId: string, + trigger: CampaignTrigger, + callback: TriggerCallback +): TriggerCleanup { + 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); + } + } + + // No cleanup needed for first visit trigger + return () => {}; +} diff --git a/src/packages/campaign/triggers/formInteraction.ts b/src/packages/campaign/triggers/formInteraction.ts new file mode 100644 index 000000000..ff5bc3c48 --- /dev/null +++ b/src/packages/campaign/triggers/formInteraction.ts @@ -0,0 +1,39 @@ +import { CampaignTrigger, isFormInteractionTrigger } from "../types"; +import { TriggerCallback, TriggerCleanup } from "./types"; +import DOMEvent from "../../../util/DOMEvent"; + +/** + * Setup form interaction trigger + */ +export function setupFormInteractionTrigger( + campaignId: string, + trigger: CampaignTrigger, + callback: TriggerCallback +): TriggerCleanup { + if (!isFormInteractionTrigger(trigger)) { + return () => {}; + } + + const selector = trigger.selector || "form input, form textarea, form select"; + const interactionType = trigger.interactionType ?? "focus"; + const eventName = + interactionType === "input" ? "input" : + interactionType === "change" ? "change" : + "focus"; + + // focus doesn't bubble — use capture phase; input/change bubble natively + const useCapture = eventName === "focus"; + + const handler = (event: Event) => { + const target = event.target as Element; + if (target.closest(selector)) { + callback(campaignId, trigger); + } + }; + + DOMEvent.on(document, eventName as any, handler, useCapture); + + return () => { + DOMEvent.off(document, eventName as any, handler, useCapture); + }; +} diff --git a/src/packages/campaign/triggers/idleUser.ts b/src/packages/campaign/triggers/idleUser.ts new file mode 100644 index 000000000..56e9175df --- /dev/null +++ b/src/packages/campaign/triggers/idleUser.ts @@ -0,0 +1,62 @@ +import { CampaignTrigger, isIdleUserTrigger } from "../types"; +import { TriggerCallback, TriggerCleanup } from "./types"; +import DOMEvent from "../../../util/DOMEvent"; + +/** + * Setup idle user trigger + */ +export function setupIdleUserTrigger( + campaignId: string, + trigger: CampaignTrigger, + callback: TriggerCallback +): TriggerCleanup { + if (!isIdleUserTrigger(trigger)) { + return () => {}; + } + + 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); + }); + + const handleVisibility = () => { + if (document.hidden) { + clearTimeout(idleTimer); + } else { + resetTimer(); + } + }; + + DOMEvent.on(document, "visibilitychange" as any, handleVisibility, false); + + resetTimer(); + + return () => { + clearTimeout(idleTimer); + handlers.forEach((cleanup) => cleanup()); + DOMEvent.off(document, "visibilitychange" as any, handleVisibility, false); + }; +} diff --git a/src/packages/campaign/triggers/index.ts b/src/packages/campaign/triggers/index.ts new file mode 100644 index 000000000..cb30a3c45 --- /dev/null +++ b/src/packages/campaign/triggers/index.ts @@ -0,0 +1,22 @@ +/** + * Export all trigger setup functions + */ +export { setupFirstVisitTrigger } from "./firstVisit"; +export { setupElementClickTrigger } from "./elementClick"; +export { setupElementHoverTrigger } from "./elementHover"; +export { setupIdleUserTrigger } from "./idleUser"; +export { setupPageLoadTrigger } from "./pageLoad"; +export { setupScrollToElementTrigger } from "./scrollToElement"; +export { setupTimeOnPageTrigger } from "./timeOnPage"; +export { setupExitIntentTrigger } from "./exitIntent"; +export { setupFormInteractionTrigger } from "./formInteraction"; +export { setupCustomEventTrigger } from "./customEvent"; +export { setupUrlMatchTrigger } from "./urlMatch"; +export { setupDeviceTypeTrigger } from "./deviceType"; +export { setupReturningUserTrigger } from "./returningUser"; +export { setupSessionCountTrigger } from "./sessionCount"; +export { setupScrollDepthTrigger } from "./scrollDepth"; +export { setupElementVisibleTrigger } from "./elementVisible"; + +export type { TriggerCallback, TriggerCleanup } from "./types"; +export { detectDeviceType } from "./utils"; diff --git a/src/packages/campaign/triggers/pageLoad.ts b/src/packages/campaign/triggers/pageLoad.ts new file mode 100644 index 000000000..0533ee2b5 --- /dev/null +++ b/src/packages/campaign/triggers/pageLoad.ts @@ -0,0 +1,35 @@ +import { CampaignTrigger } from "../types"; +import { TriggerCallback, TriggerCleanup } from "./types"; +import DOMEvent from "../../../util/DOMEvent"; + +/** + * Setup page load trigger + */ +export function setupPageLoadTrigger( + campaignId: string, + trigger: CampaignTrigger, + callback: TriggerCallback +): TriggerCleanup { + if (document.readyState === "complete") { + if (trigger.delay) { + setTimeout(() => callback(campaignId, trigger), trigger.delay); + } else { + callback(campaignId, trigger); + } + return () => {}; + } else { + const handler = () => { + if (trigger.delay) { + setTimeout(() => callback(campaignId, trigger), trigger.delay); + } else { + callback(campaignId, trigger); + } + }; + + DOMEvent.on(window, "load" as any, handler, false); + + return () => { + DOMEvent.off(window, "load" as any, handler, false); + }; + } +} diff --git a/src/packages/campaign/triggers/returningUser.ts b/src/packages/campaign/triggers/returningUser.ts new file mode 100644 index 000000000..9c696c443 --- /dev/null +++ b/src/packages/campaign/triggers/returningUser.ts @@ -0,0 +1,43 @@ +import { CampaignTrigger, isReturningUserTrigger } from "../types"; +import { TriggerCallback, TriggerCleanup } from "./types"; + +/** + * Setup returning user trigger + */ +export function setupReturningUserTrigger( + campaignId: string, + trigger: CampaignTrigger, + callback: TriggerCallback +): TriggerCleanup { + if (!isReturningUserTrigger(trigger)) { + return () => {}; + } + + const cookieName = trigger.cookieName || "introjs-returning-user"; + const hasVisited = localStorage.getItem(cookieName); + + if (!hasVisited) { + // First visit — mark and skip + localStorage.setItem(cookieName, Date.now().toString()); + return () => {}; + } + + // userTracker already incremented the count for this visit, + // so previousVisits = totalVisits - 1 + const totalVisits = parseInt( + localStorage.getItem("introjs-campaign-session-count") || "0", + 10 + ); + const previousVisits = Math.max(0, totalVisits - 1); + const minVisits = trigger.minVisits ?? 1; + + if (previousVisits >= minVisits) { + if (trigger.delay) { + setTimeout(() => callback(campaignId, trigger), trigger.delay); + } else { + callback(campaignId, trigger); + } + } + + return () => {}; +} diff --git a/src/packages/campaign/triggers/scrollDepth.ts b/src/packages/campaign/triggers/scrollDepth.ts new file mode 100644 index 000000000..f78b9ac3d --- /dev/null +++ b/src/packages/campaign/triggers/scrollDepth.ts @@ -0,0 +1,36 @@ +import { CampaignTrigger, isScrollDepthTrigger } from "../types"; +import { TriggerCallback, TriggerCleanup } from "./types"; +import DOMEvent from "../../../util/DOMEvent"; + +/** + * Setup scroll depth trigger + */ +export function setupScrollDepthTrigger( + campaignId: string, + trigger: CampaignTrigger, + callback: TriggerCallback +): TriggerCleanup { + if (!isScrollDepthTrigger(trigger)) { + return () => {}; + } + + const handler = () => { + const scrollableHeight = + document.documentElement.scrollHeight - window.innerHeight; + + if (scrollableHeight <= 0) return; + + const scrollPercent = (window.scrollY / scrollableHeight) * 100; + + if (scrollPercent >= trigger.percentage) { + callback(campaignId, trigger); + DOMEvent.off(window, "scroll", handler, false); + } + }; + + DOMEvent.on(window, "scroll", handler, false); + + return () => { + DOMEvent.off(window, "scroll", handler, false); + }; +} diff --git a/src/packages/campaign/triggers/scrollToElement.ts b/src/packages/campaign/triggers/scrollToElement.ts new file mode 100644 index 000000000..454a471e7 --- /dev/null +++ b/src/packages/campaign/triggers/scrollToElement.ts @@ -0,0 +1,41 @@ +import { CampaignTrigger, isScrollToElementTrigger } from "../types"; +import { TriggerCallback, TriggerCleanup } from "./types"; +import DOMEvent from "../../../util/DOMEvent"; + +/** + * Setup scroll to element trigger + */ +export function setupScrollToElementTrigger( + campaignId: string, + trigger: CampaignTrigger, + callback: TriggerCallback +): TriggerCleanup { + if (!isScrollToElementTrigger(trigger)) { + return () => {}; + } + + const handler = () => { + const element = document.querySelector(trigger.selector); + if (element) { + const rect = element.getBoundingClientRect(); + const elementHeight = rect.height; + if (elementHeight <= 0) return; + + const threshold = trigger.threshold || 0.5; + 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); + + return () => { + DOMEvent.off(window, "scroll", handler, false); + }; +} diff --git a/src/packages/campaign/triggers/sessionCount.ts b/src/packages/campaign/triggers/sessionCount.ts new file mode 100644 index 000000000..3e5c06d53 --- /dev/null +++ b/src/packages/campaign/triggers/sessionCount.ts @@ -0,0 +1,48 @@ +import { CampaignTrigger, isSessionCountTrigger } from "../types"; +import { TriggerCallback, TriggerCleanup } from "./types"; + +/** + * Setup session count trigger + */ +export function setupSessionCountTrigger( + campaignId: string, + trigger: CampaignTrigger, + callback: TriggerCallback +): TriggerCleanup { + if (!isSessionCountTrigger(trigger)) { + return () => {}; + } + + // userTracker manages this key — read without re-incrementing + const sessionCount = parseInt( + localStorage.getItem("introjs-campaign-session-count") || "0", + 10 + ); + + 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); + } + } + + // No cleanup needed for session count trigger + return () => {}; +} diff --git a/src/packages/campaign/triggers/timeOnPage.ts b/src/packages/campaign/triggers/timeOnPage.ts new file mode 100644 index 000000000..7ffa265ec --- /dev/null +++ b/src/packages/campaign/triggers/timeOnPage.ts @@ -0,0 +1,23 @@ +import { CampaignTrigger, isTimeOnPageTrigger } from "../types"; +import { TriggerCallback, TriggerCleanup } from "./types"; + +/** + * Setup time on page trigger + */ +export function setupTimeOnPageTrigger( + campaignId: string, + trigger: CampaignTrigger, + callback: TriggerCallback +): TriggerCleanup { + if (!isTimeOnPageTrigger(trigger)) { + return () => {}; + } + + const timer = window.setTimeout(() => { + callback(campaignId, trigger); + }, trigger.duration); + + return () => { + clearTimeout(timer); + }; +} diff --git a/src/packages/campaign/triggers/types.ts b/src/packages/campaign/triggers/types.ts new file mode 100644 index 000000000..1131d4997 --- /dev/null +++ b/src/packages/campaign/triggers/types.ts @@ -0,0 +1,14 @@ +import { CampaignTrigger } from "../types"; + +/** + * Trigger callback function type + */ +export type TriggerCallback = ( + campaignId: string, + trigger: CampaignTrigger +) => void; + +/** + * Trigger cleanup function type + */ +export type TriggerCleanup = () => void; diff --git a/src/packages/campaign/triggers/urlMatch.ts b/src/packages/campaign/triggers/urlMatch.ts new file mode 100644 index 000000000..bc531792e --- /dev/null +++ b/src/packages/campaign/triggers/urlMatch.ts @@ -0,0 +1,46 @@ +import { CampaignTrigger, isUrlMatchTrigger } from "../types"; +import { TriggerCallback, TriggerCleanup } from "./types"; + +/** + * Setup URL match trigger + */ +export function setupUrlMatchTrigger( + campaignId: string, + trigger: CampaignTrigger, + callback: TriggerCallback +): TriggerCleanup { + if (!isUrlMatchTrigger(trigger)) { + return () => {}; + } + + 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: + try { + matches = new RegExp(trigger.pattern).test(currentUrl); + } catch { + console.warn(`Invalid URL regex pattern: "${trigger.pattern}"`); + } + break; + } + + if (matches) { + if (trigger.delay) { + setTimeout(() => callback(campaignId, trigger), trigger.delay); + } else { + callback(campaignId, trigger); + } + } + + // No cleanup needed for URL match trigger + return () => {}; +} diff --git a/src/packages/campaign/triggers/utils.ts b/src/packages/campaign/triggers/utils.ts new file mode 100644 index 000000000..2daa59176 --- /dev/null +++ b/src/packages/campaign/triggers/utils.ts @@ -0,0 +1,20 @@ +/** + * Detect device type based on screen size and user agent + */ +export function 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"; + } +} diff --git a/src/packages/campaign/types.ts b/src/packages/campaign/types.ts new file mode 100644 index 000000000..615e3bfc3 --- /dev/null +++ b/src/packages/campaign/types.ts @@ -0,0 +1,330 @@ +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) +} + +/** + * Base campaign configuration with common fields + */ +export interface BaseCampaign { + 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[]; + + // 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) +} + +/** + * Tour campaign configuration + */ +export interface TourCampaign extends BaseCampaign { + mode: "tour"; + options: Partial; +} + +/** + * Hint campaign configuration + */ +export interface HintCampaign extends BaseCampaign { + mode: "hint"; + options: Partial; +} + +/** + * Main campaign configuration - discriminated union based on mode + */ +export type Campaign = TourCampaign | HintCampaign; + +/** + * Campaign collection (multiple campaigns) + */ +export interface CampaignCollection { + version: string; + campaigns: Campaign[]; + global?: { + targeting?: CampaignTargeting; + }; +} + +/** + * Type guard functions for triggers + */ +export function isExitIntentTrigger( + trigger: CampaignTrigger +): trigger is ExitIntentTrigger { + return trigger.type === "exit_intent"; +} + +export function isReturningUserTrigger( + trigger: CampaignTrigger +): trigger is ReturningUserTrigger { + return trigger.type === "returning_user"; +} + +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..c675e05c7 --- /dev/null +++ b/src/packages/campaign/userTracker.ts @@ -0,0 +1,85 @@ +import { detectDeviceType } from "./triggers/utils"; + +/** + * 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 + */ + initialize(): void { + 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 = 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; + } + + /** + * 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; + } +}