| 1 |
/** |
| 2 |
* Merchant Inactive Tab Message. |
| 3 |
* |
| 4 |
* Handles tab title change, dynamic variables, message rotation, |
| 5 |
* favicon swap, and return-to-tab welcome message. |
| 6 |
*/ |
| 7 |
|
| 8 |
'use strict'; |
| 9 |
|
| 10 |
var merchant = merchant || {}; |
| 11 |
merchant.modules = merchant.modules || {}; |
| 12 |
(function ($) { |
| 13 |
merchant.modules.inactiveTabMessage = { |
| 14 |
// State. |
| 15 |
defaultTitle: '', |
| 16 |
originalFaviconElements: [], |
| 17 |
faviconSentinel: null, |
| 18 |
faviconSwapped: false, |
| 19 |
rotationTimer: null, |
| 20 |
returnTimer: null, |
| 21 |
scrollTimer: null, |
| 22 |
scrollDelayTimer: null, |
| 23 |
currentRotationIndex: 0, |
| 24 |
cartCount: 0, |
| 25 |
cartTotalRaw: 0, |
| 26 |
cartTotal: '', |
| 27 |
siteName: '', |
| 28 |
settings: {}, |
| 29 |
/** |
| 30 |
* Initialize the module. |
| 31 |
*/ |
| 32 |
init: function init() { |
| 33 |
var s = merchant.setting || {}; |
| 34 |
|
| 35 |
// Read settings. |
| 36 |
this.settings = { |
| 37 |
message: s.inactive_tab_message || '', |
| 38 |
abandonedMessage: s.inactive_tab_abandoned_message || '', |
| 39 |
highValueMessage: s.inactive_tab_high_value_message || '', |
| 40 |
highValueThreshold: parseFloat(s.inactive_tab_high_value_threshold) || 0, |
| 41 |
enableRotation: parseInt(s.inactive_tab_enable_rotation, 10) || 0, |
| 42 |
rotationMessages: s.inactive_tab_rotation_messages || [], |
| 43 |
rotationInterval: (parseInt(s.inactive_tab_rotation_interval, 10) || 3) * 1000, |
| 44 |
enableFavicon: parseInt(s.inactive_tab_enable_favicon, 10) || 0, |
| 45 |
faviconType: s.inactive_tab_favicon_type || 'emoji', |
| 46 |
faviconEmoji: s.inactive_tab_favicon_emoji || '', |
| 47 |
faviconUrl: s.inactive_tab_favicon_url || '', |
| 48 |
returnMessage: s.inactive_tab_return_message || '', |
| 49 |
returnDuration: (parseInt(s.inactive_tab_return_duration, 10) || 2) * 1000, |
| 50 |
enableScroll: parseInt(s.inactive_tab_enable_scroll, 10) || 0 |
| 51 |
}; |
| 52 |
this.cartCount = parseInt(s.inactive_tab_cart_count, 10) || 0; |
| 53 |
this.cartTotal = s.inactive_tab_cart_total || ''; |
| 54 |
this.cartTotalRaw = parseFloat(s.inactive_tab_cart_total_raw) || 0; |
| 55 |
this.siteName = s.inactive_tab_site_name || ''; |
| 56 |
this.defaultTitle = document.title; |
| 57 |
|
| 58 |
// Prepare favicon sentinel element for swap (created once, never recreated). |
| 59 |
this.faviconSentinel = document.createElement('link'); |
| 60 |
this.faviconSentinel.rel = 'icon'; |
| 61 |
|
| 62 |
// Cart fragment tracking. |
| 63 |
this.bindCartEvents(); |
| 64 |
|
| 65 |
// Visibility change. |
| 66 |
document.addEventListener('visibilitychange', this.onVisibilityChange.bind(this)); |
| 67 |
}, |
| 68 |
// ── Cart Tracking ───────────────────────────────────── |
| 69 |
|
| 70 |
/** |
| 71 |
* Listen for WooCommerce cart AJAX events. |
| 72 |
*/ |
| 73 |
bindCartEvents: function bindCartEvents() { |
| 74 |
var self = this; |
| 75 |
$(document.body).on('added_to_cart removed_from_cart updated_wc_div', function (event, data) { |
| 76 |
if (data && data['.merchant_cart_count'] !== undefined) { |
| 77 |
self.cartCount = parseInt(data['.merchant_cart_count'], 10) || 0; |
| 78 |
self.cartTotalRaw = parseFloat(data['.merchant_cart_total_raw']) || self.cartTotalRaw; |
| 79 |
|
| 80 |
// Use pre-formatted total from PHP if available, otherwise format in JS. |
| 81 |
if (data['.merchant_cart_total']) { |
| 82 |
self.cartTotal = data['.merchant_cart_total']; |
| 83 |
} else { |
| 84 |
self.cartTotal = self.formatCurrency(self.cartTotalRaw); |
| 85 |
} |
| 86 |
} else { |
| 87 |
// Cart page fallback. |
| 88 |
self.cartCount = $('.woocommerce-cart-form tr.cart_item').length; |
| 89 |
} |
| 90 |
}); |
| 91 |
}, |
| 92 |
// ── Visibility Handler ──────────────────────────────── |
| 93 |
|
| 94 |
/** |
| 95 |
* Handle tab visibility change. |
| 96 |
*/ |
| 97 |
onVisibilityChange: function onVisibilityChange() { |
| 98 |
if (document.hidden) { |
| 99 |
this.onTabHidden(); |
| 100 |
} else { |
| 101 |
this.onTabVisible(); |
| 102 |
} |
| 103 |
}, |
| 104 |
/** |
| 105 |
* Tab became hidden. |
| 106 |
*/ |
| 107 |
onTabHidden: function onTabHidden() { |
| 108 |
// Swap favicon if enabled. |
| 109 |
if (this.settings.enableFavicon) { |
| 110 |
this.swapFavicon(); |
| 111 |
} |
| 112 |
|
| 113 |
// Start rotation or show single message. |
| 114 |
if (this.settings.enableRotation && this.settings.rotationMessages.length > 0) { |
| 115 |
this.startRotation(); |
| 116 |
} else { |
| 117 |
var self = this; |
| 118 |
var msg = this.selectMessage(); |
| 119 |
if (msg) { |
| 120 |
// Loop: scroll → restart → scroll → … (indefinitely while hidden). |
| 121 |
var _loop = function loop() { |
| 122 |
self.setTitle(msg, _loop); |
| 123 |
}; |
| 124 |
_loop(); |
| 125 |
} |
| 126 |
} |
| 127 |
}, |
| 128 |
/** |
| 129 |
* Tab became visible again. |
| 130 |
*/ |
| 131 |
onTabVisible: function onTabVisible() { |
| 132 |
// Restore favicon. |
| 133 |
if (this.settings.enableFavicon) { |
| 134 |
this.restoreFavicon(); |
| 135 |
} |
| 136 |
|
| 137 |
// Stop scroll ticker. |
| 138 |
this.stopScroll(); |
| 139 |
|
| 140 |
// Stop rotation. |
| 141 |
this.stopRotation(); |
| 142 |
|
| 143 |
// Show return message or restore title. |
| 144 |
if (this.settings.returnMessage) { |
| 145 |
this.showReturnMessage(); |
| 146 |
} else { |
| 147 |
document.title = this.defaultTitle; |
| 148 |
} |
| 149 |
}, |
| 150 |
// ── Message Selection ───────────────────────────────── |
| 151 |
|
| 152 |
/** |
| 153 |
* Select the appropriate message based on cart state. |
| 154 |
* Three tiers: empty → has-items → high-value. |
| 155 |
* |
| 156 |
* @return {string} The selected message. |
| 157 |
*/ |
| 158 |
selectMessage: function selectMessage() { |
| 159 |
if (this.cartCount === 0) { |
| 160 |
return this.settings.message; |
| 161 |
} |
| 162 |
|
| 163 |
// High-value check. |
| 164 |
if (this.settings.highValueThreshold > 0 && this.cartTotalRaw >= this.settings.highValueThreshold && this.settings.highValueMessage) { |
| 165 |
return this.settings.highValueMessage; |
| 166 |
} |
| 167 |
return this.settings.abandonedMessage; |
| 168 |
}, |
| 169 |
// ── Dynamic Variables ───────────────────────────────── |
| 170 |
|
| 171 |
/** |
| 172 |
* Interpolate {cart_count}, {cart_total}, {site_name} in a message. |
| 173 |
* |
| 174 |
* @param {string} message The message template. |
| 175 |
* @return {string} The interpolated message. |
| 176 |
*/ |
| 177 |
interpolate: function interpolate(message) { |
| 178 |
if (!message) { |
| 179 |
return ''; |
| 180 |
} |
| 181 |
return message.replace(/\{cart_count\}/g, this.cartCount).replace(/\{cart_total\}/g, this.cartTotal).replace(/\{site_name\}/g, this.siteName); |
| 182 |
}, |
| 183 |
/** |
| 184 |
* Set document.title with interpolation and entity decoding. |
| 185 |
* When scroll is enabled, starts the ticker instead of setting directly. |
| 186 |
* |
| 187 |
* @param {string} message The raw message. |
| 188 |
* @param {Function} onComplete Optional callback fired after one full scroll cycle. |
| 189 |
* @param {boolean} skipScroll If true, always set title statically (used for return message). |
| 190 |
*/ |
| 191 |
setTitle: function setTitle(message, onComplete, skipScroll) { |
| 192 |
var resolved = this.interpolate(message).replaceAll(''', "'"); |
| 193 |
if (this.settings.enableScroll && !skipScroll) { |
| 194 |
this.startScroll(resolved, onComplete); |
| 195 |
} else { |
| 196 |
this.stopScroll(); |
| 197 |
document.title = resolved; |
| 198 |
} |
| 199 |
}, |
| 200 |
// ── Scroll (Ticker / Marquee) ───────────────────────── |
| 201 |
|
| 202 |
/** |
| 203 |
* Detect if a string is predominantly RTL (Arabic, Hebrew, etc.). |
| 204 |
* |
| 205 |
* Covers Arabic, Hebrew, Thaana, N'Ko, Samaritan and their |
| 206 |
* Unicode presentation forms. |
| 207 |
* |
| 208 |
* @param {string} text The message string. |
| 209 |
* @return {boolean} True if RTL. |
| 210 |
*/ |
| 211 |
isRTL: function isRTL(text) { |
| 212 |
return /[\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC]/.test(text); |
| 213 |
}, |
| 214 |
/** |
| 215 |
* Start a consuming scroll ticker for the given message. |
| 216 |
* Removes one character per tick from the leading edge; direction is RTL-aware. |
| 217 |
* |
| 218 |
* @param {string} text Resolved message text. |
| 219 |
* @param {Function} onComplete Optional callback fired after one full scroll cycle. |
| 220 |
*/ |
| 221 |
startScroll: function startScroll(text, onComplete) { |
| 222 |
this.stopScroll(); |
| 223 |
|
| 224 |
// Don't scroll short messages — they fit in the tab without truncating. |
| 225 |
// Browser tabs typically show ~12-15 characters before truncation. |
| 226 |
var SCROLL_THRESHOLD = 15; |
| 227 |
if (text.length <= SCROLL_THRESHOLD) { |
| 228 |
document.title = text; |
| 229 |
|
| 230 |
// Short message with callback: display for rotationInterval, then advance. |
| 231 |
if (onComplete) { |
| 232 |
this.scrollDelayTimer = setTimeout(onComplete, this.settings.rotationInterval); |
| 233 |
} |
| 234 |
return; |
| 235 |
} |
| 236 |
var self = this; |
| 237 |
var rtl = this.isRTL(text); |
| 238 |
var pos = 0; |
| 239 |
var isMultiWord = text.indexOf(' ') !== -1; |
| 240 |
|
| 241 |
// Fixed scroll speed — browsers throttle background tab timers to ~1s, |
| 242 |
// so configurable speed has no practical effect. |
| 243 |
var SCROLL_SPEED = 1000; |
| 244 |
document.title = text; |
| 245 |
this.scrollTimer = setInterval(function () { |
| 246 |
pos += 1; |
| 247 |
|
| 248 |
// Full cycle complete — all characters consumed. |
| 249 |
if (pos >= text.length) { |
| 250 |
clearInterval(self.scrollTimer); |
| 251 |
self.scrollTimer = null; |
| 252 |
if (onComplete) { |
| 253 |
onComplete(); |
| 254 |
} |
| 255 |
return; |
| 256 |
} |
| 257 |
var remaining; |
| 258 |
if (rtl) { |
| 259 |
remaining = text.slice(0, text.length - pos); |
| 260 |
} else { |
| 261 |
remaining = text.slice(pos); |
| 262 |
} |
| 263 |
|
| 264 |
// Multi-word text: once only the last word remains, complete immediately. |
| 265 |
if (isMultiWord && remaining.indexOf(' ') === -1) { |
| 266 |
clearInterval(self.scrollTimer); |
| 267 |
self.scrollTimer = null; |
| 268 |
if (onComplete) { |
| 269 |
onComplete(); |
| 270 |
} |
| 271 |
return; |
| 272 |
} |
| 273 |
document.title = remaining; |
| 274 |
}, SCROLL_SPEED); |
| 275 |
}, |
| 276 |
/** |
| 277 |
* Stop the scroll ticker and any short-message delay timer. |
| 278 |
*/ |
| 279 |
stopScroll: function stopScroll() { |
| 280 |
if (this.scrollTimer) { |
| 281 |
clearInterval(this.scrollTimer); |
| 282 |
this.scrollTimer = null; |
| 283 |
} |
| 284 |
if (this.scrollDelayTimer) { |
| 285 |
clearTimeout(this.scrollDelayTimer); |
| 286 |
this.scrollDelayTimer = null; |
| 287 |
} |
| 288 |
}, |
| 289 |
// ── Message Rotation ────────────────────────────────── |
| 290 |
|
| 291 |
/** |
| 292 |
* Start cycling through rotation messages. |
| 293 |
* When scroll is active, each message finishes one full scroll cycle |
| 294 |
* before advancing. Otherwise uses a fixed interval timer. |
| 295 |
*/ |
| 296 |
startRotation: function startRotation() { |
| 297 |
var self = this; |
| 298 |
var msgs = this.settings.rotationMessages; |
| 299 |
this.currentRotationIndex = 0; |
| 300 |
if (this.settings.enableScroll) { |
| 301 |
// Chain: scroll current message → on complete → advance → next. |
| 302 |
var _showCurrent = function showCurrent() { |
| 303 |
self.setTitle(msgs[self.currentRotationIndex], function () { |
| 304 |
self.currentRotationIndex = (self.currentRotationIndex + 1) % msgs.length; |
| 305 |
_showCurrent(); |
| 306 |
}); |
| 307 |
}; |
| 308 |
_showCurrent(); |
| 309 |
} else { |
| 310 |
this.setTitle(msgs[0]); |
| 311 |
this.rotationTimer = setInterval(function () { |
| 312 |
self.currentRotationIndex = (self.currentRotationIndex + 1) % msgs.length; |
| 313 |
self.setTitle(msgs[self.currentRotationIndex]); |
| 314 |
}, this.settings.rotationInterval); |
| 315 |
} |
| 316 |
}, |
| 317 |
/** |
| 318 |
* Stop rotation timer and any active scroll ticker. |
| 319 |
*/ |
| 320 |
stopRotation: function stopRotation() { |
| 321 |
if (this.rotationTimer) { |
| 322 |
clearInterval(this.rotationTimer); |
| 323 |
this.rotationTimer = null; |
| 324 |
} |
| 325 |
this.stopScroll(); |
| 326 |
this.currentRotationIndex = 0; |
| 327 |
}, |
| 328 |
// ── Favicon Management (using favico.js) ───────────── |
| 329 |
|
| 330 |
/** |
| 331 |
* Swap favicon to the configured alternative. |
| 332 |
* Gracefully skips on Safari due to caching issues. |
| 333 |
*/ |
| 334 |
swapFavicon: function swapFavicon() { |
| 335 |
// Safari detection — skip favicon swap. |
| 336 |
if (this.isSafari()) { |
| 337 |
return; |
| 338 |
} |
| 339 |
var href = ''; |
| 340 |
|
| 341 |
// Map string keys to emoji characters. |
| 342 |
var emojiMap = { |
| 343 |
wave: '👋', |
| 344 |
bell: '🔔', |
| 345 |
cart: '🛒', |
| 346 |
clock: '⏰', |
| 347 |
alert: '❗', |
| 348 |
money: '💰', |
| 349 |
fire: '🔥', |
| 350 |
star: '⭐' |
| 351 |
}; |
| 352 |
if (this.settings.faviconType === 'image' && this.settings.faviconUrl) { |
| 353 |
href = this.settings.faviconUrl; |
| 354 |
} else if (this.settings.faviconType === 'emoji' && this.settings.faviconEmoji) { |
| 355 |
var emoji = emojiMap[this.settings.faviconEmoji] || this.settings.faviconEmoji; |
| 356 |
href = this.emojiToDataUrl(emoji); |
| 357 |
} |
| 358 |
if (href) { |
| 359 |
this.setFaviconHref(href); |
| 360 |
} |
| 361 |
}, |
| 362 |
/** |
| 363 |
* Restore the original favicon links. |
| 364 |
* Detaches the sentinel and re-appends all original link elements. |
| 365 |
*/ |
| 366 |
restoreFavicon: function restoreFavicon() { |
| 367 |
if (this.isSafari()) { |
| 368 |
return; |
| 369 |
} |
| 370 |
if (!this.faviconSwapped) { |
| 371 |
return; |
| 372 |
} |
| 373 |
|
| 374 |
// Detach the sentinel. |
| 375 |
if (this.faviconSentinel.parentNode) { |
| 376 |
this.faviconSentinel.parentNode.removeChild(this.faviconSentinel); |
| 377 |
} |
| 378 |
|
| 379 |
// Re-attach originals. |
| 380 |
for (var i = 0; i < this.originalFaviconElements.length; i++) { |
| 381 |
document.head.appendChild(this.originalFaviconElements[i]); |
| 382 |
} |
| 383 |
this.faviconSwapped = false; |
| 384 |
}, |
| 385 |
/** |
| 386 |
* Set the favicon href using a reusable sentinel element. |
| 387 |
* On the first call, detaches all original favicon links and appends |
| 388 |
* the sentinel. On subsequent calls, just flips the sentinel's href. |
| 389 |
* |
| 390 |
* @param {string} href The favicon URL or data URL. |
| 391 |
*/ |
| 392 |
setFaviconHref: function setFaviconHref(href) { |
| 393 |
// First swap: detach originals and append sentinel. |
| 394 |
if (!this.faviconSwapped) { |
| 395 |
var existing = document.querySelectorAll('link[rel="icon"], link[rel="shortcut icon"]'); |
| 396 |
this.originalFaviconElements = []; |
| 397 |
for (var i = 0; i < existing.length; i++) { |
| 398 |
this.originalFaviconElements.push(existing[i]); |
| 399 |
existing[i].parentNode.removeChild(existing[i]); |
| 400 |
} |
| 401 |
document.head.appendChild(this.faviconSentinel); |
| 402 |
this.faviconSwapped = true; |
| 403 |
} |
| 404 |
|
| 405 |
// Cache-bust for regular URLs (not data URIs). |
| 406 |
if (href.indexOf('data:') !== 0) { |
| 407 |
href = href + (href.indexOf('?') === -1 ? '?' : '&') + 't=' + Date.now(); |
| 408 |
} |
| 409 |
this.faviconSentinel.setAttribute('href', href); |
| 410 |
}, |
| 411 |
/** |
| 412 |
* Convert an emoji character to a 32×32 canvas data URL. |
| 413 |
* |
| 414 |
* @param {string} emoji The emoji character. |
| 415 |
* @return {string} Data URL of the rendered emoji. |
| 416 |
*/ |
| 417 |
emojiToDataUrl: function emojiToDataUrl(emoji) { |
| 418 |
var canvas = document.createElement('canvas'); |
| 419 |
canvas.width = 32; |
| 420 |
canvas.height = 32; |
| 421 |
var ctx = canvas.getContext('2d'); |
| 422 |
ctx.font = '28px serif'; |
| 423 |
ctx.textAlign = 'center'; |
| 424 |
ctx.textBaseline = 'middle'; |
| 425 |
ctx.fillText(emoji, 16, 18); |
| 426 |
return canvas.toDataURL('image/png'); |
| 427 |
}, |
| 428 |
/** |
| 429 |
* Detect Safari browser. |
| 430 |
* |
| 431 |
* @return {boolean} True if Safari. |
| 432 |
*/ |
| 433 |
isSafari: function isSafari() { |
| 434 |
return /^((?!chrome|android).)*safari/i.test(navigator.userAgent); |
| 435 |
}, |
| 436 |
// ── Return Message ──────────────────────────────────── |
| 437 |
|
| 438 |
/** |
| 439 |
* Show a brief welcome-back message, then restore the original title. |
| 440 |
* Always displayed statically — scrolling a brief welcome-back defeats the purpose. |
| 441 |
*/ |
| 442 |
showReturnMessage: function showReturnMessage() { |
| 443 |
var self = this; |
| 444 |
|
| 445 |
// Skip scroll — return message is always static. |
| 446 |
this.setTitle(this.settings.returnMessage, null, true); |
| 447 |
|
| 448 |
// Clear previous return timer if any. |
| 449 |
if (this.returnTimer) { |
| 450 |
clearTimeout(this.returnTimer); |
| 451 |
} |
| 452 |
this.returnTimer = setTimeout(function () { |
| 453 |
document.title = self.defaultTitle; |
| 454 |
self.returnTimer = null; |
| 455 |
}, this.settings.returnDuration); |
| 456 |
}, |
| 457 |
// ── Currency Formatting ─────────────────────────────── |
| 458 |
|
| 459 |
/** |
| 460 |
* Format a numeric value as a currency string using WooCommerce settings. |
| 461 |
* Uses merchant.general for symbol, position, separators, and decimals. |
| 462 |
* |
| 463 |
* @param {number} amount The raw numeric amount. |
| 464 |
* @return {string} Formatted currency string. |
| 465 |
*/ |
| 466 |
formatCurrency: function formatCurrency(amount) { |
| 467 |
var g = merchant.general || {}; |
| 468 |
var symbol = g.wooCurrencySymbol || '$'; |
| 469 |
var position = g.wooCurrencyPosition || 'left'; |
| 470 |
var thousands = g.wooThousandsSeparator || ','; |
| 471 |
var decimal = g.wooDecimalSeparator || '.'; |
| 472 |
var decimals = parseInt(g.wooNumberOfDecimals, 10); |
| 473 |
if (isNaN(decimals)) { |
| 474 |
decimals = 2; |
| 475 |
} |
| 476 |
|
| 477 |
// Format the number. |
| 478 |
var fixed = parseFloat(amount).toFixed(decimals); |
| 479 |
var parts = fixed.split('.'); |
| 480 |
var intPart = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, thousands); |
| 481 |
var formatted = decimals > 0 ? intPart + decimal + parts[1] : intPart; |
| 482 |
|
| 483 |
// Apply currency position. |
| 484 |
switch (position) { |
| 485 |
case 'left': |
| 486 |
return symbol + formatted; |
| 487 |
case 'right': |
| 488 |
return formatted + symbol; |
| 489 |
case 'left_space': |
| 490 |
return symbol + ' ' + formatted; |
| 491 |
case 'right_space': |
| 492 |
return formatted + ' ' + symbol; |
| 493 |
default: |
| 494 |
return symbol + formatted; |
| 495 |
} |
| 496 |
} |
| 497 |
}; |
| 498 |
merchant.modules.inactiveTabMessage.init(); |
| 499 |
})(jQuery); |