@mobiloud/push-banner-widget
Version:
Smart, responsive banner widget for enabling push notifications in MobiLoud apps. Features session limiting, mobile optimization, and zero dependencies.
810 lines (683 loc) • 23.8 kB
JavaScript
/**
* Push Notification Banner Widget
* A smart banner that prompts users to enable push notifications
* Integrated with MobiLoud app platform
*
* Usage:
* const banner = createPushBanner({
* heading: "Get 10% OFF your next order",
* text: "Enable push notifications to receive your unique coupon code",
* position: 'top',
* sessionLimit: 3,
* debugMode: false
* });
*/
// Inject CSS styles for the banner widget
(function injectBannerStyles() {
// Check if styles are already injected
if (document.getElementById('push-banner-widget-styles')) return;
const styles = `
/* Push Notification Banner Widget Styles */
.push-banner {
width: 100%;
box-sizing: border-box;
cursor: pointer;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 9999;
/* Default styling */
background-color: #e3f2fd;
color: #475569;
/* Prevent text selection */
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
/* Banner content container */
.push-banner-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
min-height: 60px;
max-width: 1200px;
margin: 0 auto;
position: relative;
transition: all 0.3s ease;
}
/* Text content wrapper */
.push-banner-text {
flex: 1;
min-width: 0; /* Allows text to shrink */
margin-right: 16px;
transition: all 0.3s ease;
}
/* Banner heading */
.push-banner-heading {
font-size: 15px;
font-weight: 600;
margin: 0 0 4px 0;
line-height: 1.3;
transition: all 0.3s ease;
}
/* Banner description text */
.push-banner-description {
font-size: 14px;
font-weight: 400;
margin: 0;
line-height: 1.4;
opacity: 0.9;
transition: all 0.3s ease;
}
/* Success message styling */
.push-banner-success {
font-size: 15px;
font-weight: 500;
margin: 0;
line-height: 1.4;
transition: all 0.3s ease;
}
/* Chevron icon */
.push-banner-icon {
flex-shrink: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.8;
transition: all 0.3s ease;
}
/* CSS Chevron */
.push-banner-chevron {
width: 8px;
height: 8px;
border-right: 2px solid currentColor;
border-bottom: 2px solid currentColor;
transform: rotate(-45deg);
transition: transform 0.3s ease;
}
/* Icon animation on hover */
.push-banner:hover .push-banner-icon {
opacity: 1;
transform: translateX(2px);
}
/* Active state */
.push-banner:active {
transform: scale(0.995);
}
/* Position: Fixed Top */
.push-banner.position-top {
position: fixed;
top: 0;
left: 0;
right: 0;
transform: translateY(-100%);
}
.push-banner.position-top.visible {
transform: translateY(0);
}
/* Position: Fixed Bottom */
.push-banner.position-bottom {
position: fixed;
bottom: 0;
left: 0;
right: 0;
transform: translateY(100%);
}
.push-banner.position-bottom.visible {
transform: translateY(0);
}
/* Position: Within wrapper element */
.push-banner.position-wrapper {
position: relative;
opacity: 0;
transform: translateY(-10px);
}
.push-banner.position-wrapper.visible {
opacity: 1;
transform: translateY(0);
}
/* Success state modifications */
.push-banner.success-state .push-banner-heading {
opacity: 0;
height: 0;
margin: 0;
overflow: hidden;
transform: translateY(-10px);
}
.push-banner.success-state .push-banner-description {
opacity: 0;
height: 0;
margin: 0;
overflow: hidden;
transform: translateY(-10px);
}
.push-banner.success-state .push-banner-icon {
opacity: 0;
transform: translateX(20px);
}
.push-banner.success-state .push-banner-success {
opacity: 1;
height: auto;
transform: translateY(0);
}
/* Hide success message initially */
.push-banner-success {
opacity: 0;
height: 0;
overflow: hidden;
transform: translateY(10px);
}
/* Hiding animation */
.push-banner.hiding {
opacity: 0;
}
.push-banner.hiding.position-top {
transform: translateY(-100%);
}
.push-banner.hiding.position-bottom {
transform: translateY(100%);
}
.push-banner.hiding.position-wrapper {
transform: translateY(-10px);
}
/* Tablet styles */
(max-width: 768px) {
.push-banner-content {
padding: 14px 16px;
min-height: 56px;
}
.push-banner-heading {
font-size: 15px;
margin-bottom: 2px;
}
.push-banner-description {
font-size: 14px;
}
.push-banner-success {
font-size: 15px;
}
.push-banner-text {
margin-right: 12px;
}
.push-banner-icon {
width: 20px;
height: 20px;
}
.push-banner-chevron {
width: 7px;
height: 7px;
}
}
/* Mobile styles */
(max-width: 480px) {
.push-banner-content {
padding: 12px 16px;
min-height: 52px;
}
.push-banner-heading {
font-size: 15px;
margin-bottom: 2px;
}
.push-banner-description {
font-size: 14px;
}
.push-banner-success {
font-size: 15px;
}
.push-banner-text {
margin-right: 10px;
}
.push-banner-icon {
width: 18px;
height: 18px;
}
.push-banner-chevron {
width: 6px;
height: 6px;
}
}
/* Small mobile styles */
(max-width: 320px) {
.push-banner-content {
padding: 10px 12px;
}
.push-banner-heading {
font-size: 14px;
}
.push-banner-description {
font-size: 13px;
}
.push-banner-success {
font-size: 14px;
}
.push-banner-text {
margin-right: 8px;
}
.push-banner-chevron {
width: 5px;
height: 5px;
}
}
/* Accessibility */
(prefers-reduced-motion: reduce) {
.push-banner,
.push-banner *,
.push-banner:hover .push-banner-icon {
transition: none;
transform: none;
}
.push-banner:hover .push-banner-icon {
transform: none;
}
}
/* Focus styles for accessibility */
.push-banner:focus {
outline: 2px solid rgba(255, 255, 255, 0.5);
outline-offset: -2px;
}
/* Dark mode support */
(prefers-color-scheme: dark) {
.push-banner {
background-color: #1a202c;
color: #e2e8f0;
}
}
/* High contrast mode support */
(prefers-contrast: high) {
.push-banner {
border: 2px solid currentColor;
}
.push-banner-icon {
opacity: 1;
}
.push-banner-description {
opacity: 1;
}
}
`;
const styleSheet = document.createElement('style');
styleSheet.id = 'push-banner-widget-styles';
styleSheet.textContent = styles;
document.head.appendChild(styleSheet);
})();
class PushNotificationBanner {
constructor(options = {}) {
this.options = {
heading: options.heading || "Get 10% OFF your next order",
text: options.text || "Enable push notifications to receive your unique coupon code",
successMessage: options.successMessage || "Thank you for subscribing! Use APPLOVER coupon to get your 10% discount",
position: options.position || 'top', // 'top', 'bottom', or { element: '#selector' }
displayMode: options.displayMode || 'fixed', // 'fixed', 'relative', 'scroll'
sessionLimit: options.sessionLimit || 3,
backgroundColor: options.backgroundColor || '#e3f2fd',
headingColor: options.headingColor || '#1e293b',
textColor: options.textColor || '#475569',
autoHideSuccess: options.autoHideSuccess !== false, // Default true
debugMode: options.debugMode || false,
onAccept: options.onAccept || (() => {}),
onShow: options.onShow || (() => {}),
onHide: options.onHide || (() => {}),
allowedUrls: options.allowedUrls || null
};
this.isVisible = false;
this.banner = null;
this.currentPushStatus = null;
this.statusCheckInterval = null;
this.sessionKey = 'pushBanner_' + this.generateSessionKey();
this._scrollHandler = null;
this.init();
}
generateSessionKey() {
// Generate a unique key based on position and content
const position = typeof this.options.position === 'object'
? this.options.position.element
: this.options.position;
// Create a simple hash from the combined string
const combined = position + this.options.heading + this.options.text;
let hash = 0;
for (let i = 0; i < combined.length; i++) {
const char = combined.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32-bit integer
}
// Convert to positive number and then to alphanumeric string
return Math.abs(hash).toString(36).slice(0, 10);
}
init() {
// Check if current URL is allowed
if (!this.isUrlAllowed()) {
return;
}
// Check session limit
if (!this.checkSessionLimit()) {
return;
}
// Check if should show banner
if (!this.shouldShowBanner()) {
return;
}
this.createBanner();
this.setupPushStatusMonitoring();
this.show();
}
isInApp() {
// Check if user agent contains "canvas" (case insensitive)
return navigator.userAgent.toLowerCase().includes('canvas');
}
isPushEnabled() {
try {
// Check MobiLoud push notification status
return !!(window.mobiloudAppInfo && window.mobiloudAppInfo.pushSubscribed);
} catch (e) {
// If can't determine status, assume disabled
return false;
}
}
shouldShowBanner() {
// In debug mode, bypass all checks
if (this.options.debugMode) {
console.log('[PushBanner Debug] Debug mode enabled - bypassing app and push checks');
return true;
}
// Check if user is in the app
if (!this.isInApp()) {
console.log('[PushBanner] Not in app context - banner will not show');
return false;
}
// Check if push notifications are already enabled
if (this.isPushEnabled()) {
console.log('[PushBanner] Push notifications already enabled - banner will not show');
return false;
}
return true;
}
checkSessionLimit() {
try {
const sessionCount = parseInt(sessionStorage.getItem(this.sessionKey) || '0', 10);
if (sessionCount >= this.options.sessionLimit) {
console.log(`[PushBanner] Session limit reached (${sessionCount}/${this.options.sessionLimit})`);
return false;
}
return true;
} catch (e) {
console.error('[PushBanner] Error checking session limit:', e);
return true; // Fallback to showing banner
}
}
incrementSessionCount() {
try {
const currentCount = parseInt(sessionStorage.getItem(this.sessionKey) || '0', 10);
sessionStorage.setItem(this.sessionKey, (currentCount + 1).toString());
} catch (e) {
console.error('[PushBanner] Error incrementing session count:', e);
}
}
setupPushStatusMonitoring() {
// Set up real-time push status monitoring
this.currentPushStatus = this.isPushEnabled();
// Override the global push status change callback
const originalCallback = window.mlPushStatusChanged;
window.mlPushStatusChanged = (isSubscribed) => {
// Call original callback if it existed
if (typeof originalCallback === 'function') {
originalCallback(isSubscribed);
}
// Update our status and UI
this.currentPushStatus = isSubscribed;
this.updateUIForPushStatus(isSubscribed);
};
// Poll for status changes as backup
this.statusCheckInterval = setInterval(() => {
const newStatus = this.isPushEnabled();
if (newStatus !== this.currentPushStatus) {
this.currentPushStatus = newStatus;
this.updateUIForPushStatus(newStatus);
}
}, 1000);
}
updateUIForPushStatus(isEnabled) {
if (!this.banner || !isEnabled) return;
// Show success message
this.showSuccessMessage();
// Auto-hide after 3 seconds if enabled
if (this.options.autoHideSuccess) {
setTimeout(() => {
this.hide();
}, 3000);
}
}
showSuccessMessage() {
if (!this.banner) return;
this.banner.classList.add('success-state');
// Update the success message text
const successElement = this.banner.querySelector('.push-banner-success');
if (successElement) {
successElement.textContent = this.options.successMessage;
}
}
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
if (pattern.includes('*')) {
const regexPattern = pattern
.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
.replace(/\*/g, '.*');
const regex = new RegExp(`^${regexPattern}$`, 'i');
return regex.test(currentUrl) || regex.test(currentPath);
}
// Regex pattern
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
return currentUrl.includes(pattern) || currentPath.includes(pattern);
});
}
createBanner() {
// Create banner container
this.banner = document.createElement('div');
this.banner.className = 'push-banner';
// Add position/displayMode class
if (this.options.displayMode === 'relative') {
this.banner.classList.add('position-relative');
} else if (this.options.displayMode === 'scroll') {
this.banner.classList.add('position-top'); // Use fixed top, but control visibility with scroll
this.banner.classList.add('scroll-activated');
} else if (typeof this.options.position === 'string') {
this.banner.classList.add(`position-${this.options.position}`);
} else {
this.banner.classList.add('position-wrapper');
}
// Apply custom colors
this.banner.style.backgroundColor = this.options.backgroundColor;
// Create content container
const content = document.createElement('div');
content.className = 'push-banner-content';
// Create text container
const textContainer = document.createElement('div');
textContainer.className = 'push-banner-text';
// Create heading
const heading = document.createElement('div');
heading.className = 'push-banner-heading';
heading.textContent = this.options.heading;
heading.style.color = this.options.headingColor;
// Create description
const description = document.createElement('div');
description.className = 'push-banner-description';
description.textContent = this.options.text;
description.style.color = this.options.textColor;
// Create success message (hidden initially)
const successMessage = document.createElement('div');
successMessage.className = 'push-banner-success';
successMessage.textContent = this.options.successMessage;
successMessage.style.color = this.options.headingColor;
// Create icon
const icon = document.createElement('div');
icon.className = 'push-banner-icon';
icon.innerHTML = '<div class="push-banner-chevron"></div>';
icon.style.color = this.options.textColor;
// Assemble the banner
textContainer.appendChild(heading);
textContainer.appendChild(description);
textContainer.appendChild(successMessage);
content.appendChild(textContainer);
content.appendChild(icon);
this.banner.appendChild(content);
// Add click handler
this.banner.addEventListener('click', () => {
this.triggerPushPrompt();
});
// Add keyboard support
this.banner.setAttribute('tabindex', '0');
this.banner.setAttribute('role', 'button');
this.banner.setAttribute('aria-label', `${this.options.heading}. ${this.options.text}`);
this.banner.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.triggerPushPrompt();
}
});
// Position the banner
this.positionBanner();
}
positionBanner() {
if (this.options.displayMode === 'relative') {
// Insert as first child of body (normal flow)
document.body.insertBefore(this.banner, document.body.firstChild);
} else if (this.options.displayMode === 'scroll') {
// Fixed at top, but only visible after scroll
document.body.appendChild(this.banner);
this.banner.classList.remove('visible'); // Hide initially
this._scrollHandler = () => {
if (window.scrollY > 0) {
if (!this.isVisible) {
this.show();
}
} else {
if (this.isVisible) {
this.hide();
}
}
};
window.addEventListener('scroll', this._scrollHandler);
// Initial check
this._scrollHandler();
} else if (typeof this.options.position === 'string') {
// Fixed positioning (default)
document.body.appendChild(this.banner);
} else {
// Position within specified element
const targetElement = document.querySelector(this.options.position.element);
if (targetElement) {
targetElement.appendChild(this.banner);
} else {
console.error(`[PushBanner] Target element "${this.options.position.element}" not found`);
// Fallback to body
document.body.appendChild(this.banner);
}
}
}
triggerPushPrompt() {
try {
// Call user's onAccept callback first
this.options.onAccept();
// Check if we're in debug mode
if (this.options.debugMode) {
console.log('[PushBanner Debug] Debug mode - simulating push prompt acceptance');
setTimeout(() => {
this.updateUIForPushStatus(true);
}, 1000);
return;
}
// Safety checks for native functions
if (typeof nativeFunctions === 'undefined') {
throw new Error('nativeFunctions not available');
}
if (typeof nativeFunctions.triggerPushPrompt !== 'function') {
throw new Error('triggerPushPrompt not available');
}
// Call the native function
nativeFunctions.triggerPushPrompt();
} catch (e) {
console.error('[PushBanner] Error triggering push prompt:', e);
}
}
show() {
if (this.isVisible || !this.banner) return;
this.isVisible = true;
// Increment session count
this.incrementSessionCount();
// Show banner with animation
requestAnimationFrame(() => {
this.banner.classList.add('visible');
document.body.classList.add('push-banner-visible');
});
// Call onShow callback
this.options.onShow();
}
hide() {
if (!this.isVisible || !this.banner) return;
this.isVisible = false;
// Hide banner with animation
this.banner.classList.add('hiding');
document.body.classList.remove('push-banner-visible');
setTimeout(() => {
if (this.banner && this.banner.parentNode) {
this.banner.parentNode.removeChild(this.banner);
}
// For scroll mode, re-insert the banner so it can show again after scroll up
if (this.options.displayMode === 'scroll') {
document.body.appendChild(this.banner);
this.banner.classList.remove('hiding', 'visible');
}
}, 300);
// Call onHide callback
this.options.onHide();
}
destroy() {
// Clean up status monitoring
if (this.statusCheckInterval) {
clearInterval(this.statusCheckInterval);
}
// Remove scroll event if needed
if (this._scrollHandler) {
window.removeEventListener('scroll', this._scrollHandler);
this._scrollHandler = null;
}
// Remove banner
if (this.banner && this.banner.parentNode) {
this.banner.parentNode.removeChild(this.banner);
}
document.body.classList.remove('push-banner-visible');
this.isVisible = false;
this.banner = null;
}
}
// Global function for easy usage
window.createPushBanner = function(options) {
return new PushNotificationBanner(options);
};
// Auto-initialize if window.pushBannerConfig exists
if (typeof window.pushBannerConfig !== 'undefined') {
window.pushBanner = new PushNotificationBanner(window.pushBannerConfig);
}