@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
JavaScript
/**
* 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};
}
(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 = '×'; // 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);
};