posthog-tours
Version:
A TypeScript package for creating guided tours in PostHog
273 lines (272 loc) • 10.7 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.PostHogTours = void 0;
const posthog_js_1 = __importDefault(require("posthog-js"));
const types_1 = require("./types");
class PostHogTours {
constructor(options) {
var _a, _b;
this.observers = new Map();
this.intersectionObservers = new Map();
this.activeTourId = null;
this.localStorageKey = 'posthog_tours_seen';
this.posthog = options.posthogInstance || posthog_js_1.default;
this.tours = options.tours;
this.userPropertyPrefix = options.userPropertyPrefix || 'seen_tour_';
this.defaultOnEligible = options.defaultOnEligible;
this.shouldCheckElementVisibility = (_a = options.checkElementVisibility) !== null && _a !== void 0 ? _a : true;
this.debug = (_b = options.debug) !== null && _b !== void 0 ? _b : false; // Default to false (silent mode)
// Check if PostHog is initialized
if (!this.posthog.__loaded) {
throw new types_1.PostHogNotInitializedError();
}
// Validate that all provided feature flags exist
this.validateFeatureFlags();
// Sync localStorage with PostHog on initialization
this.syncLocalStorageWithPostHog();
// Start monitoring all tours
this.startMonitoringTours();
}
log(level, ...args) {
if (this.debug) {
console[level](...args);
}
}
syncLocalStorageWithPostHog() {
// Get all seen tours from PostHog
const userProperties = this.posthog.get_property('$stored_person_properties') || {};
const seenTours = this.getSeenToursFromStorage();
let hasChanges = false;
// Check for any tours marked as seen in PostHog but not in localStorage
Object.keys(userProperties).forEach(key => {
if (key.startsWith(this.userPropertyPrefix) && userProperties[key] && !seenTours[key]) {
seenTours[key] = true;
hasChanges = true;
}
});
// Save to localStorage if there were any changes
if (hasChanges) {
this.saveSeenToursToStorage(seenTours);
}
}
validateFeatureFlags() {
const missingFlags = Object.keys(this.tours).filter(flag => !this.posthog.isFeatureEnabled(flag));
if (missingFlags.length > 0) {
this.log('warn', `PostHog Tours: The following feature flags are not configured: ${missingFlags.join(', ')}. These tours will be ignored.`);
}
}
startMonitoringTours() {
Object.entries(this.tours).forEach(([flagKey, tour]) => {
if (tour.target) {
this.monitorElement(flagKey, tour.target);
}
});
}
monitorElement(flagKey, selector) {
// Check if element already exists
const element = document.querySelector(selector);
if (element) {
this.checkTourEligibility(flagKey);
return;
}
// Set up observer to watch for the element
const observer = new MutationObserver((mutations, obs) => {
const element = document.querySelector(selector);
if (element) {
obs.disconnect();
this.observers.delete(flagKey);
this.checkTourEligibility(flagKey);
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
this.observers.set(flagKey, observer);
}
async checkTourEligibility(tourId) {
const tour = this.tours[tourId];
const selector = tour.target;
if (!selector) {
return {
eligible: false,
element: null,
tourId,
flagEnabled: this.posthog.isFeatureEnabled(tourId) || false,
targetPresent: false,
alreadySeen: this.hasTourBeenSeen(tourId)
};
}
const element = document.querySelector(selector);
const flagEnabled = this.posthog.isFeatureEnabled(tourId) || false;
const alreadySeen = this.hasTourBeenSeen(tourId);
const result = {
eligible: false,
element,
tourId,
flagEnabled,
targetPresent: !!element,
alreadySeen
};
// Element doesn't exist or flag is off or user has already seen it
if (!element || !flagEnabled || alreadySeen) {
return result;
}
// If we need to check visibility
if (this.shouldCheckElementVisibility) {
const isVisible = await this.checkVisibility(tourId, element);
if (!isVisible) {
return result;
}
}
// We've met all conditions for eligibility!
result.eligible = true;
// Only trigger the tour if no other tour is active
if (this.activeTourId === null) {
// Set this tour as active before calling the callback
this.activeTourId = tourId;
// Call the callback
const onEligible = tour.onEligible || this.defaultOnEligible;
if (onEligible && element) {
onEligible(element, tourId);
}
}
return result;
}
getSeenToursFromStorage() {
try {
const stored = localStorage.getItem(this.localStorageKey);
if (!stored)
return {};
return JSON.parse(stored);
}
catch (error) {
this.log('warn', 'Failed to parse posthog_tours_seen from localStorage:', error);
// Clear corrupted data
localStorage.removeItem(this.localStorageKey);
return {};
}
}
saveSeenToursToStorage(seenTours) {
try {
localStorage.setItem(this.localStorageKey, JSON.stringify(seenTours));
}
catch (error) {
// Could be QuotaExceededError or other storage issues
this.log('error', 'Failed to save tour state to localStorage:', error);
}
}
checkVisibility(tourId, element) {
return new Promise((resolve) => {
// If the element is already in the viewport, resolve immediately
const rect = element.getBoundingClientRect();
const isInViewport = (rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
rect.right <= (window.innerWidth || document.documentElement.clientWidth));
if (isInViewport) {
resolve(true);
return;
}
// Otherwise, set up an IntersectionObserver to wait for it to become visible
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
observer.disconnect();
this.intersectionObservers.delete(tourId);
resolve(true);
}
}, { threshold: 0.1 }); // Element is visible when at least 10% is in view
observer.observe(element);
this.intersectionObservers.set(tourId, observer);
// Set a timeout to prevent waiting forever if the element never becomes visible
setTimeout(() => {
if (this.intersectionObservers.has(tourId)) {
observer.disconnect();
this.intersectionObservers.delete(tourId);
resolve(false);
}
}, 30000); // 30 second timeout
});
}
hasTourBeenSeen(tourId) {
const key = `${this.userPropertyPrefix}${tourId}`;
// Check localStorage first (always up-to-date)
const seenTours = this.getSeenToursFromStorage();
if (seenTours[key]) {
return true;
}
// Check PostHog's properties (might have data from other sessions/devices)
const userProperties = this.posthog.get_property('$stored_person_properties') || {};
const seenInPostHog = !!userProperties[key];
// If PostHog has it but localStorage doesn't, sync localStorage
if (seenInPostHog) {
seenTours[key] = true;
this.saveSeenToursToStorage(seenTours);
}
return seenInPostHog;
}
markTourAsSeen(tourId) {
var _a;
const key = `${this.userPropertyPrefix}${tourId}`;
const properties = {};
properties[key] = true;
// 1. Update PostHog (eventual consistency)
this.posthog.people.set(properties);
// 2. Also update localStorage immediately (immediate consistency)
const seenTours = this.getSeenToursFromStorage();
seenTours[key] = true;
this.saveSeenToursToStorage(seenTours);
// Also capture an event for analytics purposes
this.posthog.capture('tour_seen', {
tour_id: tourId,
tour_name: (_a = this.tours[tourId]) === null || _a === void 0 ? void 0 : _a.name
});
// Clear active tour and check for other eligible tours
this.activeTourId = null;
this.checkAllTours();
}
async checkAllTours() {
const results = [];
for (const tourId of Object.keys(this.tours)) {
const result = await this.checkTourEligibility(tourId);
results.push(result);
// If we found an eligible tour, no need to check others
if (result.eligible) {
break;
}
}
return results;
}
reset() {
// Clear all observers
this.observers.forEach(observer => observer.disconnect());
this.observers.clear();
this.intersectionObservers.forEach(observer => observer.disconnect());
this.intersectionObservers.clear();
// Start monitoring again
this.startMonitoringTours();
}
getTourConfig(flagKey) {
return this.tours[flagKey];
}
async forceTour(tourId) {
const tour = this.tours[tourId];
if (!tour || !tour.target) {
return false;
}
const element = document.querySelector(tour.target);
if (!element) {
return false;
}
const onEligible = tour.onEligible || this.defaultOnEligible;
if (onEligible) {
onEligible(element, tourId);
return true;
}
return false;
}
}
exports.PostHogTours = PostHogTours;