UNPKG

@mobiloud/ml-review-popup

Version:

A modern, iOS-style popup widget designed specifically for MobiLoud mobile apps to prompt users to leave app store reviews

887 lines (749 loc) 33.7 kB
/** * Review Prompt Popup Widget - v1.0 * A minimal, modern popup for prompting app store reviews * Integrated with MobiLoud app platform * * Features: * - Manual initialization (no auto-initialization) * - Auto-trigger session control with interval support * - Manual dark mode control * - Cookie-based session limiting and "never show again" logic * - Google Analytics integration (recommended) * * Usage: * const popup = createReviewPopup({ * heading: "Rate Our App", * text: "Love using our app? Leave us a review!", * enableAnalytics: true, * autoTrigger: true, * darkMode: false, * debugMode: false // Set to true for browser testing * }); */ class ReviewPromptPopup { // Track style usage count static styleUsageCount = 0; // Track if any popup is currently visible globally static currentlyVisible = false; constructor(options = {}) { this.options = { heading: options.heading || "Rate Our App", image: options.image || null, text: options.text || "Love using our app? Your feedback helps us improve and reach more people like you!", acceptText: options.acceptText || "Rate Now", declineText: options.declineText !== undefined ? options.declineText : "Maybe Later", successMessage: options.successMessage || "✅ Thank you for your feedback!", onAccept: options.onAccept || (() => console.log('Accepted')), onDecline: options.onDecline || (() => console.log('Declined')), onClose: options.onClose || (() => console.log('Closed')), autoTrigger: options.autoTrigger || false, triggerElement: options.triggerElement || null, delay: options.delay || 0, allowedUrls: options.allowedUrls || null, debugMode: options.debugMode || false, maxSessions: options.maxSessions || null, timeframeDays: options.timeframeDays || null, initialDelay: options.initialDelay || null, darkMode: options.darkMode || false, enableAnalytics: options.enableAnalytics || false, colors: { // Button colors acceptButton: options.colors?.acceptButton || '#007AFF', acceptButtonHover: options.colors?.acceptButtonHover || '#0056CC', acceptButtonText: options.colors?.acceptButtonText || 'white', declineButton: options.colors?.declineButton || '#f5f5f5', declineButtonHover: options.colors?.declineButtonHover || '#e5e5e5', declineButtonText: options.colors?.declineButtonText || '#666', // Close button colors closeButton: options.colors?.closeButton || '#f5f5f5', closeButtonHover: options.colors?.closeButtonHover || '#e5e5e5', closeButtonText: options.colors?.closeButtonText || '#666', // Text and background colors headingText: options.colors?.headingText || '#1a1a1a', bodyText: options.colors?.bodyText || '#666', background: options.colors?.background || 'white', // Success message colors successBackground: options.colors?.successBackground || '#e8f5e9', successBorder: options.colors?.successBorder || '#34c759', successText: options.colors?.successText || '#256029', // Dark mode colors (used when darkMode is enabled) darkMode: { background: options.colors?.darkMode?.background || '#2c2c2e', headingText: options.colors?.darkMode?.headingText || 'white', bodyText: options.colors?.darkMode?.bodyText || '#a1a1a6', declineButton: options.colors?.darkMode?.declineButton || '#48484a', declineButtonHover: options.colors?.darkMode?.declineButtonHover || '#5a5a5c', declineButtonText: options.colors?.darkMode?.declineButtonText || '#a1a1a6', closeButton: options.colors?.darkMode?.closeButton || '#48484a', closeButtonHover: options.colors?.darkMode?.closeButtonHover || '#5a5a5c', closeButtonText: options.colors?.darkMode?.closeButtonText || '#a1a1a6', successBackground: options.colors?.darkMode?.successBackground || '#1e3a1e', successBorder: options.colors?.darkMode?.successBorder || '#30d158', successText: options.colors?.darkMode?.successText || '#30d158' } } }; this.isVisible = false; this.overlay = null; this.popup = null; this._onOverlayClick = null; this._onKeyDown = null; this.cookieName = 'ml_review_popup_tracking'; this.hasAutoTriggeredThisSession = false; this.init(); } trackEvent(eventName, customParameters = {}) { if (!this.options.enableAnalytics) { return; } try { // Check if gtag is available if (typeof window.gtag !== 'function') { if (this.options.debugMode) { console.warn('[ReviewPopup Analytics] Google Analytics (gtag) not available - event not tracked:', eventName); } return; } // Ensure event name has ml_ prefix const prefixedEventName = eventName.startsWith('ml_') ? eventName : `ml_${eventName}`; // Standard event parameters const standardParameters = { event_category: 'engagement', event_label: 'review_prompt_popup', page_url: window.location.href, page_title: document.title, user_agent: navigator.userAgent, timestamp: new Date().toISOString() }; // Merge custom parameters with standard ones const eventParameters = { ...standardParameters, ...customParameters }; // Track the event window.gtag('event', prefixedEventName, eventParameters); if (this.options.debugMode) { console.log('[ReviewPopup Analytics] Event tracked:', { event: prefixedEventName, parameters: eventParameters }); } } catch (error) { if (this.options.debugMode) { console.error('[ReviewPopup Analytics] Error tracking event:', eventName, error); } } } init() { // Check if current URL is allowed if (!this.isUrlAllowed()) { return; // Don't initialize if URL is not in allowed list } // Check if should show popup (app context + push disabled, unless debug mode) if (!this.shouldShowPopup()) { return; // Don't initialize if conditions aren't met } this.createStyles(); this.createPopup(); this.bindEvents(); // Auto-trigger if enabled if (this.options.autoTrigger) { setTimeout(() => this.show(true), this.options.delay); } // Bind to trigger element if provided if (this.options.triggerElement) { const element = typeof this.options.triggerElement === 'string' ? document.querySelector(this.options.triggerElement) : this.options.triggerElement; if (element) { element.addEventListener('click', () => this.show()); } } } isInApp() { // Check if user agent contains "canvas" (case insensitive) return navigator.userAgent.toLowerCase().includes('canvas'); } shouldShowPopup() { // Check if user has already accepted and never wants to see again const data = this.getCookieData(); if (data.neverShowAgain) { console.log('[ReviewPopup] User has already accepted review - popup will not show'); return false; } // In debug mode, bypass remaining checks if (this.options.debugMode) { console.log('[ReviewPopup Debug] Debug mode enabled - bypassing app checks'); return true; } // Check if user is in the app if (!this.isInApp()) { console.log('[ReviewPopup] Not in app context - popup will not show'); return false; } // Check session limits if configured if (this.options.maxSessions !== null && this.options.timeframeDays !== null) { if (!this.shouldShowBasedOnLimits()) { console.log('[ReviewPopup] Session limit reached - popup will not show'); return false; } } return true; } getCookieData() { try { const cookieValue = document.cookie .split('; ') .find(row => row.startsWith(this.cookieName + '=')); if (!cookieValue) { return { count: 0, firstShown: null, firstEligibleDate: null, neverShowAgain: false }; } const data = JSON.parse(decodeURIComponent(cookieValue.split('=')[1])); // Add missing fields for backward compatibility if (!data.hasOwnProperty('firstEligibleDate')) { data.firstEligibleDate = null; } if (!data.hasOwnProperty('neverShowAgain')) { data.neverShowAgain = false; } return data; } catch (e) { return { count: 0, firstShown: null, firstEligibleDate: null, neverShowAgain: false }; } } setCookieData(data) { const expires = new Date(); expires.setFullYear(expires.getFullYear() + 1); // 1 year expiry const cookieValue = encodeURIComponent(JSON.stringify(data)); document.cookie = `${this.cookieName}=${cookieValue}; expires=${expires.toUTCString()}; path=/`; } setNeverShowAgain() { const data = this.getCookieData(); data.neverShowAgain = true; this.setCookieData(data); if (this.options.debugMode) { console.log('[ReviewPopup] Never show again flag set in cookies'); } } shouldShowBasedOnLimits() { const data = this.getCookieData(); const now = new Date().getTime(); // Handle initial delay if configured if (this.options.initialDelay !== null) { if (!data.firstEligibleDate) { // First time - set the eligible date to now + initialDelay days const firstEligibleDate = now + (this.options.initialDelay * 24 * 60 * 60 * 1000); this.setCookieData({ ...data, firstEligibleDate: firstEligibleDate }); return false; // Don't show yet } // Check if we've reached the eligible date if (now < data.firstEligibleDate) { return false; // Not yet eligible } } // If no previous data, allow showing if (!data.firstShown) { return true; } const daysSinceFirst = (now - data.firstShown) / (1000 * 60 * 60 * 24); // If outside timeframe, reset count if (daysSinceFirst > this.options.timeframeDays) { this.setCookieData({ count: 0, firstShown: now, firstEligibleDate: data.firstEligibleDate }); return true; } // Check if under the limit return data.count < this.options.maxSessions; } incrementSessionCount() { if (this.options.maxSessions === null || this.options.timeframeDays === null) { return; } const data = this.getCookieData(); const now = new Date().getTime(); if (!data.firstShown) { // First time showing this.setCookieData({ count: 1, firstShown: now }); } else { const daysSinceFirst = (now - data.firstShown) / (1000 * 60 * 60 * 24); if (daysSinceFirst > this.options.timeframeDays) { // Reset timeframe this.setCookieData({ count: 1, firstShown: now }); } else { // Increment count this.setCookieData({ count: data.count + 1, firstShown: data.firstShown }); } } } isUrlAllowed() { // If no URL restrictions specified, allow all URLs if (!this.options.allowedUrls) { return true; } const currentUrl = window.location.href; const currentPath = window.location.pathname; const allowedUrls = Array.isArray(this.options.allowedUrls) ? this.options.allowedUrls : [this.options.allowedUrls]; return allowedUrls.some(pattern => { // Exact match if (pattern === currentUrl || pattern === currentPath) { return true; } // Wildcard matching (* = any characters) if (pattern.includes('*')) { const regexPattern = pattern .replace(/[.+?^${}()|[\]\\]/g, '\\$&') // Escape special regex chars .replace(/\*/g, '.*'); // Convert * to .* const regex = new RegExp(`^${regexPattern}$`, 'i'); return regex.test(currentUrl) || regex.test(currentPath); } // Regex pattern (if it starts and ends with /) if (pattern.startsWith('/') && pattern.endsWith('/')) { const regexPattern = pattern.slice(1, -1); const regex = new RegExp(regexPattern, 'i'); return regex.test(currentUrl) || regex.test(currentPath); } // Partial match (contains) return currentUrl.includes(pattern) || currentPath.includes(pattern); }); } createStyles() { if (!document.getElementById('ml-review-popup-styles')) { const colors = this.options.colors; const styles = ` .ml-review-popup-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.5); backdrop-filter: blur(10px); z-index: 10000; opacity: 0; transition: opacity 0.3s ease; pointer-events: none; } .ml-review-popup-overlay.ml-visible { opacity: 1; pointer-events: all; } .ml-review-popup { position: fixed; bottom: 0; left: 0; right: 0; background: ${colors.background}; border-radius: 20px 20px 0 0; padding: 24px; box-shadow: none; /* No shadow when hidden */ transform: translateY(100%); transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.4s cubic-bezier(0.4, 0, 0.2, 1); max-width: 500px; margin: 0 auto; z-index: 10001; } .ml-review-popup.ml-dark-mode { background: ${colors.darkMode.background}; } .ml-review-popup.ml-visible { transform: translateY(0); box-shadow: 0 -10px 40px rgba(0, 0, 0, 0.15); /* Shadow only when visible */ } .ml-review-popup-close { position: absolute; top: 16px; right: 16px; width: 32px; height: 32px; min-width: 32px; /* Ensure minimum width for perfect circle */ min-height: 32px; /* Ensure minimum height for perfect circle */ border: none; background: ${colors.closeButton}; border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 18px; color: ${colors.closeButtonText}; transition: all 0.2s ease; box-sizing: border-box; padding: 0; line-height: 1; /* Mobile-specific properties */ -webkit-appearance: none; -webkit-tap-highlight-color: transparent; -webkit-user-select: none; user-select: none; } .ml-dark-mode .ml-review-popup-close { background: ${colors.darkMode.closeButton}; color: ${colors.darkMode.closeButtonText}; } .ml-review-popup-close:hover { background: ${colors.closeButtonHover}; transform: scale(1.1); } .ml-dark-mode .ml-review-popup-close:hover { background: ${colors.darkMode.closeButtonHover}; } .ml-review-popup-content { text-align: center; padding-top: 12px; } .ml-review-popup-heading { font-size: 22px; font-weight: 600; color: ${colors.headingText}; margin: 0 0 16px 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; } .ml-dark-mode .ml-review-popup-heading { color: ${colors.darkMode.headingText}; } .ml-review-popup-image { width: 64px; height: 64px; margin: 0 auto 16px auto; border-radius: 12px; object-fit: cover; } .ml-review-popup-text { font-size: 16px; line-height: 1.5; color: ${colors.bodyText}; margin: 0 0 24px 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; } .ml-dark-mode .ml-review-popup-text { color: ${colors.darkMode.bodyText}; } .ml-review-popup-buttons { display: flex; gap: 12px; flex-direction: column; } .ml-review-popup-button { /* Flexbox centering for reliable text alignment */ display: flex; align-items: center; justify-content: center; /* Fixed height instead of padding-only approach */ height: 56px; padding: 0 24px; border-radius: 12px; font-size: 16px; font-weight: 600; border: none; cursor: pointer; transition: all 0.2s ease; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; /* CSS reset properties to override conflicts */ line-height: 1; vertical-align: middle; text-align: center; box-sizing: border-box; /* Mobile-specific webkit properties */ -webkit-appearance: none; -webkit-tap-highlight-color: transparent; -webkit-user-select: none; user-select: none; } .ml-review-popup-accept { background: ${colors.acceptButton}; color: ${colors.acceptButtonText}; } .ml-review-popup-accept:hover { background: ${colors.acceptButtonHover}; transform: translateY(-1px); } .ml-review-popup-decline { background: ${colors.declineButton}; color: ${colors.declineButtonText}; } .ml-dark-mode .ml-review-popup-decline { background: ${colors.darkMode.declineButton}; color: ${colors.darkMode.declineButtonText}; } .ml-review-popup-decline:hover { background: ${colors.declineButtonHover}; } .ml-dark-mode .ml-review-popup-decline:hover { background: ${colors.darkMode.declineButtonHover}; } .ml-review-popup-success { background: ${colors.successBackground}; border: 1.5px solid ${colors.successBorder}; color: ${colors.successText}; border-radius: 12px; padding: 16px 24px; font-weight: 600; font-size: 16px; text-align: center; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; box-shadow: 0 1px 6px rgba(52,199,89,0.1); } .ml-dark-mode .ml-review-popup-success { background: ${colors.darkMode.successBackground}; border-color: ${colors.darkMode.successBorder}; color: ${colors.darkMode.successText}; } @media (max-width: 480px) { .ml-review-popup { margin: 0; border-radius: 20px 20px 0 0; } .ml-review-popup-heading { font-size: 20px; } .ml-review-popup-text { font-size: 15px; } } `; const styleSheet = document.createElement('style'); styleSheet.id = 'ml-review-popup-styles'; styleSheet.textContent = styles; document.head.appendChild(styleSheet); } // Increment style usage count ReviewPromptPopup.styleUsageCount++; } createPopup() { // Create overlay this.overlay = document.createElement('div'); this.overlay.className = 'ml-review-popup-overlay'; // Create popup container this.popup = document.createElement('div'); this.popup.className = 'ml-review-popup'; // Apply dark mode if enabled if (this.options.darkMode) { this.popup.classList.add('ml-dark-mode'); } // Create close button const closeBtn = document.createElement('button'); closeBtn.className = 'ml-review-popup-close'; closeBtn.innerHTML = '&times;'; // Use HTML entity for more reliable display closeBtn.addEventListener('click', () => this.hide(true)); // Create content container const content = document.createElement('div'); content.className = 'ml-review-popup-content'; // Create heading const heading = document.createElement('h2'); heading.className = 'ml-review-popup-heading'; heading.textContent = this.options.heading; // Create image (if provided) let image = null; if (this.options.image) { image = document.createElement('img'); image.className = 'ml-review-popup-image'; image.src = this.options.image; image.alt = 'App icon'; } // Create text const text = document.createElement('p'); text.className = 'ml-review-popup-text'; text.textContent = this.options.text; // Create buttons container const buttonsContainer = document.createElement('div'); buttonsContainer.className = 'ml-review-popup-buttons'; // Create accept button const acceptBtn = document.createElement('button'); acceptBtn.className = 'ml-review-popup-button ml-review-popup-accept'; acceptBtn.textContent = this.options.acceptText; acceptBtn.addEventListener('click', () => { // Track accept action before triggering review flow this.trackEvent('ml_review_popup_accepted', { event_category: 'conversion' }); this.triggerReviewFlow(); }); // Create decline button (only if declineText is provided) let declineBtn = null; if (this.options.declineText && this.options.declineText.trim() !== '') { declineBtn = document.createElement('button'); declineBtn.className = 'ml-review-popup-button ml-review-popup-decline'; declineBtn.textContent = this.options.declineText; declineBtn.addEventListener('click', () => { // Track decline action this.trackEvent('ml_review_popup_declined', { event_category: 'engagement' }); this.options.onDecline(); this.hide(); }); } // Assemble the popup content.appendChild(heading); if (image) content.appendChild(image); content.appendChild(text); buttonsContainer.appendChild(acceptBtn); if (declineBtn) buttonsContainer.appendChild(declineBtn); content.appendChild(buttonsContainer); this.popup.appendChild(closeBtn); this.popup.appendChild(content); // Add to DOM but keep hidden document.body.appendChild(this.overlay); document.body.appendChild(this.popup); } triggerReviewFlow() { try { // Set "never show again" flag for accepted reviews this.setNeverShowAgain(); // Call user's onAccept callback this.options.onAccept(); // Check if we're in debug mode or if native functions are available if (this.options.debugMode) { console.log('[ReviewPopup Debug] Debug mode - simulating review flow trigger'); console.log('[ReviewPopup Debug] Never show again flag set'); // In debug mode, just hide the popup after showing success this.hide(); return; } // Safety check: Ensure native bridge is available if (typeof nativeFunctions === 'undefined') { throw new Error('nativeFunctions not available'); } // Safety check: Ensure the specific function exists if (typeof nativeFunctions.triggerReviewFlow !== 'function') { throw new Error('triggerReviewFlow not available'); } // Call the native function to trigger review flow nativeFunctions.triggerReviewFlow(); // Hide the popup immediately since review is a one-time action this.hide(); } catch (e) { console.error('[ReviewPopup] Error triggering review flow:', e); // Fallback: just hide the popup this.hide(); } } bindEvents() { // Store handler references for cleanup this._onOverlayClick = (e) => { if (e.target === this.overlay) { this.hide(true); } }; this.overlay.addEventListener('click', this._onOverlayClick); this._onKeyDown = (e) => { if (e.key === 'Escape' && this.isVisible) { this.hide(true); } }; document.addEventListener('keydown', this._onKeyDown); } show(isAutoTrigger = false) { if (this.isVisible) return; // Check if popup was properly initialized (DOM elements exist) if (!this.popup || !this.overlay) { if (this.options.debugMode) { console.warn('[ReviewPopup] Cannot show popup - not properly initialized (DOM elements missing)'); } console.error('[ReviewPopup] Popup not initialized - show() called but DOM elements do not exist'); return; } // Check if another popup is already visible globally if (ReviewPromptPopup.currentlyVisible) { console.log('[ReviewPopup] Another popup is already visible - blocking this popup'); return; } // Check if this is an auto-trigger and we've already auto-triggered this session if (isAutoTrigger && this.hasAutoTriggeredThisSession) { console.log('[ReviewPopup] Auto-trigger blocked - already shown once this session'); return; } // Mark that we've auto-triggered if this is an auto-trigger if (isAutoTrigger) { this.hasAutoTriggeredThisSession = true; } this.isVisible = true; // Set global state to indicate a popup is now visible ReviewPromptPopup.currentlyVisible = true; // Increment session count when showing this.incrementSessionCount(); // Show overlay first requestAnimationFrame(() => { this.overlay.classList.add('ml-visible'); // Track that popup is displayed this.trackEvent('ml_review_popup_displayed', { event_category: 'engagement', trigger_type: isAutoTrigger ? 'auto' : 'manual' }); // Then show popup with slight delay setTimeout(() => { this.popup.classList.add('ml-visible'); }, 50); }); // Prevent body scroll document.body.style.overflow = 'hidden'; } hide(userInitiated = false) { if (!this.isVisible) return; this.isVisible = false; // Clear global state to allow other popups to show ReviewPromptPopup.currentlyVisible = false; // Track user-initiated close events if (userInitiated) { this.trackEvent('ml_review_popup_closed', { event_category: 'engagement', close_method: 'user_action' }); } // Only manipulate DOM if elements exist if (this.popup) { this.popup.classList.remove('ml-visible'); } // Only manipulate overlay if it exists if (this.overlay) { setTimeout(() => { this.overlay.classList.remove('ml-visible'); }, 200); } // Always restore body scroll (critical for fixing the bug) setTimeout(() => { document.body.style.overflow = ''; if (this.options.debugMode) { console.log('[ReviewPopup] Body scroll restored in hide()'); } }, 300); // Call onClose callback this.options.onClose(); } destroy() { // Clear global state if this popup was visible if (this.isVisible) { ReviewPromptPopup.currentlyVisible = false; this.isVisible = false; } // Always restore body scroll as a safety measure // This prevents the bug where body scroll gets stuck disabled document.body.style.overflow = ''; if (this.options.debugMode) { console.log('[ReviewPopup] Body scroll restored in destroy() as safety measure'); } // Remove event listeners if (this.overlay && this._onOverlayClick) { this.overlay.removeEventListener('click', this._onOverlayClick); } if (this._onKeyDown) { document.removeEventListener('keydown', this._onKeyDown); } if (this.overlay) { this.overlay.remove(); } if (this.popup) { this.popup.remove(); } // Decrement style usage count and remove style if last popup ReviewPromptPopup.styleUsageCount = Math.max(0, ReviewPromptPopup.styleUsageCount - 1); if (ReviewPromptPopup.styleUsageCount === 0) { const styles = document.getElementById('ml-review-popup-styles'); if (styles) { styles.remove(); } } } } // Global function for easy usage window.createReviewPopup = function(options) { return new ReviewPromptPopup(options); };