From 4fc710829777f48bab7bc2e6def16ea0b0681bd9 Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Fri, 23 Jan 2026 13:57:26 -0500 Subject: [PATCH 1/5] fix: exclude mailto/tel URLs from PID parameter tracking Non-HTTP protocols like mailto:, tel:, and javascript: cannot handle query parameters. The ?pid= was being appended incorrectly, breaking these special links. Added regex check to skip any URL with a protocol that isn't http(s). --- assets/js/src/site/plugins/pum-url-tracking.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/assets/js/src/site/plugins/pum-url-tracking.js b/assets/js/src/site/plugins/pum-url-tracking.js index 6f2702e38..754bfdc47 100644 --- a/assets/js/src/site/plugins/pum-url-tracking.js +++ b/assets/js/src/site/plugins/pum-url-tracking.js @@ -86,6 +86,11 @@ return false; } + // Skip non-HTTP protocols (mailto:, tel:, javascript:, etc.). + if ( /^[a-z][a-z0-9+.-]*:/i.test( url ) && ! /^https?:/i.test( url ) ) { + return false; + } + // Handle relative URLs. if ( url.indexOf( '/' ) === 0 && url.indexOf( '//' ) !== 0 ) { return true; From b96c29702787c4f89fa279fb044ea15bb3b287c0 Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Fri, 23 Jan 2026 15:12:42 -0500 Subject: [PATCH 2/5] feat: add link click conversion tracking for external links - Fire beacon analytics for mailto, tel, and external link clicks - Track link clicks as conversions with eventData.type: 'link_click' - Add LinkClickTracking service with atomic counter updates - Preserve existing PID tracking for internal links only --- .../js/src/site/plugins/pum-url-tracking.js | 112 ++++++++- classes/Plugin/Core.php | 16 ++ classes/Services/LinkClickTracking.php | 228 ++++++++++++++++++ 3 files changed, 353 insertions(+), 3 deletions(-) create mode 100644 classes/Services/LinkClickTracking.php diff --git a/assets/js/src/site/plugins/pum-url-tracking.js b/assets/js/src/site/plugins/pum-url-tracking.js index 754bfdc47..f840630cb 100644 --- a/assets/js/src/site/plugins/pum-url-tracking.js +++ b/assets/js/src/site/plugins/pum-url-tracking.js @@ -12,6 +12,7 @@ * * Handles: * - Appending pid (popup ID) to internal links within popups + * - Firing click conversion beacons for external/special links (mailto, tel, etc.) */ window.PUM_URLTracking = { /** @@ -44,6 +45,9 @@ /** * Process all links within a popup to add tracking parameters. * + * Internal links get ?pid= appended (tracked via server redirect). + * External/special links get click handlers for beacon tracking. + * * @param {jQuery} $popup The popup element. * @param {number} pid The popup ID. */ @@ -54,9 +58,8 @@ var $link = $( this ), href = $link.attr( 'href' ); - // Only process internal URLs. if ( self.isInternalUrl( href ) ) { - // Start with base URL parameters. + // Internal URLs: Append PID parameter (tracked via server redirect). var urlParams = { pid: pid }; // Allow extensions to add additional parameters. @@ -71,7 +74,107 @@ var newHref = self.appendParamsToUrl( href, urlParams ); $link.attr( 'href', newHref ); + } else if ( self.shouldTrackClick( href ) ) { + // External/special links: Attach click handler for beacon tracking. + self.attachClickTracking( $link, pid, href ); + } + } ); + }, + + /** + * Determine if a link should have click tracking attached. + * + * @param {string} url The URL to check. + * @return {boolean} True if click should be tracked. + */ + shouldTrackClick: function ( url ) { + // Skip empty URLs. + if ( ! url ) { + return false; + } + + // Skip links already tracked via CTA system. + if ( url.indexOf( 'cta=' ) !== -1 ) { + return false; + } + + return true; + }, + + /** + * Get the link type for analytics segmentation. + * + * @param {string} url The URL to categorize. + * @return {string} Link type: 'external', 'mailto', 'tel', or 'other'. + */ + getLinkType: function ( url ) { + if ( url.indexOf( 'mailto:' ) === 0 ) { + return 'mailto'; + } + if ( url.indexOf( 'tel:' ) === 0 ) { + return 'tel'; + } + if ( url.indexOf( 'javascript:' ) === 0 ) { + return 'javascript'; + } + if ( url === '#' || url.indexOf( '#' ) === 0 ) { + return 'anchor'; + } + if ( url.indexOf( 'http' ) === 0 || url.indexOf( '//' ) === 0 ) { + return 'external'; + } + return 'other'; + }, + + /** + * Attach click tracking to a link element. + * + * Fires a conversion beacon when the link is clicked. + * + * @param {jQuery} $link The link element. + * @param {number} pid The popup ID. + * @param {string} href The link URL. + */ + attachClickTracking: function ( $link, pid, href ) { + var self = this; + + // Prevent duplicate handlers. + if ( $link.data( 'pum-click-tracked' ) ) { + return; + } + $link.data( 'pum-click-tracked', true ); + + $link.on( 'click.pum_tracking', function () { + // Only track if analytics is available and enabled. + if ( + ! window.PUM_Analytics || + ! window.pum_vars || + ! window.pum_vars.analytics_enabled + ) { + return; } + + var data = { + pid: pid, + event: 'conversion', + eventData: { + type: 'link_click', + url: href, + linkType: self.getLinkType( href ), + }, + }; + + // Allow extensions to modify click tracking data. + if ( window.PUM && window.PUM.hooks ) { + data = window.PUM.hooks.applyFilters( + 'popupMaker.popup.linkClickData', + data, + $link + ); + } + + // Fire beacon (sendBeacon queues even during navigation). + window.PUM_Analytics.beacon( data ); } ); }, @@ -87,7 +190,10 @@ } // Skip non-HTTP protocols (mailto:, tel:, javascript:, etc.). - if ( /^[a-z][a-z0-9+.-]*:/i.test( url ) && ! /^https?:/i.test( url ) ) { + if ( + /^[a-z][a-z0-9+.-]*:/i.test( url ) && + ! /^https?:/i.test( url ) + ) { return false; } diff --git a/classes/Plugin/Core.php b/classes/Plugin/Core.php index 06e7212e8..30c3f8c52 100644 --- a/classes/Plugin/Core.php +++ b/classes/Plugin/Core.php @@ -292,6 +292,18 @@ function () { } ); + $this->set( + 'link_click_tracking', + /** + * Get link click tracking service. + * + * @return \PopupMaker\Services\LinkClickTracking + */ + function ( $container ) { + return new \PopupMaker\Services\LinkClickTracking( $container ); + } + ); + do_action( 'popup_maker/register_services', $this ); } @@ -302,6 +314,10 @@ function () { */ protected function init_services() { $license = $this->get( 'license' ); + + // Initialize link click tracking. + $link_click_tracking = $this->get( 'link_click_tracking' ); + $link_click_tracking->init(); } /** diff --git a/classes/Services/LinkClickTracking.php b/classes/Services/LinkClickTracking.php new file mode 100644 index 000000000..7de340a2f --- /dev/null +++ b/classes/Services/LinkClickTracking.php @@ -0,0 +1,228 @@ +increment_site_count(); + + // Increment per-popup count. + $this->increment_popup_count( $popup_id ); + + /** + * Fires after a link click is tracked. + * + * @since X.X.X + * + * @param int $popup_id Popup ID. + * @param array $event_data Link click event data (url, linkType, etc.). + */ + do_action( 'popup_maker/link_click_tracked', $popup_id, $event_data ); + } + + /** + * Increment site-wide link click count. + * + * Uses atomic SQL update to prevent race conditions. + * + * @since X.X.X + * + * @return int New count after increment. + */ + protected function increment_site_count() { + global $wpdb; + + // Check if option exists; if not, create it with autoload disabled. + $exists = $wpdb->get_var( + $wpdb->prepare( + "SELECT option_id FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", + self::SITE_COUNT_KEY + ) + ); + + if ( ! $exists ) { + add_option( self::SITE_COUNT_KEY, 0, '', false ); + } + + // Atomic increment (prevents race condition). + $wpdb->query( + $wpdb->prepare( + "UPDATE {$wpdb->options} SET option_value = option_value + 1 WHERE option_name = %s", + self::SITE_COUNT_KEY + ) + ); + + wp_cache_delete( self::SITE_COUNT_KEY, 'options' ); + + return (int) get_option( self::SITE_COUNT_KEY, 0 ); + } + + /** + * Increment per-popup link click count. + * + * Uses atomic SQL update to prevent race conditions. + * + * @since X.X.X + * + * @param int $popup_id Popup post ID. + * @return int New count after increment. + */ + protected function increment_popup_count( $popup_id ) { + global $wpdb; + + $exists = $wpdb->get_var( + $wpdb->prepare( + "SELECT meta_id FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s LIMIT 1", + $popup_id, + self::POPUP_META_KEY + ) + ); + + if ( ! $exists ) { + add_post_meta( $popup_id, self::POPUP_META_KEY, 0, true ); + } + + // Atomic increment. + $wpdb->query( + $wpdb->prepare( + "UPDATE {$wpdb->postmeta} SET meta_value = meta_value + 1 WHERE post_id = %d AND meta_key = %s", + $popup_id, + self::POPUP_META_KEY + ) + ); + + wp_cache_delete( $popup_id, 'post_meta' ); + + return (int) get_post_meta( $popup_id, self::POPUP_META_KEY, true ); + } + + /** + * Get site-wide link click count. + * + * @since X.X.X + * + * @return int Total link clicks across all popups. + */ + public function get_site_count() { + return (int) get_option( self::SITE_COUNT_KEY, 0 ); + } + + /** + * Get link click count for a specific popup. + * + * @since X.X.X + * + * @param int $popup_id Popup post ID. + * @return int Link clicks for this popup. + */ + public function get_popup_count( $popup_id ) { + return (int) get_post_meta( $popup_id, self::POPUP_META_KEY, true ); + } + + /** + * Reset site-wide link click count. + * + * @since X.X.X + */ + public function reset_site_count() { + delete_option( self::SITE_COUNT_KEY ); + } + + /** + * Reset link click count for a specific popup. + * + * @since X.X.X + * + * @param int $popup_id Popup post ID. + */ + public function reset_popup_count( $popup_id ) { + delete_post_meta( $popup_id, self::POPUP_META_KEY ); + } +} From 183632eed37f40f28b076301e87b782d1a2c417c Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Fri, 23 Jan 2026 16:58:28 -0500 Subject: [PATCH 3/5] refactor: use %i placeholder for table identifiers (WP 6.2+) Modernizes SQL queries to use wpdb::prepare() %i placeholder for table name identifiers instead of PHP string interpolation. --- classes/Services/LinkClickTracking.php | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/classes/Services/LinkClickTracking.php b/classes/Services/LinkClickTracking.php index 7de340a2f..882a2c794 100644 --- a/classes/Services/LinkClickTracking.php +++ b/classes/Services/LinkClickTracking.php @@ -122,7 +122,8 @@ protected function increment_site_count() { // Check if option exists; if not, create it with autoload disabled. $exists = $wpdb->get_var( $wpdb->prepare( - "SELECT option_id FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", + 'SELECT option_id FROM %i WHERE option_name = %s LIMIT 1', + $wpdb->options, self::SITE_COUNT_KEY ) ); @@ -134,7 +135,8 @@ protected function increment_site_count() { // Atomic increment (prevents race condition). $wpdb->query( $wpdb->prepare( - "UPDATE {$wpdb->options} SET option_value = option_value + 1 WHERE option_name = %s", + 'UPDATE %i SET option_value = option_value + 1 WHERE option_name = %s', + $wpdb->options, self::SITE_COUNT_KEY ) ); @@ -159,7 +161,8 @@ protected function increment_popup_count( $popup_id ) { $exists = $wpdb->get_var( $wpdb->prepare( - "SELECT meta_id FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s LIMIT 1", + 'SELECT meta_id FROM %i WHERE post_id = %d AND meta_key = %s LIMIT 1', + $wpdb->postmeta, $popup_id, self::POPUP_META_KEY ) @@ -172,7 +175,8 @@ protected function increment_popup_count( $popup_id ) { // Atomic increment. $wpdb->query( $wpdb->prepare( - "UPDATE {$wpdb->postmeta} SET meta_value = meta_value + 1 WHERE post_id = %d AND meta_key = %s", + 'UPDATE %i SET meta_value = meta_value + 1 WHERE post_id = %d AND meta_key = %s', + $wpdb->postmeta, $popup_id, self::POPUP_META_KEY ) From 69917c54790b134f54387087d9be25d7f66cd4ce Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Fri, 23 Jan 2026 17:00:19 -0500 Subject: [PATCH 4/5] docs: add changelog entries for link click tracking feature --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17c5274dd..0cc02e936 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ **Features** +- Added link click conversion tracking for external and special links (mailto:, tel:, etc.) within popups. Clicks are tracked via analytics beacon and categorized by link type for conversion reporting. - Added [Beaver Builder Forms integration](https://wppopupmaker.com/form-integrations/beaver-builder/) for form submission tracking and conversion analytics. Supports Contact, Subscribe, and Login form modules. - Added [Bit Form integration](https://wppopupmaker.com/form-integrations/bit-form/) for form submission tracking and conversion analytics. - Added [Elementor Pro Forms integration](https://wppopupmaker.com/form-integrations/elementor-forms/) for form submission tracking and conversion analytics with support for targeting specific forms. @@ -21,6 +22,7 @@ **Fixes** +- Fixed mailto: and tel: links inside popups being incorrectly modified with tracking parameters, which broke email and phone links. - Fixed Fluent Forms integration fatal error when using double opt-in. Closes #1094. - Fixed Time Delay trigger settings tab displaying blank when switching from Click Trigger advanced tab. Closes #1109. - Fixed trigger modal "Add" button label not displaying due to incorrect i18n function usage. Props to @DAnn2012. From a14a65cd4f443cfbc520c6ae15cca5e14905c53c Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Fri, 23 Jan 2026 17:30:16 -0500 Subject: [PATCH 5/5] fix: run PID tracking at priority 0 to beat competing plugins Ensures template_redirect fires before other plugins that might intercept/redirect, preventing missed tracking events. --- CHANGELOG.md | 1 + classes/Controllers/CallToActions.php | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cc02e936..f61d47da6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ **Improvements** +- Improved PID tracking reliability by firing template_redirect at priority 0, ensuring tracking occurs before other plugins that might redirect. - Enhanced all Popup list views with sortable Enabled column and bulk enable/disable actions for easier management of multiple popups. - Block library assets (CSS) loading unnecessarily on all front-end pages. WordPress now automatically loads these styles only when Popup Maker blocks are actually rendered. - Enhanced ad-blocker bypass feature to obfuscate script and style element IDs (in addition to filenames) for improved bypass reliability. IDs now consistently use per site settings using either MD5 hashing or custom prefixes. diff --git a/classes/Controllers/CallToActions.php b/classes/Controllers/CallToActions.php index a5b8fd4fd..01e5a6fe4 100644 --- a/classes/Controllers/CallToActions.php +++ b/classes/Controllers/CallToActions.php @@ -24,7 +24,8 @@ class CallToActions extends Controller { * Initialize cta actions */ public function init() { - add_action( 'template_redirect', [ $this, 'template_redirect' ] ); + // Priority 0 ensures PID tracking fires before other plugins that might redirect. + add_action( 'template_redirect', [ $this, 'template_redirect' ], 0 ); add_action( 'popup_maker/cta_conversion', [ $this, 'track_cta_conversion' ], 10, 2 ); }