PluginProbe
Product Labels, Quick View, Buy Now, Pre-Orders, Frequently Bought Together & More for WooCommerce – Merchant / 2.3.1
Product Labels, Quick View, Buy Now, Pre-Orders, Frequently Bought Together & More for WooCommerce – Merchant v2.3.1
2.3.2 2.3.1 2.3.0 2.2.8 2.2.7 trunk 1.10.0 1.10.1 1.10.2 1.10.3 1.10.4 1.10.5 1.11.0 1.11.1 1.11.2 1.6 1.7 1.8 1.8.1 1.8.2 1.8.3 1.9.0 1.9.1 1.9.10 1.9.11 All 60 releases
merchant / assets / js / modules / inactive-tab-message / inactive-tab-message.js

inactive-tab-message.js in Product Labels, Quick View, Buy Now, Pre-Orders, Frequently Bought Together & More for WooCommerce – Merchant 2.3.1, at assets/js/modules/inactive-tab-message/inactive-tab-message.js

499 lines 17.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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);